From 01358d100694bf4771d4d2beaca7710eddf9f4a0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 11:10:33 -0700 Subject: [PATCH 01/38] Persist provider-aware model selections - 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 --- .../orchestrationEngine.integration.test.ts | 29 +- .../Layers/CheckpointDiffQuery.test.ts | 7 +- .../Layers/CheckpointReactor.test.ts | 10 +- .../Layers/OrchestrationEngine.test.ts | 90 +++- .../Layers/ProjectionPipeline.test.ts | 85 +++- .../Layers/ProjectionPipeline.ts | 12 +- .../Layers/ProjectionSnapshotQuery.test.ts | 14 +- .../Layers/ProjectionSnapshotQuery.ts | 163 +++++-- .../Layers/ProviderCommandReactor.test.ts | 105 ++--- .../Layers/ProviderCommandReactor.ts | 87 ++-- .../Layers/ProviderRuntimeIngestion.test.ts | 35 +- .../orchestration/commandInvariants.test.ts | 30 +- .../decider.projectScripts.test.ts | 39 +- apps/server/src/orchestration/decider.ts | 18 +- .../src/orchestration/projector.test.ts | 45 +- apps/server/src/orchestration/projector.ts | 12 +- .../Layers/OrchestrationEventStore.test.ts | 2 +- .../persistence/Layers/ProjectionProjects.ts | 182 +++++--- .../persistence/Layers/ProjectionThreads.ts | 75 +++- apps/server/src/persistence/Migrations.ts | 4 + .../Migrations/016_ProjectionProviders.ts | 38 ++ .../017_ProjectionModelSelectionOptions.ts | 28 ++ .../Services/ProjectionProjects.ts | 4 +- .../persistence/Services/ProjectionThreads.ts | 3 +- apps/server/src/wsServer.test.ts | 40 +- apps/server/src/wsServer.ts | 16 +- apps/web/src/components/ChatView.browser.tsx | 76 ++-- apps/web/src/components/ChatView.logic.ts | 6 +- apps/web/src/components/ChatView.tsx | 151 ++++--- .../components/KeybindingsToast.browser.tsx | 10 +- apps/web/src/components/Sidebar.tsx | 5 +- .../chat/ClaudeTraitsPicker.browser.tsx | 13 +- .../components/chat/ClaudeTraitsPicker.tsx | 6 +- .../chat/CodexTraitsPicker.browser.tsx | 28 +- .../src/components/chat/CodexTraitsPicker.tsx | 7 +- .../CompactComposerControlsMenu.browser.tsx | 13 +- apps/web/src/composerDraftStore.test.ts | 255 +++++------ apps/web/src/composerDraftStore.ts | 316 ++++++-------- apps/web/src/hooks/useHandleNewThread.ts | 18 +- apps/web/src/store.test.ts | 64 ++- apps/web/src/store.ts | 65 +-- apps/web/src/types.ts | 7 +- apps/web/src/worktreeCleanup.test.ts | 5 +- apps/web/src/wsNativeApi.test.ts | 7 +- packages/contracts/src/orchestration.test.ts | 178 +++++++- packages/contracts/src/orchestration.ts | 403 ++++++++++++++++-- packages/shared/src/model.test.ts | 18 - packages/shared/src/model.ts | 17 - 48 files changed, 1938 insertions(+), 903 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/016_ProjectionProviders.ts create mode 100644 apps/server/src/persistence/Migrations/017_ProjectionModelSelectionOptions.ts diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index 10fb32a5ed..e3a79551d2 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -115,7 +115,10 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => projectId: PROJECT_ID, title: "Integration Project", workspaceRoot: harness.workspaceDir, - defaultModel, + defaultModelSelection: { + provider, + model: defaultModel, + }, createdAt, }); @@ -125,7 +128,10 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => threadId: THREAD_ID, projectId: PROJECT_ID, title: "Integration Thread", - model: defaultModel, + modelSelection: { + provider, + model: defaultModel, + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, @@ -151,7 +157,14 @@ const startTurn = (input: { text: input.text, attachments: [], }, - ...(input.provider !== undefined ? { provider: input.provider } : {}), + ...(input.provider !== undefined + ? { + modelSelection: { + provider: input.provider, + model: DEFAULT_MODEL_BY_PROVIDER[input.provider], + }, + } + : {}), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: nowIso(), @@ -254,7 +267,10 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( projectId: PROJECT_ID, title: "Integration Project", workspaceRoot: harness.workspaceDir, - defaultModel: "gpt-5.3-codex", + defaultModelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, createdAt, }); @@ -264,7 +280,10 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( threadId: THREAD_ID, projectId: PROJECT_ID, title: "Integration Thread", - model: "gpt-5.3-codex", + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", branch: null, diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index 2f79ea9d5a..daa6eb5f4c 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -31,7 +31,7 @@ function makeSnapshot(input: { id: input.projectId, title: "Project", workspaceRoot: input.workspaceRoot, - defaultModel: null, + defaultModelSelection: null, scripts: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", @@ -43,7 +43,10 @@ function makeSnapshot(input: { id: input.threadId, projectId: input.projectId, title: "Thread", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", branch: null, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 702e826a5f..69360ebf6a 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -282,7 +282,10 @@ describe("CheckpointReactor", () => { projectId: asProjectId("project-1"), title: "Test Project", workspaceRoot: options?.projectWorkspaceRoot ?? cwd, - defaultModel: "gpt-5-codex", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, createdAt, }), ); @@ -293,7 +296,10 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 0aa204d829..a1cbfa002d 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -72,7 +72,10 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-1"), title: "Project 1", workspaceRoot: "/tmp/project-1", - defaultModel: "gpt-5-codex", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, createdAt, }), ); @@ -83,7 +86,10 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, @@ -126,7 +132,10 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-replay"), title: "Replay Project", workspaceRoot: "/tmp/project-replay", - defaultModel: "gpt-5-codex", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, createdAt, }), ); @@ -137,7 +146,10 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-replay"), projectId: asProjectId("project-replay"), title: "replay", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, @@ -178,7 +190,10 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-stream"), title: "Stream Project", workspaceRoot: "/tmp/project-stream", - defaultModel: "gpt-5-codex", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, createdAt, }), ); @@ -199,7 +214,10 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-stream"), projectId: asProjectId("project-stream"), title: "domain-stream", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, @@ -233,7 +251,10 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-turn-diff"), title: "Turn Diff Project", workspaceRoot: "/tmp/project-turn-diff", - defaultModel: "gpt-5-codex", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, createdAt, }), ); @@ -244,7 +265,10 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-turn-diff"), projectId: asProjectId("project-turn-diff"), title: "Turn diff thread", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, @@ -344,7 +368,10 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-flaky"), title: "Flaky Project", workspaceRoot: "/tmp/project-flaky", - defaultModel: "gpt-5-codex", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, createdAt, }), ); @@ -357,7 +384,10 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-flaky-fail"), projectId: asProjectId("project-flaky"), title: "flaky-fail", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, @@ -374,7 +404,10 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-flaky-ok"), projectId: asProjectId("project-flaky"), title: "flaky-ok", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, @@ -428,7 +461,10 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-atomic"), title: "Atomic Project", workspaceRoot: "/tmp/project-atomic", - defaultModel: "gpt-5-codex", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, createdAt, }), ); @@ -439,7 +475,10 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-atomic"), projectId: asProjectId("project-atomic"), title: "atomic", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, @@ -563,7 +602,10 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-sync"), title: "Sync Project", workspaceRoot: "/tmp/project-sync", - defaultModel: "gpt-5-codex", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, createdAt, }), ); @@ -574,7 +616,10 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-sync"), projectId: asProjectId("project-sync"), title: "sync-before", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, @@ -642,7 +687,10 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-duplicate"), title: "Duplicate Project", workspaceRoot: "/tmp/project-duplicate", - defaultModel: "gpt-5-codex", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, createdAt, }), ); @@ -654,7 +702,10 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-duplicate"), projectId: asProjectId("project-duplicate"), title: "duplicate", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, @@ -671,7 +722,10 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-duplicate"), projectId: asProjectId("project-duplicate"), title: "duplicate", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 83ee080fbe..355f452d48 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -68,7 +68,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.makeUnsafe("project-1"), title: "Project 1", workspaceRoot: "/tmp/project-1", - defaultModel: null, + defaultModelSelection: null, scripts: [], createdAt: now, updatedAt: now, @@ -89,7 +89,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: ProjectId.makeUnsafe("project-1"), title: "Thread 1", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, runtimeMode: "full-access", branch: null, worktreePath: null, @@ -337,7 +340,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.makeUnsafe("project-clear-attachments"), title: "Project Clear Attachments", workspaceRoot: "/tmp/project-clear-attachments", - defaultModel: null, + defaultModelSelection: null, scripts: [], createdAt: now, updatedAt: now, @@ -358,7 +361,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { threadId: ThreadId.makeUnsafe("thread-clear-attachments"), projectId: ProjectId.makeUnsafe("project-clear-attachments"), title: "Thread Clear Attachments", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, runtimeMode: "full-access", branch: null, worktreePath: null, @@ -462,7 +468,7 @@ it.layer( projectId: ProjectId.makeUnsafe("project-overwrite"), title: "Project Overwrite", workspaceRoot: "/tmp/project-overwrite", - defaultModel: null, + defaultModelSelection: null, scripts: [], createdAt: now, updatedAt: now, @@ -483,7 +489,10 @@ it.layer( threadId: ThreadId.makeUnsafe("thread-overwrite"), projectId: ProjectId.makeUnsafe("project-overwrite"), title: "Thread Overwrite", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, runtimeMode: "full-access", branch: null, worktreePath: null, @@ -607,7 +616,7 @@ it.layer( projectId: ProjectId.makeUnsafe("project-rollback"), title: "Project Rollback", workspaceRoot: "/tmp/project-rollback", - defaultModel: null, + defaultModelSelection: null, scripts: [], createdAt: now, updatedAt: now, @@ -628,7 +637,10 @@ it.layer( threadId: ThreadId.makeUnsafe("thread-rollback"), projectId: ProjectId.makeUnsafe("project-rollback"), title: "Thread Rollback", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, runtimeMode: "full-access", branch: null, worktreePath: null, @@ -733,7 +745,7 @@ it.layer( projectId: ProjectId.makeUnsafe("project-revert-files"), title: "Project Revert Files", workspaceRoot: "/tmp/project-revert-files", - defaultModel: null, + defaultModelSelection: null, scripts: [], createdAt: now, updatedAt: now, @@ -754,7 +766,10 @@ it.layer( threadId, projectId: ProjectId.makeUnsafe("project-revert-files"), title: "Thread Revert Files", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, runtimeMode: "full-access", branch: null, worktreePath: null, @@ -938,7 +953,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta projectId: ProjectId.makeUnsafe("project-delete-files"), title: "Project Delete Files", workspaceRoot: "/tmp/project-delete-files", - defaultModel: null, + defaultModelSelection: null, scripts: [], createdAt: now, updatedAt: now, @@ -959,7 +974,10 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta threadId, projectId: ProjectId.makeUnsafe("project-delete-files"), title: "Thread Delete Files", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, runtimeMode: "full-access", branch: null, worktreePath: null, @@ -1098,7 +1116,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.makeUnsafe("project-a"), title: "Project A", workspaceRoot: "/tmp/project-a", - defaultModel: null, + defaultModelSelection: null, scripts: [], createdAt: now, updatedAt: now, @@ -1119,7 +1137,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { threadId: ThreadId.makeUnsafe("thread-a"), projectId: ProjectId.makeUnsafe("project-a"), title: "Thread A", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, runtimeMode: "full-access", branch: null, worktreePath: null, @@ -1222,7 +1243,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.makeUnsafe("project-empty"), title: "Project Empty", workspaceRoot: "/tmp/project-empty", - defaultModel: null, + defaultModelSelection: null, scripts: [], createdAt: now, updatedAt: now, @@ -1243,7 +1264,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { threadId: ThreadId.makeUnsafe("thread-empty"), projectId: ProjectId.makeUnsafe("project-empty"), title: "Thread Empty", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, runtimeMode: "full-access", branch: null, worktreePath: null, @@ -1359,7 +1383,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.makeUnsafe("project-conflict"), title: "Project Conflict", workspaceRoot: "/tmp/project-conflict", - defaultModel: null, + defaultModelSelection: null, scripts: [], createdAt: "2026-02-26T13:00:00.000Z", updatedAt: "2026-02-26T13:00:00.000Z", @@ -1380,7 +1404,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { threadId: ThreadId.makeUnsafe("thread-conflict"), projectId: ProjectId.makeUnsafe("project-conflict"), title: "Thread Conflict", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, runtimeMode: "full-access", branch: null, worktreePath: null, @@ -1500,7 +1527,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { projectId: ProjectId.makeUnsafe("project-revert"), title: "Project Revert", workspaceRoot: "/tmp/project-revert", - defaultModel: null, + defaultModelSelection: null, scripts: [], createdAt: "2026-02-26T12:00:00.000Z", updatedAt: "2026-02-26T12:00:00.000Z", @@ -1521,7 +1548,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { threadId: ThreadId.makeUnsafe("thread-revert"), projectId: ProjectId.makeUnsafe("project-revert"), title: "Thread Revert", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, runtimeMode: "full-access", branch: null, worktreePath: null, @@ -1837,7 +1867,10 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { projectId: ProjectId.makeUnsafe("project-live"), title: "Live Project", workspaceRoot: "/tmp/project-live", - defaultModel: "gpt-5-codex", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, createdAt, }); @@ -1872,7 +1905,10 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { projectId: ProjectId.makeUnsafe("project-scripts"), title: "Scripts Project", workspaceRoot: "/tmp/project-scripts", - defaultModel: "gpt-5-codex", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, createdAt, }); @@ -1889,7 +1925,10 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { runOnWorktreeCreate: false, }, ], - defaultModel: "gpt-5", + defaultModelSelection: { + provider: "codex", + model: "gpt-5", + }, }); const projectRows = yield* sql<{ diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 0651dab646..ce68d654ef 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -362,7 +362,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { projectId: event.payload.projectId, title: event.payload.title, workspaceRoot: event.payload.workspaceRoot, - defaultModel: event.payload.defaultModel, + defaultModelSelection: event.payload.defaultModelSelection, scripts: event.payload.scripts, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -383,8 +383,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { ...(event.payload.workspaceRoot !== undefined ? { workspaceRoot: event.payload.workspaceRoot } : {}), - ...(event.payload.defaultModel !== undefined - ? { defaultModel: event.payload.defaultModel } + ...(event.payload.defaultModelSelection !== undefined + ? { defaultModelSelection: event.payload.defaultModelSelection } : {}), ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}), updatedAt: event.payload.updatedAt, @@ -420,7 +420,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { threadId: event.payload.threadId, projectId: event.payload.projectId, title: event.payload.title, - model: event.payload.model, + modelSelection: event.payload.modelSelection, runtimeMode: event.payload.runtimeMode, interactionMode: event.payload.interactionMode, branch: event.payload.branch, @@ -442,7 +442,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { yield* projectionThreadRepository.upsert({ ...existingRow.value, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), - ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), + ...(event.payload.modelSelection !== undefined + ? { modelSelection: event.payload.modelSelection } + : {}), ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}), ...(event.payload.worktreePath !== undefined ? { worktreePath: event.payload.worktreePath } diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index b5b73fd6e0..172b90f057 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -34,6 +34,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { project_id, title, workspace_root, + default_provider, default_model, scripts_json, created_at, @@ -44,6 +45,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 'project-1', 'Project 1', '/tmp/project-1', + 'codex', 'gpt-5-codex', '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]', '2026-02-24T00:00:00.000Z', @@ -57,6 +59,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { thread_id, project_id, title, + provider, model, branch, worktree_path, @@ -69,6 +72,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 'thread-1', 'project-1', 'Thread 1', + 'codex', 'gpt-5-codex', NULL, NULL, @@ -234,7 +238,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { id: asProjectId("project-1"), title: "Project 1", workspaceRoot: "/tmp/project-1", - defaultModel: "gpt-5-codex", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, scripts: [ { id: "script-1", @@ -254,7 +261,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { id: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread 1", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: "default", runtimeMode: "full-access", branch: null, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 849d2fa3b6..1dab0bf037 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1,6 +1,7 @@ import { ChatAttachment, IsoDateTime, + ModelSelection, MessageId, NonNegativeInt, OrchestrationCheckpointFile, @@ -42,12 +43,19 @@ import { type ProjectionSnapshotQueryShape, } from "../Services/ProjectionSnapshotQuery.ts"; -const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); -const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( - Struct.assign({ - scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), - }), -); +const decodeReadModelSync = Schema.decodeUnknownSync(OrchestrationReadModel); +const ProjectionProjectDbRowSchema = Schema.Struct({ + projectId: ProjectionProject.fields.projectId, + title: ProjectionProject.fields.title, + workspaceRoot: ProjectionProject.fields.workspaceRoot, + defaultProvider: Schema.NullOr(Schema.Literals(["codex", "claudeAgent"])), + defaultModel: Schema.NullOr(Schema.String), + defaultModelOptions: Schema.NullOr(Schema.fromJsonString(Schema.Unknown)), + scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), + createdAt: ProjectionProject.fields.createdAt, + updatedAt: ProjectionProject.fields.updatedAt, + deletedAt: ProjectionProject.fields.deletedAt, +}); const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( Struct.assign({ isStreaming: Schema.Number, @@ -55,7 +63,22 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( }), ); const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; -const ProjectionThreadDbRowSchema = ProjectionThread; +const ProjectionThreadDbRowSchema = Schema.Struct({ + threadId: ProjectionThread.fields.threadId, + projectId: ProjectionThread.fields.projectId, + title: ProjectionThread.fields.title, + provider: Schema.Literals(["codex", "claudeAgent"]), + model: Schema.String, + modelOptions: Schema.NullOr(Schema.fromJsonString(Schema.Unknown)), + runtimeMode: ProjectionThread.fields.runtimeMode, + interactionMode: ProjectionThread.fields.interactionMode, + branch: ProjectionThread.fields.branch, + worktreePath: ProjectionThread.fields.worktreePath, + latestTurnId: ProjectionThread.fields.latestTurnId, + createdAt: ProjectionThread.fields.createdAt, + updatedAt: ProjectionThread.fields.updatedAt, + deletedAt: ProjectionThread.fields.deletedAt, +}); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( Struct.assign({ payload: Schema.fromJsonString(Schema.Unknown), @@ -80,6 +103,7 @@ const ProjectionLatestTurnDbRowSchema = Schema.Struct({ sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), }); const ProjectionStateDbRowSchema = ProjectionState; +const decodeModelSelectionSync = Schema.decodeUnknownSync(ModelSelection); const REQUIRED_SNAPSHOT_PROJECTORS = [ ORCHESTRATION_PROJECTOR_NAMES.projects, @@ -129,6 +153,26 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st : toPersistenceSqlError(sqlOperation)(cause); } +const decodeModelSelection = (input: { + readonly provider: "codex" | "claudeAgent"; + readonly model: string; + readonly options: unknown; +}) => + Effect.try({ + try: () => + decodeModelSelectionSync({ + provider: input.provider, + model: input.model, + ...(input.options !== null && input.options !== undefined + ? { options: input.options } + : {}), + }), + catch: (error) => + toPersistenceDecodeError("ProjectionSnapshotQuery.decodeModelSelection")( + error as Schema.SchemaError, + ), + }); + const makeProjectionSnapshotQuery = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -141,7 +185,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { project_id AS "projectId", title, workspace_root AS "workspaceRoot", + default_provider AS "defaultProvider", default_model AS "defaultModel", + default_model_options_json AS "defaultModelOptions", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -160,7 +206,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + provider, model, + model_options_json AS "modelOptions", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", branch, @@ -531,36 +579,65 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); } - const projects: Array = projectRows.map((row) => ({ - id: row.projectId, - title: row.title, - workspaceRoot: row.workspaceRoot, - defaultModel: row.defaultModel, - scripts: row.scripts, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - })); - - const threads: Array = threadRows.map((row) => ({ - id: row.threadId, - projectId: row.projectId, - title: row.title, - model: row.model, - runtimeMode: row.runtimeMode, - interactionMode: row.interactionMode, - branch: row.branch, - worktreePath: row.worktreePath, - latestTurn: latestTurnByThread.get(row.threadId) ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - messages: messagesByThread.get(row.threadId) ?? [], - proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], - activities: activitiesByThread.get(row.threadId) ?? [], - checkpoints: checkpointsByThread.get(row.threadId) ?? [], - session: sessionsByThread.get(row.threadId) ?? null, - })); + const projects: ReadonlyArray = yield* Effect.forEach( + projectRows, + (row) => { + const defaultModelSelectionEffect: Effect.Effect< + OrchestrationProject["defaultModelSelection"], + ProjectionRepositoryError + > = + row.defaultProvider === null || row.defaultModel === null + ? Effect.succeed(null) + : decodeModelSelection({ + provider: row.defaultProvider, + model: row.defaultModel, + options: row.defaultModelOptions, + }); + + return defaultModelSelectionEffect.pipe( + Effect.map((defaultModelSelection) => ({ + id: row.projectId, + title: row.title, + workspaceRoot: row.workspaceRoot, + defaultModelSelection, + scripts: row.scripts, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + })), + ); + }, + ); + + const threads: ReadonlyArray = yield* Effect.forEach( + threadRows, + (row) => + decodeModelSelection({ + provider: row.provider, + model: row.model, + options: row.modelOptions, + }).pipe( + Effect.map((modelSelection) => ({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + messages: messagesByThread.get(row.threadId) ?? [], + proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], + activities: activitiesByThread.get(row.threadId) ?? [], + checkpoints: checkpointsByThread.get(row.threadId) ?? [], + session: sessionsByThread.get(row.threadId) ?? null, + })), + ), + ); const snapshot = { snapshotSequence: computeSnapshotSequence(stateRows), @@ -569,11 +646,13 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updatedAt: updatedAt ?? new Date(0).toISOString(), }; - return yield* decodeReadModel(snapshot).pipe( - Effect.mapError( - toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"), - ), - ); + return yield* Effect.try({ + try: () => decodeReadModelSync(snapshot), + catch: (error) => + toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel")( + error as Schema.SchemaError, + ), + }); }), ) .pipe( diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 5a7084a61b..c7b232b81c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -63,6 +63,9 @@ async function waitFor( return poll(); } +const inferTestProviderForModel = (model: string): "codex" | "claudeAgent" => + model.startsWith("claude-") ? "claudeAgent" : "codex"; + describe("ProviderCommandReactor", () => { let runtime: ManagedRuntime.ManagedRuntime< OrchestrationEngineService | ProviderCommandReactor, @@ -101,6 +104,7 @@ describe("ProviderCommandReactor", () => { const { stateDir } = deriveServerPathsSync(baseDir, undefined); createdStateDirs.add(stateDir); const threadModel = input?.threadModel ?? "gpt-5-codex"; + const threadProvider = inferTestProviderForModel(threadModel); const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); let nextSessionIndex = 1; const runtimeSessions: Array = []; @@ -242,7 +246,10 @@ describe("ProviderCommandReactor", () => { projectId: asProjectId("project-1"), title: "Provider Project", workspaceRoot: "/tmp/provider-project", - defaultModel: threadModel, + defaultModelSelection: { + provider: threadProvider, + model: threadModel, + }, createdAt: now, }), ); @@ -253,7 +260,10 @@ describe("ProviderCommandReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", - model: threadModel, + modelSelection: { + provider: threadProvider, + model: threadModel, + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, @@ -328,10 +338,10 @@ describe("ProviderCommandReactor", () => { text: "hello fast mode", attachments: [], }, - provider: "codex", - model: "gpt-5.3-codex", - modelOptions: { - codex: { + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + options: { reasoningEffort: "high", fastMode: true, }, @@ -380,10 +390,10 @@ describe("ProviderCommandReactor", () => { text: "hello with effort", attachments: [], }, - provider: "claudeAgent", - model: "claude-sonnet-4-6", - modelOptions: { - claudeAgent: { + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + options: { effort: "max", }, }, @@ -430,10 +440,10 @@ describe("ProviderCommandReactor", () => { text: "hello with fast mode", attachments: [], }, - provider: "claudeAgent", - model: "claude-opus-4-6", - modelOptions: { - claudeAgent: { + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { fastMode: true, }, }, @@ -518,7 +528,10 @@ describe("ProviderCommandReactor", () => { text: "hello claude", attachments: [], }, - provider: "claudeAgent", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -552,50 +565,41 @@ describe("ProviderCommandReactor", () => { }); }); - it("rejects a turn when the requested model belongs to a different provider", async () => { + it("allows custom model strings on the thread's explicit provider", async () => { const harness = await createHarness(); const now = new Date().toISOString(); await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-model-provider-mismatch"), + commandId: CommandId.makeUnsafe("cmd-turn-start-custom-model"), threadId: ThreadId.makeUnsafe("thread-1"), message: { - messageId: asMessageId("user-message-model-provider-mismatch"), + messageId: asMessageId("user-message-custom-model"), role: "user", text: "hello", attachments: [], }, - model: "claude-sonnet-4-6", + modelSelection: { + provider: "codex", + model: "claude-sonnet-4-6", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }), ); - await waitFor(async () => { - const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find( - (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), - ); - return ( - thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? - false - ); - }); - - expect(harness.startSession).not.toHaveBeenCalled(); - expect(harness.sendTurn).not.toHaveBeenCalled(); + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); - const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); - expect( - thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), - ).toMatchObject({ - payload: { - detail: expect.stringContaining("does not belong to provider 'codex'"), - }, + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + provider: "codex", + model: "claude-sonnet-4-6", + }); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + threadId: ThreadId.makeUnsafe("thread-1"), + model: "claude-sonnet-4-6", }); }); @@ -660,10 +664,10 @@ describe("ProviderCommandReactor", () => { text: "first claude turn", attachments: [], }, - provider: "claudeAgent", - model: "claude-sonnet-4-6", - modelOptions: { - claudeAgent: { + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + options: { effort: "medium", }, }, @@ -687,10 +691,10 @@ describe("ProviderCommandReactor", () => { text: "second claude turn", attachments: [], }, - provider: "claudeAgent", - model: "claude-sonnet-4-6", - modelOptions: { - claudeAgent: { + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + options: { effort: "max", }, }, @@ -835,7 +839,10 @@ describe("ProviderCommandReactor", () => { text: "second", attachments: [], }, - provider: "claudeAgent", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 57405ca515..5147e32aa3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -3,6 +3,7 @@ import { CommandId, DEFAULT_GIT_TEXT_GENERATION_MODEL, EventId, + type ModelSelection, type OrchestrationEvent, type ProviderModelOptions, ProviderKind, @@ -26,7 +27,6 @@ import { ProviderCommandReactor, type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; -import { inferProviderForModel } from "@t3tools/shared/model"; type ProviderIntentEvent = Extract< OrchestrationEvent, @@ -81,6 +81,17 @@ const sameModelOptions = ( 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): boolean { const error = Cause.squash(cause); if (Schema.is(ProviderAdapterRequestError)(error)) { @@ -215,8 +226,7 @@ const make = Effect.gen(function* () { threadId: ThreadId, createdAt: string, options?: { - readonly provider?: ProviderKind; - readonly model?: string; + readonly modelSelection?: ModelSelection; readonly modelOptions?: ProviderModelOptions; readonly providerOptions?: ProviderStartOptions; }, @@ -233,26 +243,23 @@ const make = Effect.gen(function* () { ) ? thread.session.providerName : undefined; - const threadProvider: ProviderKind = currentProvider ?? inferProviderForModel(thread.model); - if (options?.provider !== undefined && options.provider !== threadProvider) { - return yield* new ProviderAdapterRequestError({ - provider: threadProvider, - method: "thread.turn.start", - detail: `Thread '${threadId}' is bound to provider '${threadProvider}' and cannot switch to '${options.provider}'.`, - }); - } + const requestedModelSelection = options?.modelSelection; + const threadProvider: ProviderKind = currentProvider ?? thread.modelSelection.provider; if ( - options?.model !== undefined && - inferProviderForModel(options.model, threadProvider) !== threadProvider + requestedModelSelection !== undefined && + requestedModelSelection.provider !== threadProvider ) { return yield* new ProviderAdapterRequestError({ provider: threadProvider, method: "thread.turn.start", - detail: `Model '${options.model}' does not belong to provider '${threadProvider}' for thread '${threadId}'.`, + detail: `Thread '${threadId}' is bound to provider '${threadProvider}' and cannot switch to '${requestedModelSelection.provider}'.`, }); } const preferredProvider: ProviderKind = currentProvider ?? threadProvider; - const desiredModel = options?.model ?? thread.model; + const desiredModelSelection = requestedModelSelection ?? thread.modelSelection; + const desiredModel = desiredModelSelection.model; + const desiredModelOptions = + options?.modelOptions ?? toProviderModelOptions(desiredModelSelection); const effectiveCwd = resolveThreadWorkspaceCwd({ thread, projects: readModel.projects, @@ -269,12 +276,10 @@ const make = Effect.gen(function* () { }) => providerService.startSession(threadId, { threadId, - ...((input?.provider ?? preferredProvider) - ? { provider: input?.provider ?? preferredProvider } - : {}), + ...(preferredProvider ? { provider: preferredProvider } : {}), ...(effectiveCwd ? { cwd: effectiveCwd } : {}), ...(desiredModel ? { model: desiredModel } : {}), - ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}), + ...(desiredModelOptions !== undefined ? { modelOptions: desiredModelOptions } : {}), ...(options?.providerOptions !== undefined ? { providerOptions: options.providerOptions } : {}), @@ -303,13 +308,16 @@ const make = Effect.gen(function* () { if (existingSessionThreadId) { const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode; const providerChanged = - options?.provider !== undefined && options.provider !== currentProvider; + requestedModelSelection !== undefined && + requestedModelSelection.provider !== currentProvider; const activeSession = yield* resolveActiveSession(existingSessionThreadId); const sessionModelSwitch = currentProvider === undefined ? "in-session" : (yield* providerService.getCapabilities(currentProvider)).sessionModelSwitch; - const modelChanged = options?.model !== undefined && options.model !== activeSession?.model; + const modelChanged = + requestedModelSelection !== undefined && + requestedModelSelection.model !== activeSession?.model; const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "restart-session"; const previousModelOptions = threadModelOptions.get(threadId); const shouldRestartForModelOptionsChange = @@ -334,7 +342,7 @@ const make = Effect.gen(function* () { threadId, existingSessionThreadId, currentProvider, - desiredProvider: options?.provider ?? currentProvider, + desiredProvider: desiredModelSelection.provider, currentRuntimeMode: thread.session?.runtimeMode, desiredRuntimeMode: thread.runtimeMode, runtimeModeChanged, @@ -344,10 +352,9 @@ const make = Effect.gen(function* () { shouldRestartForModelOptionsChange, hasResumeCursor: resumeCursor !== undefined, }); - const restartedSession = yield* startProviderSession({ - ...(resumeCursor !== undefined ? { resumeCursor } : {}), - ...(options?.provider !== undefined ? { provider: options.provider } : {}), - }); + const restartedSession = yield* startProviderSession( + resumeCursor !== undefined ? { resumeCursor } : undefined, + ); yield* Effect.logInfo("provider command reactor restarted provider session", { threadId, previousSessionId: existingSessionThreadId, @@ -359,9 +366,7 @@ const make = Effect.gen(function* () { return restartedSession.threadId; } - const startedSession = yield* startProviderSession( - options?.provider !== undefined ? { provider: options.provider } : undefined, - ); + const startedSession = yield* startProviderSession(undefined); yield* bindSessionToThread(startedSession); return startedSession.threadId; }); @@ -370,8 +375,7 @@ const make = Effect.gen(function* () { readonly threadId: ThreadId; readonly messageText: string; readonly attachments?: ReadonlyArray; - readonly provider?: ProviderKind; - readonly model?: string; + readonly modelSelection?: ModelSelection; readonly modelOptions?: ProviderModelOptions; readonly providerOptions?: ProviderStartOptions; readonly interactionMode?: "default" | "plan"; @@ -382,8 +386,7 @@ const make = Effect.gen(function* () { return; } yield* ensureSessionForThread(input.threadId, input.createdAt, { - ...(input.provider !== undefined ? { provider: input.provider } : {}), - ...(input.model !== undefined ? { model: input.model } : {}), + ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), }); @@ -404,7 +407,8 @@ const make = Effect.gen(function* () { activeSession === undefined ? "in-session" : (yield* providerService.getCapabilities(activeSession.provider)).sessionModelSwitch; - const modelForTurn = sessionModelSwitch === "unsupported" ? activeSession?.model : input.model; + const modelForTurn = + sessionModelSwitch === "unsupported" ? activeSession?.model : input.modelSelection?.model; yield* providerService.sendTurn({ threadId: input.threadId, @@ -520,15 +524,18 @@ const make = Effect.gen(function* () { ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), }).pipe(Effect.forkScoped); + const requestedModelOptions = event.payload.modelSelection + ? toProviderModelOptions(event.payload.modelSelection) + : undefined; + yield* sendTurnForThread({ threadId: event.payload.threadId, messageText: message.text, ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), - ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}), - ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), - ...(event.payload.modelOptions !== undefined - ? { modelOptions: event.payload.modelOptions } + ...(event.payload.modelSelection !== undefined + ? { modelSelection: event.payload.modelSelection } : {}), + ...(requestedModelOptions !== undefined ? { modelOptions: requestedModelOptions } : {}), ...(event.payload.providerOptions !== undefined ? { providerOptions: event.payload.providerOptions } : {}), @@ -698,7 +705,9 @@ const make = Effect.gen(function* () { return; } const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId); - const cachedModelOptions = threadModelOptions.get(event.payload.threadId); + const cachedModelOptions = + threadModelOptions.get(event.payload.threadId) ?? + toProviderModelOptions(thread.modelSelection); yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, { ...(cachedProviderOptions !== undefined ? { providerOptions: cachedProviderOptions } diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index c1ba48108f..3108811c0b 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -187,7 +187,10 @@ describe("ProviderRuntimeIngestion", () => { projectId: asProjectId("project-1"), title: "Provider Project", workspaceRoot, - defaultModel: "gpt-5-codex", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, createdAt, }), ); @@ -198,7 +201,10 @@ describe("ProviderRuntimeIngestion", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, @@ -724,7 +730,10 @@ describe("ProviderRuntimeIngestion", () => { threadId: sourceThreadId, projectId: asProjectId("project-1"), title: "Plan Source", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: "plan", runtimeMode: "approval-required", branch: null, @@ -756,7 +765,10 @@ describe("ProviderRuntimeIngestion", () => { threadId: targetThreadId, projectId: asProjectId("project-1"), title: "Plan Target", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, @@ -905,7 +917,10 @@ describe("ProviderRuntimeIngestion", () => { threadId: sourceThreadId, projectId: asProjectId("project-1"), title: "Plan Source", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: "plan", runtimeMode: "approval-required", branch: null, @@ -1055,7 +1070,10 @@ describe("ProviderRuntimeIngestion", () => { threadId: sourceThreadId, projectId: asProjectId("project-1"), title: "Plan Source", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: "plan", runtimeMode: "approval-required", branch: null, @@ -1087,7 +1105,10 @@ describe("ProviderRuntimeIngestion", () => { threadId: targetThreadId, projectId: asProjectId("project-1"), title: "Plan Target", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index f95e4db754..b07eb4234f 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -28,7 +28,10 @@ const readModel: OrchestrationReadModel = { id: ProjectId.makeUnsafe("project-a"), title: "Project A", workspaceRoot: "/tmp/project-a", - defaultModel: "gpt-5-codex", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, scripts: [], createdAt: now, updatedAt: now, @@ -38,7 +41,10 @@ const readModel: OrchestrationReadModel = { id: ProjectId.makeUnsafe("project-b"), title: "Project B", workspaceRoot: "/tmp/project-b", - defaultModel: "gpt-5-codex", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, scripts: [], createdAt: now, updatedAt: now, @@ -50,7 +56,10 @@ const readModel: OrchestrationReadModel = { id: ThreadId.makeUnsafe("thread-1"), projectId: ProjectId.makeUnsafe("project-a"), title: "Thread A", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", branch: null, @@ -69,7 +78,10 @@ const readModel: OrchestrationReadModel = { id: ThreadId.makeUnsafe("thread-2"), projectId: ProjectId.makeUnsafe("project-b"), title: "Thread B", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", branch: null, @@ -144,7 +156,10 @@ describe("commandInvariants", () => { threadId: ThreadId.makeUnsafe("thread-3"), projectId: ProjectId.makeUnsafe("project-a"), title: "new", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", branch: null, @@ -165,7 +180,10 @@ describe("commandInvariants", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: ProjectId.makeUnsafe("project-a"), title: "dup", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", branch: null, diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 516d8b2a28..69a9117824 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -59,7 +59,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-scripts"), title: "Scripts", workspaceRoot: "/tmp/scripts", - defaultModel: null, + defaultModelSelection: null, scripts: [], createdAt: now, updatedAt: now, @@ -113,7 +113,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-1"), title: "Project", workspaceRoot: "/tmp/project", - defaultModel: null, + defaultModelSelection: null, scripts: [], createdAt: now, updatedAt: now, @@ -136,7 +136,10 @@ describe("decider project scripts", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, @@ -159,10 +162,10 @@ describe("decider project scripts", () => { text: "hello", attachments: [], }, - provider: "codex", - model: "gpt-5.3-codex", - modelOptions: { - codex: { + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + options: { reasoningEffort: "high", fastMode: true, }, @@ -189,10 +192,10 @@ describe("decider project scripts", () => { expect(turnStartEvent.payload).toMatchObject({ threadId: ThreadId.makeUnsafe("thread-1"), messageId: asMessageId("message-user-1"), - provider: "codex", - model: "gpt-5.3-codex", - modelOptions: { - codex: { + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + options: { reasoningEffort: "high", fastMode: true, }, @@ -220,7 +223,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-1"), title: "Project", workspaceRoot: "/tmp/project", - defaultModel: null, + defaultModelSelection: null, scripts: [], createdAt: now, updatedAt: now, @@ -243,7 +246,10 @@ describe("decider project scripts", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", branch: null, @@ -299,7 +305,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-1"), title: "Project", workspaceRoot: "/tmp/project", - defaultModel: null, + defaultModelSelection: null, scripts: [], createdAt: now, updatedAt: now, @@ -322,7 +328,10 @@ describe("decider project scripts", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 6ea4c51759..761ab56a7d 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -77,7 +77,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" projectId: command.projectId, title: command.title, workspaceRoot: command.workspaceRoot, - defaultModel: command.defaultModel ?? null, + defaultModelSelection: command.defaultModelSelection ?? null, scripts: [], createdAt: command.createdAt, updatedAt: command.createdAt, @@ -104,7 +104,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" projectId: command.projectId, ...(command.title !== undefined ? { title: command.title } : {}), ...(command.workspaceRoot !== undefined ? { workspaceRoot: command.workspaceRoot } : {}), - ...(command.defaultModel !== undefined ? { defaultModel: command.defaultModel } : {}), + ...(command.defaultModelSelection !== undefined + ? { defaultModelSelection: command.defaultModelSelection } + : {}), ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), updatedAt: occurredAt, }, @@ -156,7 +158,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, projectId: command.projectId, title: command.title, - model: command.model, + modelSelection: command.modelSelection, runtimeMode: command.runtimeMode, interactionMode: command.interactionMode, branch: command.branch, @@ -207,7 +209,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, ...(command.title !== undefined ? { title: command.title } : {}), - ...(command.model !== undefined ? { model: command.model } : {}), + ...(command.modelSelection !== undefined + ? { modelSelection: command.modelSelection } + : {}), ...(command.branch !== undefined ? { branch: command.branch } : {}), ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), updatedAt: occurredAt, @@ -323,9 +327,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, messageId: command.message.messageId, - ...(command.provider !== undefined ? { provider: command.provider } : {}), - ...(command.model !== undefined ? { model: command.model } : {}), - ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}), + ...(command.modelSelection !== undefined + ? { modelSelection: command.modelSelection } + : {}), ...(command.providerOptions !== undefined ? { providerOptions: command.providerOptions } : {}), diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 71f5b6bd4b..fd95d028d8 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -56,7 +56,10 @@ describe("orchestration projector", () => { threadId: "thread-1", projectId: "project-1", title: "demo", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, runtimeMode: "full-access", branch: null, worktreePath: null, @@ -73,7 +76,10 @@ describe("orchestration projector", () => { id: "thread-1", projectId: "project-1", title: "demo", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, runtimeMode: "full-access", interactionMode: "default", branch: null, @@ -110,7 +116,10 @@ describe("orchestration projector", () => { // missing required threadId projectId: "project-1", title: "demo", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, branch: null, worktreePath: null, createdAt: now, @@ -170,7 +179,10 @@ describe("orchestration projector", () => { threadId: "thread-1", projectId: "project-1", title: "demo", - model: "gpt-5.3-codex", + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, runtimeMode: "full-access", branch: null, worktreePath: null, @@ -233,7 +245,10 @@ describe("orchestration projector", () => { threadId: "thread-1", projectId: "project-1", title: "demo", - model: "gpt-5.3-codex", + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, runtimeMode: "full-access", branch: null, worktreePath: null, @@ -287,7 +302,10 @@ describe("orchestration projector", () => { threadId: "thread-1", projectId: "project-1", title: "demo", - model: "gpt-5.3-codex", + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, runtimeMode: "full-access", branch: null, worktreePath: null, @@ -371,7 +389,10 @@ describe("orchestration projector", () => { threadId: "thread-1", projectId: "project-1", title: "demo", - model: "gpt-5.3-codex", + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, runtimeMode: "full-access", branch: null, worktreePath: null, @@ -583,7 +604,10 @@ describe("orchestration projector", () => { threadId: "thread-revert", projectId: "project-1", title: "demo", - model: "gpt-5.3-codex", + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, runtimeMode: "full-access", branch: null, worktreePath: null, @@ -733,7 +757,10 @@ describe("orchestration projector", () => { threadId: "thread-capped", projectId: "project-1", title: "capped", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, runtimeMode: "full-access", branch: null, worktreePath: null, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 015f82a677..05a660f753 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -181,7 +181,7 @@ export function projectEvent( id: payload.projectId, title: payload.title, workspaceRoot: payload.workspaceRoot, - defaultModel: payload.defaultModel, + defaultModelSelection: payload.defaultModelSelection, scripts: payload.scripts, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -211,8 +211,8 @@ export function projectEvent( ...(payload.workspaceRoot !== undefined ? { workspaceRoot: payload.workspaceRoot } : {}), - ...(payload.defaultModel !== undefined - ? { defaultModel: payload.defaultModel } + ...(payload.defaultModelSelection !== undefined + ? { defaultModelSelection: payload.defaultModelSelection } : {}), ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), updatedAt: payload.updatedAt, @@ -252,7 +252,7 @@ export function projectEvent( id: payload.threadId, projectId: payload.projectId, title: payload.title, - model: payload.model, + modelSelection: payload.modelSelection, runtimeMode: payload.runtimeMode, interactionMode: payload.interactionMode, branch: payload.branch, @@ -295,7 +295,9 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { ...(payload.title !== undefined ? { title: payload.title } : {}), - ...(payload.model !== undefined ? { model: payload.model } : {}), + ...(payload.modelSelection !== undefined + ? { modelSelection: payload.modelSelection } + : {}), ...(payload.branch !== undefined ? { branch: payload.branch } : {}), ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), updatedAt: payload.updatedAt, diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts index 77f7ee3cac..249e9d1e36 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts @@ -35,7 +35,7 @@ layer("OrchestrationEventStore", (it) => { projectId: ProjectId.makeUnsafe("project-roundtrip"), title: "Roundtrip Project", workspaceRoot: "/tmp/project-roundtrip", - defaultModel: null, + defaultModelSelection: null, scripts: [], createdAt: now, updatedAt: now, diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index 5dbc8c2d1f..9eb46a2633 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -1,9 +1,13 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Option, Schema, Struct } from "effect"; - -import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; +import { Effect, Layer, Option, Schema } from "effect"; +import { ModelSelection, ProjectScript } from "@t3tools/contracts"; +import { + PersistenceDecodeError, + toPersistenceDecodeError, + toPersistenceSqlError, +} from "../Errors.ts"; import { DeleteProjectionProjectInput, GetProjectionProjectInput, @@ -11,69 +15,100 @@ import { ProjectionProjectRepository, type ProjectionProjectRepositoryShape, } from "../Services/ProjectionProjects.ts"; -import { ProjectScript } from "@t3tools/contracts"; -// Makes sure that the scripts are parsed from the JSON string the DB returns -const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( - Struct.assign({ scripts: Schema.fromJsonString(Schema.Array(ProjectScript)) }), -); +const ProjectionProjectDbRow = Schema.Struct({ + projectId: ProjectionProject.fields.projectId, + title: ProjectionProject.fields.title, + workspaceRoot: ProjectionProject.fields.workspaceRoot, + defaultProvider: Schema.NullOr(Schema.Literals(["codex", "claudeAgent"])), + defaultModel: Schema.NullOr(Schema.String), + defaultModelOptions: Schema.NullOr(Schema.fromJsonString(Schema.Unknown)), + scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), + createdAt: ProjectionProject.fields.createdAt, + updatedAt: ProjectionProject.fields.updatedAt, + deletedAt: ProjectionProject.fields.deletedAt, +}); +type ProjectionProjectDbRow = typeof ProjectionProjectDbRow.Type; -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { - return (cause: unknown) => - Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); +const decodeModelSelectionSync = Schema.decodeUnknownSync(ModelSelection); + +function decodeDefaultModelSelection( + row: ProjectionProjectDbRow, +): Effect.Effect { + if (row.defaultProvider === null || row.defaultModel === null) { + return Effect.succeed(null); + } + return Effect.try({ + try: () => + decodeModelSelectionSync({ + provider: row.defaultProvider, + model: row.defaultModel, + ...(row.defaultModelOptions !== null ? { options: row.defaultModelOptions } : {}), + }), + catch: (error) => + toPersistenceDecodeError("ProjectionProjectRepository.decodeDefaultModelSelection")( + error as Schema.SchemaError, + ), + }); } const makeProjectionProjectRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const upsertProjectionProjectRow = SqlSchema.void({ - Request: ProjectionProjectDbRowSchema, + Request: ProjectionProject, execute: (row) => sql` - INSERT INTO projection_projects ( - project_id, - title, - workspace_root, - default_model, - scripts_json, - created_at, - updated_at, - deleted_at - ) - VALUES ( - ${row.projectId}, - ${row.title}, - ${row.workspaceRoot}, - ${row.defaultModel}, - ${row.scripts}, - ${row.createdAt}, - ${row.updatedAt}, - ${row.deletedAt} - ) - ON CONFLICT (project_id) - DO UPDATE SET - title = excluded.title, - workspace_root = excluded.workspace_root, - default_model = excluded.default_model, - scripts_json = excluded.scripts_json, - created_at = excluded.created_at, - updated_at = excluded.updated_at, - deleted_at = excluded.deleted_at - `, + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_provider, + default_model, + default_model_options_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + ${row.projectId}, + ${row.title}, + ${row.workspaceRoot}, + ${row.defaultModelSelection?.provider ?? null}, + ${row.defaultModelSelection?.model ?? null}, + ${JSON.stringify(row.defaultModelSelection?.options ?? null)}, + ${JSON.stringify(row.scripts)}, + ${row.createdAt}, + ${row.updatedAt}, + ${row.deletedAt} + ) + ON CONFLICT (project_id) + DO UPDATE SET + title = excluded.title, + workspace_root = excluded.workspace_root, + default_provider = excluded.default_provider, + default_model = excluded.default_model, + default_model_options_json = excluded.default_model_options_json, + scripts_json = excluded.scripts_json, + created_at = excluded.created_at, + updated_at = excluded.updated_at, + deleted_at = excluded.deleted_at + `, }); const getProjectionProjectRow = SqlSchema.findOneOption({ Request: GetProjectionProjectInput, - Result: ProjectionProjectDbRowSchema, + Result: ProjectionProjectDbRow, execute: ({ projectId }) => sql` SELECT project_id AS "projectId", title, workspace_root AS "workspaceRoot", + default_provider AS "defaultProvider", default_model AS "defaultModel", + default_model_options_json AS "defaultModelOptions", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -85,14 +120,16 @@ const makeProjectionProjectRepository = Effect.gen(function* () { const listProjectionProjectRows = SqlSchema.findAll({ Request: Schema.Void, - Result: ProjectionProjectDbRowSchema, + Result: ProjectionProjectDbRow, execute: () => sql` SELECT project_id AS "projectId", title, workspace_root AS "workspaceRoot", + default_provider AS "defaultProvider", default_model AS "defaultModel", + default_model_options_json AS "defaultModelOptions", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -113,40 +150,53 @@ const makeProjectionProjectRepository = Effect.gen(function* () { const upsert: ProjectionProjectRepositoryShape["upsert"] = (row) => upsertProjectionProjectRow(row).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionProjectRepository.upsert:query", - "ProjectionProjectRepository.upsert:encodeRequest", - ), - ), + Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.upsert:query")), ); const getById: ProjectionProjectRepositoryShape["getById"] = (input) => getProjectionProjectRow(input).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionProjectRepository.getById:query", - "ProjectionProjectRepository.getById:decodeRow", - ), - ), - Effect.flatMap((rowOption) => - Option.match(rowOption, { + Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.getById:query")), + Effect.flatMap( + Option.match({ onNone: () => Effect.succeed(Option.none()), onSome: (row) => - Effect.succeed(Option.some(row as Schema.Schema.Type)), + decodeDefaultModelSelection(row).pipe( + Effect.map((defaultModelSelection) => + Option.some({ + projectId: row.projectId, + title: row.title, + workspaceRoot: row.workspaceRoot, + defaultModelSelection, + scripts: row.scripts, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + }), + ), + ), }), ), ); const listAll: ProjectionProjectRepositoryShape["listAll"] = () => listProjectionProjectRows().pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionProjectRepository.listAll:query", - "ProjectionProjectRepository.listAll:decodeRows", + Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.listAll:query")), + Effect.flatMap((rows) => + Effect.forEach(rows, (row) => + decodeDefaultModelSelection(row).pipe( + Effect.map((defaultModelSelection) => ({ + projectId: row.projectId, + title: row.title, + workspaceRoot: row.workspaceRoot, + defaultModelSelection, + scripts: row.scripts, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + })), + ), ), ), - Effect.map((rows) => rows as ReadonlyArray>), ); const deleteById: ProjectionProjectRepositoryShape["deleteById"] = (input) => diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 10192697d0..e74a657f88 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -1,8 +1,9 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer } from "effect"; +import { Effect, Layer, Option, Schema } from "effect"; -import { toPersistenceSqlError } from "../Errors.ts"; +import { ModelSelection } from "@t3tools/contracts"; +import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; import { DeleteProjectionThreadInput, GetProjectionThreadInput, @@ -12,6 +13,53 @@ import { type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; +const ProjectionThreadDbRow = Schema.Struct({ + threadId: ProjectionThread.fields.threadId, + projectId: ProjectionThread.fields.projectId, + title: ProjectionThread.fields.title, + provider: Schema.Literals(["codex", "claudeAgent"]), + model: Schema.String, + modelOptions: Schema.NullOr(Schema.fromJsonString(Schema.Unknown)), + runtimeMode: ProjectionThread.fields.runtimeMode, + interactionMode: ProjectionThread.fields.interactionMode, + branch: ProjectionThread.fields.branch, + worktreePath: ProjectionThread.fields.worktreePath, + latestTurnId: ProjectionThread.fields.latestTurnId, + createdAt: ProjectionThread.fields.createdAt, + updatedAt: ProjectionThread.fields.updatedAt, + deletedAt: ProjectionThread.fields.deletedAt, +}); +type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; + +const decodeModelSelectionSync = Schema.decodeUnknownSync(ModelSelection); + +function decodeProjectionThread(row: ProjectionThreadDbRow) { + return Effect.try({ + try: () => ({ + threadId: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: decodeModelSelectionSync({ + provider: row.provider, + model: row.model, + ...(row.modelOptions !== null ? { options: row.modelOptions } : {}), + }), + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurnId: row.latestTurnId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + }), + catch: (error) => + toPersistenceDecodeError("ProjectionThreadRepository.decodeProjectionThread")( + error as Schema.SchemaError, + ), + }); +} + const makeProjectionThreadRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -23,7 +71,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id, project_id, title, + provider, model, + model_options_json, runtime_mode, interaction_mode, branch, @@ -37,7 +87,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.threadId}, ${row.projectId}, ${row.title}, - ${row.model}, + ${row.modelSelection.provider}, + ${row.modelSelection.model}, + ${JSON.stringify(row.modelSelection.options ?? null)}, ${row.runtimeMode}, ${row.interactionMode}, ${row.branch}, @@ -51,7 +103,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { DO UPDATE SET project_id = excluded.project_id, title = excluded.title, + provider = excluded.provider, model = excluded.model, + model_options_json = excluded.model_options_json, runtime_mode = excluded.runtime_mode, interaction_mode = excluded.interaction_mode, branch = excluded.branch, @@ -65,14 +119,16 @@ const makeProjectionThreadRepository = Effect.gen(function* () { const getProjectionThreadRow = SqlSchema.findOneOption({ Request: GetProjectionThreadInput, - Result: ProjectionThread, + Result: ProjectionThreadDbRow, execute: ({ threadId }) => sql` SELECT thread_id AS "threadId", project_id AS "projectId", title, + provider, model, + model_options_json AS "modelOptions", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", branch, @@ -88,14 +144,16 @@ const makeProjectionThreadRepository = Effect.gen(function* () { const listProjectionThreadRows = SqlSchema.findAll({ Request: ListProjectionThreadsByProjectInput, - Result: ProjectionThread, + Result: ProjectionThreadDbRow, execute: ({ projectId }) => sql` SELECT thread_id AS "threadId", project_id AS "projectId", title, + provider, model, + model_options_json AS "modelOptions", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", branch, @@ -127,11 +185,18 @@ const makeProjectionThreadRepository = Effect.gen(function* () { const getById: ProjectionThreadRepositoryShape["getById"] = (input) => getProjectionThreadRow(input).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.getById:query")), + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => decodeProjectionThread(row).pipe(Effect.map(Option.some)), + }), + ), ); const listByProjectId: ProjectionThreadRepositoryShape["listByProjectId"] = (input) => listProjectionThreadRows(input).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.listByProjectId:query")), + Effect.flatMap((rows) => Effect.forEach(rows, decodeProjectionThread)), ); const deleteById: ProjectionThreadRepositoryShape["deleteById"] = (input) => diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ea1821014a..46224b6cd6 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -27,6 +27,8 @@ import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts" import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts"; import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplementation.ts"; import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts"; +import Migration0016 from "./Migrations/016_ProjectionProviders.ts"; +import Migration0017 from "./Migrations/017_ProjectionModelSelectionOptions.ts"; import { Effect } from "effect"; /** @@ -55,6 +57,8 @@ const loader = Migrator.fromRecord({ "13_ProjectionThreadProposedPlans": Migration0013, "14_ProjectionThreadProposedPlanImplementation": Migration0014, "15_ProjectionTurnsSourceProposedPlan": Migration0015, + "16_ProjectionProviders": Migration0016, + "17_ProjectionModelSelectionOptions": Migration0017, }); /** diff --git a/apps/server/src/persistence/Migrations/016_ProjectionProviders.ts b/apps/server/src/persistence/Migrations/016_ProjectionProviders.ts new file mode 100644 index 0000000000..4b1ffbd2fd --- /dev/null +++ b/apps/server/src/persistence/Migrations/016_ProjectionProviders.ts @@ -0,0 +1,38 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN default_provider TEXT + `; + + yield* sql` + UPDATE projection_projects + SET default_provider = CASE + WHEN default_model IS NULL THEN NULL + ELSE 'codex' + END + WHERE default_provider IS NULL + `; + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN provider TEXT + `; + + yield* sql` + UPDATE projection_threads + SET provider = COALESCE( + ( + SELECT provider_name + FROM projection_thread_sessions + WHERE projection_thread_sessions.thread_id = projection_threads.thread_id + ), + 'codex' + ) + WHERE provider IS NULL + `; +}); diff --git a/apps/server/src/persistence/Migrations/017_ProjectionModelSelectionOptions.ts b/apps/server/src/persistence/Migrations/017_ProjectionModelSelectionOptions.ts new file mode 100644 index 0000000000..d17bd4b4c3 --- /dev/null +++ b/apps/server/src/persistence/Migrations/017_ProjectionModelSelectionOptions.ts @@ -0,0 +1,28 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN default_model_options_json TEXT + `; + + yield* sql` + UPDATE projection_projects + SET default_model_options_json = NULL + WHERE default_model_options_json IS NULL + `; + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN model_options_json TEXT + `; + + yield* sql` + UPDATE projection_threads + SET model_options_json = NULL + WHERE model_options_json IS NULL + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 1380a9609a..996ffe6e7b 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -6,7 +6,7 @@ * * @module ProjectionProjectRepository */ -import { IsoDateTime, ProjectId, ProjectScript } from "@t3tools/contracts"; +import { IsoDateTime, ModelSelection, ProjectId, ProjectScript } from "@t3tools/contracts"; import { Option, Schema, ServiceMap } from "effect"; import type { Effect } from "effect"; @@ -16,7 +16,7 @@ export const ProjectionProject = Schema.Struct({ projectId: ProjectId, title: Schema.String, workspaceRoot: Schema.String, - defaultModel: Schema.NullOr(Schema.String), + defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 7a30870f2d..cf4bd55a81 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -8,6 +8,7 @@ */ import { IsoDateTime, + ModelSelection, ProjectId, ProviderInteractionMode, RuntimeMode, @@ -23,7 +24,7 @@ export const ProjectionThread = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: Schema.String, - model: Schema.String, + modelSelection: ModelSelection, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 9c6adfeba9..14a773ab5c 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -703,13 +703,19 @@ describe("WebSocket Server", () => { id: string; workspaceRoot: string; title: string; - defaultModel: string | null; + defaultModelSelection: { + provider: string; + model: string; + } | null; }>; threads: Array<{ id: string; projectId: string; title: string; - model: string; + modelSelection: { + provider: string; + model: string; + }; branch: string | null; worktreePath: string | null; }>; @@ -725,7 +731,10 @@ describe("WebSocket Server", () => { id: bootstrapProjectId, workspaceRoot: "/test/bootstrap-workspace", title: "bootstrap-workspace", - defaultModel: "gpt-5-codex", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, }), ]), ); @@ -735,7 +744,10 @@ describe("WebSocket Server", () => { id: bootstrapThreadId, projectId: bootstrapProjectId, title: "New thread", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, branch: null, worktreePath: null, }), @@ -1192,7 +1204,10 @@ describe("WebSocket Server", () => { projectId: "project-diff", title: "Diff Project", workspaceRoot, - defaultModel: "gpt-5-codex", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, createdAt, }); expect(createProjectResponse.error).toBeUndefined(); @@ -1202,7 +1217,10 @@ describe("WebSocket Server", () => { threadId: "thread-diff", projectId: "project-diff", title: "Diff Thread", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, runtimeMode: "full-access", interactionMode: "default", branch: null, @@ -1270,7 +1288,10 @@ describe("WebSocket Server", () => { projectId: "project-1", title: "WS Project", workspaceRoot, - defaultModel: "gpt-5-codex", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, createdAt, }); expect(createProjectResponse.error).toBeUndefined(); @@ -1280,7 +1301,10 @@ describe("WebSocket Server", () => { threadId: "thread-1", projectId: "project-1", title: "Thread 1", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, runtimeMode: "full-access", interactionMode: "default", branch: null, diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index e22c23988b..4255938d62 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -631,25 +631,31 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< (project) => project.workspaceRoot === cwd && project.deletedAt === null, ); let bootstrapProjectId: ProjectId; - let bootstrapProjectDefaultModel: string; + let bootstrapProjectDefaultModelSelection; if (!existingProject) { const createdAt = new Date().toISOString(); bootstrapProjectId = ProjectId.makeUnsafe(crypto.randomUUID()); const bootstrapProjectTitle = path.basename(cwd) || "project"; - bootstrapProjectDefaultModel = "gpt-5-codex"; + bootstrapProjectDefaultModelSelection = { + provider: "codex" as const, + model: "gpt-5-codex", + }; yield* orchestrationEngine.dispatch({ type: "project.create", commandId: CommandId.makeUnsafe(crypto.randomUUID()), projectId: bootstrapProjectId, title: bootstrapProjectTitle, workspaceRoot: cwd, - defaultModel: bootstrapProjectDefaultModel, + defaultModelSelection: bootstrapProjectDefaultModelSelection, createdAt, }); } else { bootstrapProjectId = existingProject.id; - bootstrapProjectDefaultModel = existingProject.defaultModel ?? "gpt-5-codex"; + bootstrapProjectDefaultModelSelection = existingProject.defaultModelSelection ?? { + provider: "codex" as const, + model: "gpt-5-codex", + }; } const existingThread = snapshot.threads.find( @@ -664,7 +670,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< threadId, projectId: bootstrapProjectId, title: "New thread", - model: bootstrapProjectDefaultModel, + modelSelection: bootstrapProjectDefaultModelSelection, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", branch: null, diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 48c627747d..17d0bbb4de 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -220,7 +220,10 @@ function createSnapshotForTargetUser(options: { id: PROJECT_ID, title: "Project", workspaceRoot: "/repo/project", - defaultModel: "gpt-5", + defaultModelSelection: { + provider: "codex", + model: "gpt-5", + }, scripts: [], createdAt: NOW_ISO, updatedAt: NOW_ISO, @@ -232,7 +235,10 @@ function createSnapshotForTargetUser(options: { id: THREAD_ID, projectId: PROJECT_ID, title: "Browser test thread", - model: "gpt-5", + modelSelection: { + provider: "codex", + model: "gpt-5", + }, interactionMode: "default", runtimeMode: "full-access", branch: "main", @@ -286,7 +292,10 @@ function addThreadToSnapshot( id: threadId, projectId: PROJECT_ID, title: "New thread", - model: "gpt-5", + modelSelection: { + provider: "codex", + model: "gpt-5", + }, interactionMode: "default", runtimeMode: "full-access", branch: "main", @@ -752,8 +761,7 @@ describe("ChatView timeline estimator parity (full app)", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, - stickyModel: null, - stickyModelOptions: {}, + stickyModelSelection: null, }); useStore.setState({ projects: [], @@ -1283,9 +1291,10 @@ describe("ChatView timeline estimator parity (full app)", () => { it("snapshots sticky codex settings into a new draft thread", async () => { useComposerDraftStore.setState({ - stickyModel: "gpt-5.3-codex", - stickyModelOptions: { - codex: { + stickyModelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + options: { reasoningEffort: "medium", fastMode: true, }, @@ -1314,10 +1323,10 @@ describe("ChatView timeline estimator parity (full app)", () => { const newThreadId = newThreadPath.slice(1) as ThreadId; expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ - model: "gpt-5.3-codex", - provider: "codex", - modelOptions: { - codex: { + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + options: { fastMode: true, }, }, @@ -1329,9 +1338,10 @@ describe("ChatView timeline estimator parity (full app)", () => { it("hydrates the provider alongside a sticky claude model", async () => { useComposerDraftStore.setState({ - stickyModel: "claude-opus-4-6", - stickyModelOptions: { - claudeAgent: { + stickyModelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { effort: "max", fastMode: true, }, @@ -1360,10 +1370,10 @@ describe("ChatView timeline estimator parity (full app)", () => { const newThreadId = newThreadPath.slice(1) as ThreadId; expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ - provider: "claudeAgent", - model: "claude-opus-4-6", - modelOptions: { - claudeAgent: { + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { effort: "max", fastMode: true, }, @@ -1405,9 +1415,10 @@ describe("ChatView timeline estimator parity (full app)", () => { it("prefers draft state over sticky composer settings and defaults", async () => { useComposerDraftStore.setState({ - stickyModel: "gpt-5.3-codex", - stickyModelOptions: { - codex: { + stickyModelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + options: { reasoningEffort: "medium", fastMode: true, }, @@ -1436,17 +1447,19 @@ describe("ChatView timeline estimator parity (full app)", () => { const threadId = threadPath.slice(1) as ThreadId; expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ - model: "gpt-5.3-codex", - modelOptions: { - codex: { + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + options: { fastMode: true, }, }, }); - useComposerDraftStore.getState().setModel(threadId, "gpt-5.4"); - useComposerDraftStore.getState().setModelOptions(threadId, { - codex: { + useComposerDraftStore.getState().setModelSelection(threadId, { + provider: "codex", + model: "gpt-5.4", + options: { reasoningEffort: "low", fastMode: true, }, @@ -1460,9 +1473,10 @@ describe("ChatView timeline estimator parity (full app)", () => { "New-thread should reuse the existing project draft thread.", ); expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ - model: "gpt-5.4", - modelOptions: { - codex: { + modelSelection: { + provider: "codex", + model: "gpt-5.4", + options: { reasoningEffort: "low", fastMode: true, }, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index ddc84718e6..0a9f242ed0 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,4 +1,4 @@ -import { ProjectId, type ThreadId } from "@t3tools/contracts"; +import { ProjectId, type ModelSelection, type ThreadId } from "@t3tools/contracts"; import { type ChatMessage, type Thread } from "../types"; import { randomUUID } from "~/lib/utils"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; @@ -17,7 +17,7 @@ export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema. export function buildLocalDraftThread( threadId: ThreadId, draftThread: DraftThreadState, - fallbackModel: string, + fallbackModelSelection: ModelSelection, error: string | null, ): Thread { return { @@ -25,7 +25,7 @@ export function buildLocalDraftThread( codexThreadId: null, projectId: draftThread.projectId, title: "New thread", - model: fallbackModel, + modelSelection: fallbackModelSelection, runtimeMode: draftThread.runtimeMode, interactionMode: draftThread.interactionMode, session: null, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3484375244..81a34472c6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3,9 +3,11 @@ import { DEFAULT_MODEL_BY_PROVIDER, type ClaudeCodeEffort, type MessageId, + type ModelSelection, type ProjectScript, type ModelSlug, type ProviderKind, + type ProviderModelOptions, type ProjectEntry, type ProjectId, type ProviderApprovalDecision, @@ -201,6 +203,23 @@ function formatOutgoingPrompt(params: { } return params.text; } +function extractModelSelectionOptions( + provider: ProviderKind, + modelOptions: ProviderModelOptions | undefined, +): ModelSelection["options"] | undefined { + 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; @@ -245,7 +264,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const { settings } = useAppSettings(); - const setStickyComposerModel = useComposerDraftStore((store) => store.setStickyModel); + const setStickyComposerModelSelection = useComposerDraftStore( + (store) => store.setStickyModelSelection, + ); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); const rawSearch = useSearch({ @@ -270,8 +291,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); - const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider); - const setComposerDraftModel = useComposerDraftStore((store) => store.setModel); + const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( (store) => store.setInteractionMode, @@ -464,11 +484,14 @@ export default function ChatView({ threadId }: ChatViewProps) { ? buildLocalDraftThread( threadId, draftThread, - fallbackDraftProject?.model ?? DEFAULT_MODEL_BY_PROVIDER.codex, + fallbackDraftProject?.defaultModelSelection ?? { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, localDraftError, ) : undefined, - [draftThread, fallbackDraftProject?.model, localDraftError, threadId], + [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId], ); const activeThread = serverThread ?? localDraftThread; const runtimeMode = @@ -583,7 +606,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ]); const sessionProvider = activeThread?.session?.provider ?? null; - const selectedProviderByThreadId = composerDraft.provider; + const selectedProviderByThreadId = composerDraft.modelSelection?.provider ?? null; + const threadProvider = + activeThread?.modelSelection.provider ?? activeProject?.defaultModelSelection?.provider ?? null; const hasThreadStarted = Boolean( activeThread && (activeThread.latestTurn !== null || @@ -591,22 +616,30 @@ export default function ChatView({ threadId }: ChatViewProps) { activeThread.session !== null), ); const lockedProvider: ProviderKind | null = hasThreadStarted - ? (sessionProvider ?? selectedProviderByThreadId ?? null) + ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null) : null; - const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex"; + const selectedProvider: ProviderKind = + lockedProvider ?? selectedProviderByThreadId ?? threadProvider ?? "codex"; const baseThreadModel = resolveModelSlugForProvider( selectedProvider, - activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), + activeThread?.modelSelection.model ?? + activeProject?.defaultModelSelection?.model ?? + getDefaultModel(selectedProvider), ); const customModelsByProvider = useMemo(() => getCustomModelsByProvider(settings), [settings]); const selectedModel = useMemo(() => { - const draftModel = composerDraft.model; + const draftModel = composerDraft.modelSelection?.model; if (!draftModel) { return baseThreadModel; } return resolveAppModelSelection(selectedProvider, customModelsByProvider, draftModel); - }, [baseThreadModel, composerDraft.model, customModelsByProvider, selectedProvider]); - const draftModelOptions = composerDraft.modelOptions; + }, [ + baseThreadModel, + composerDraft.modelSelection?.model, + customModelsByProvider, + selectedProvider, + ]); + const draftModelOptions = toProviderModelOptions(composerDraft.modelSelection); const composerProviderState = useMemo( () => getComposerProviderState({ @@ -619,6 +652,21 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; + const selectedModelSelection = useMemo( + () => ({ + provider: selectedProvider, + model: selectedModel, + ...(extractModelSelectionOptions(selectedProvider, selectedModelOptionsForDispatch) + ? { + options: extractModelSelectionOptions( + selectedProvider, + selectedModelOptionsForDispatch, + ), + } + : {}), + }), + [selectedModel, selectedModelOptionsForDispatch, selectedProvider], + ); const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]); const selectedModelForPicker = selectedModel; const modelOptionsByProvider = useMemo( @@ -1581,7 +1629,7 @@ export default function ChatView({ threadId }: ChatViewProps) { async (input: { threadId: ThreadId; createdAt: string; - model?: string; + modelSelection?: ModelSelection; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; }) => { @@ -1593,12 +1641,18 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } - if (input.model !== undefined && input.model !== serverThread.model) { + if ( + input.modelSelection !== undefined && + (input.modelSelection.model !== serverThread.modelSelection.model || + input.modelSelection.provider !== serverThread.modelSelection.provider || + JSON.stringify(input.modelSelection.options ?? null) !== + JSON.stringify(serverThread.modelSelection.options ?? null)) + ) { await api.orchestration.dispatchCommand({ type: "thread.meta.update", commandId: newCommandId(), threadId: input.threadId, - model: input.model, + modelSelection: input.modelSelection, }); } @@ -2529,8 +2583,14 @@ export default function ChatView({ threadId }: ChatViewProps) { } } const title = truncateTitle(titleSeed); - let threadCreateModel: ModelSlug = - selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL_BY_PROVIDER.codex; + const threadCreateModelSelection: ModelSelection = { + provider: selectedProvider, + model: + selectedModel || + activeProject.defaultModelSelection?.model || + DEFAULT_MODEL_BY_PROVIDER.codex, + ...(selectedModelSelection.options ? { options: selectedModelSelection.options } : {}), + }; if (isLocalDraftThread) { await api.orchestration.dispatchCommand({ @@ -2539,7 +2599,7 @@ export default function ChatView({ threadId }: ChatViewProps) { threadId: threadIdForSend, projectId: activeProject.id, title, - model: threadCreateModel, + modelSelection: threadCreateModelSelection, runtimeMode, interactionMode, branch: nextThreadBranch, @@ -2589,7 +2649,7 @@ export default function ChatView({ threadId }: ChatViewProps) { await persistThreadSettingsForNextTurn({ threadId: threadIdForSend, createdAt: messageCreatedAt, - ...(selectedModel ? { model: selectedModel } : {}), + ...(selectedModel ? { modelSelection: selectedModelSelection } : {}), runtimeMode, interactionMode, }); @@ -2607,12 +2667,8 @@ export default function ChatView({ threadId }: ChatViewProps) { text: outgoingMessageText, attachments: turnAttachments, }, - model: selectedModel || undefined, - ...(selectedModelOptionsForDispatch - ? { modelOptions: selectedModelOptionsForDispatch } - : {}), + modelSelection: selectedModelSelection, ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), - provider: selectedProvider, assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode, @@ -2873,7 +2929,7 @@ export default function ChatView({ threadId }: ChatViewProps) { await persistThreadSettingsForNextTurn({ threadId: threadIdForSend, createdAt: messageCreatedAt, - ...(selectedModel ? { model: selectedModel } : {}), + modelSelection: selectedModelSelection, runtimeMode, interactionMode: nextInteractionMode, }); @@ -2892,11 +2948,7 @@ export default function ChatView({ threadId }: ChatViewProps) { text: outgoingMessageText, attachments: [], }, - provider: selectedProvider, - model: selectedModel || undefined, - ...(selectedModelOptionsForDispatch - ? { modelOptions: selectedModelOptionsForDispatch } - : {}), + modelSelection: selectedModelSelection, ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, @@ -2943,8 +2995,7 @@ export default function ChatView({ threadId }: ChatViewProps) { resetSendPhase, runtimeMode, selectedPromptEffort, - selectedModel, - selectedModelOptionsForDispatch, + selectedModelSelection, providerOptionsForDispatch, selectedProvider, setComposerDraftInteractionMode, @@ -2978,11 +3029,12 @@ export default function ChatView({ threadId }: ChatViewProps) { text: implementationPrompt, }); const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown)); - const nextThreadModel: ModelSlug = - selectedModel || - (activeThread.model as ModelSlug) || - (activeProject.model as ModelSlug) || - DEFAULT_MODEL_BY_PROVIDER.codex; + const nextThreadModelSelection: ModelSelection = selectedModelSelection ?? + activeThread.modelSelection ?? + activeProject.defaultModelSelection ?? { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }; sendInFlightRef.current = true; beginSendPhase("sending-turn"); @@ -2998,7 +3050,7 @@ export default function ChatView({ threadId }: ChatViewProps) { threadId: nextThreadId, projectId: activeProject.id, title: nextThreadTitle, - model: nextThreadModel, + modelSelection: nextThreadModelSelection, runtimeMode, interactionMode: "default", branch: activeThread.branch, @@ -3016,11 +3068,7 @@ export default function ChatView({ threadId }: ChatViewProps) { text: outgoingImplementationPrompt, attachments: [], }, - provider: selectedProvider, - model: selectedModel || undefined, - ...(selectedModelOptionsForDispatch - ? { modelOptions: selectedModelOptionsForDispatch } - : {}), + modelSelection: selectedModelSelection, ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, @@ -3072,8 +3120,7 @@ export default function ChatView({ threadId }: ChatViewProps) { resetSendPhase, runtimeMode, selectedPromptEffort, - selectedModel, - selectedModelOptionsForDispatch, + selectedModelSelection, providerOptionsForDispatch, selectedProvider, settings.enableAssistantStreaming, @@ -3088,18 +3135,20 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model); - setComposerDraftProvider(activeThread.id, provider); - setComposerDraftModel(activeThread.id, resolvedModel); - setStickyComposerModel(resolvedModel); + const nextModelSelection: ModelSelection = { + provider, + model: resolvedModel, + }; + setComposerDraftModelSelection(activeThread.id, nextModelSelection); + setStickyComposerModelSelection(nextModelSelection); scheduleComposerFocus(); }, [ activeThread, lockedProvider, scheduleComposerFocus, - setComposerDraftModel, - setComposerDraftProvider, - setStickyComposerModel, + setComposerDraftModelSelection, + setStickyComposerModelSelection, customModelsByProvider, ], ); diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index ba4c8f4320..7cb55e795c 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -64,7 +64,10 @@ function createMinimalSnapshot(): OrchestrationReadModel { id: PROJECT_ID, title: "Project", workspaceRoot: "/repo/project", - defaultModel: "gpt-5", + defaultModelSelection: { + provider: "codex", + model: "gpt-5", + }, scripts: [], createdAt: NOW_ISO, updatedAt: NOW_ISO, @@ -76,7 +79,10 @@ function createMinimalSnapshot(): OrchestrationReadModel { id: THREAD_ID, projectId: PROJECT_ID, title: "Test thread", - model: "gpt-5", + modelSelection: { + provider: "codex", + model: "gpt-5", + }, interactionMode: "default", runtimeMode: "full-access", branch: "main", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9ff741897c..5b13cd89c1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -434,7 +434,10 @@ export default function Sidebar() { projectId, title, workspaceRoot: cwd, - defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex, + defaultModelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, createdAt, }); await handleNewThread(projectId, { diff --git a/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx b/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx index a675a82d89..8f043759f4 100644 --- a/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx @@ -25,10 +25,10 @@ async function mountPicker(props?: { nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], - provider: "claudeAgent", - model: props?.model ?? "claude-opus-4-6", - modelOptions: { - claudeAgent: { + modelSelection: { + provider: "claudeAgent", + model: props?.model ?? "claude-opus-4-6", + options: { ...(props?.effort ? { effort: props.effort } : {}), ...(props?.thinkingEnabled === false ? { thinking: false } : {}), ...(props?.fastModeEnabled ? { fastMode: true } : {}), @@ -185,8 +185,9 @@ describe("ClaudeTraitsPicker", () => { await page.getByRole("button").click(); await page.getByRole("menuitemradio", { name: "Max" }).click(); - expect(useComposerDraftStore.getState().stickyModelOptions).toMatchObject({ - claudeAgent: { + expect(useComposerDraftStore.getState().stickyModelSelection).toMatchObject({ + provider: "claudeAgent", + options: { effort: "max", }, }); diff --git a/apps/web/src/components/chat/ClaudeTraitsPicker.tsx b/apps/web/src/components/chat/ClaudeTraitsPicker.tsx index d6585d43d8..994e65012f 100644 --- a/apps/web/src/components/chat/ClaudeTraitsPicker.tsx +++ b/apps/web/src/components/chat/ClaudeTraitsPicker.tsx @@ -93,7 +93,8 @@ export const ClaudeTraitsMenuContent = memo(function ClaudeTraitsMenuContentImpl }: ClaudeTraitsMenuContentProps) { const draft = useComposerThreadDraft(threadId); const prompt = draft.prompt; - const modelOptions = draft.modelOptions?.[PROVIDER]; + const modelOptions = + draft.modelSelection?.provider === PROVIDER ? draft.modelSelection.options : null; const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); const { effort, @@ -225,7 +226,8 @@ export const ClaudeTraitsPicker = memo(function ClaudeTraitsPicker({ const [isMenuOpen, setIsMenuOpen] = useState(false); const draft = useComposerThreadDraft(threadId); const prompt = draft.prompt; - const modelOptions = draft.modelOptions?.[PROVIDER]; + const modelOptions = + draft.modelSelection?.provider === PROVIDER ? draft.modelSelection.options : null; const { effort, thinkingEnabled, fastModeEnabled, ultrathinkPromptControlled, supportsFastMode } = getSelectedClaudeTraits(model, prompt, modelOptions); const triggerLabel = [ diff --git a/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx b/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx index 9d2b73989d..c494bb04b5 100644 --- a/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx @@ -22,10 +22,10 @@ async function mountPicker(props: { nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], - provider: "codex", - model: null, - modelOptions: { - codex: { + modelSelection: { + provider: "codex", + model: "gpt-5.4", + options: { ...(props.reasoningEffort ? { reasoningEffort: props.reasoningEffort } : {}), ...(props.fastModeEnabled ? { fastMode: true } : {}), }, @@ -125,8 +125,9 @@ describe("CodexTraitsPicker", () => { await page.getByRole("button").click(); await page.getByRole("menuitemradio", { name: "on" }).click(); - expect(useComposerDraftStore.getState().stickyModelOptions).toMatchObject({ - codex: { + expect(useComposerDraftStore.getState().stickyModelSelection).toMatchObject({ + provider: "codex", + options: { fastMode: true, }, }); @@ -145,6 +146,7 @@ describe("CodexTraitsPicker", () => { [threadId]: { prompt: "", attachments: [], + modelSelection: null, provider: "codex", model: "gpt-5.3-codex", effort: "xhigh", @@ -173,12 +175,16 @@ describe("CodexTraitsPicker", () => { await vi.waitFor(() => { expect(document.body.textContent ?? "").toContain("Extra High · Fast"); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ - codex: { - reasoningEffort: "xhigh", - fastMode: true, + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( + { + provider: "codex", + model: "gpt-5.3-codex", + options: { + reasoningEffort: "xhigh", + fastMode: true, + }, }, - }); + ); }); } finally { await screen.unmount(); diff --git a/apps/web/src/components/chat/CodexTraitsPicker.tsx b/apps/web/src/components/chat/CodexTraitsPicker.tsx index 7b37063bff..e985e76901 100644 --- a/apps/web/src/components/chat/CodexTraitsPicker.tsx +++ b/apps/web/src/components/chat/CodexTraitsPicker.tsx @@ -48,7 +48,8 @@ function getSelectedCodexTraits(modelOptions: CodexModelOptions | null | undefin function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) { const draft = useComposerThreadDraft(props.threadId); - const modelOptions = draft.modelOptions?.[PROVIDER]; + const modelOptions = + draft.modelSelection?.provider === PROVIDER ? draft.modelSelection.options : null; const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); const options = getReasoningEffortOptions(PROVIDER); const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER); @@ -112,7 +113,9 @@ export const CodexTraitsMenuContent = memo(CodexTraitsMenuContentImpl); export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { threadId: ThreadId }) { const [isMenuOpen, setIsMenuOpen] = useState(false); - const modelOptions = useComposerThreadDraft(props.threadId).modelOptions?.codex; + const draft = useComposerThreadDraft(props.threadId); + const modelOptions = + draft.modelSelection?.provider === PROVIDER ? draft.modelSelection.options : null; const { effort, fastModeEnabled } = getSelectedCodexTraits(modelOptions); const triggerLabel = [CODEX_REASONING_LABELS[effort], ...(fastModeEnabled ? ["Fast"] : [])] .filter(Boolean) diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 83716d619a..3d98760bbd 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -27,9 +27,16 @@ async function mountMenu(props?: { nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], - provider, - model: props?.model ?? "claude-opus-4-6", - modelOptions: props?.modelOptions ?? null, + modelSelection: { + provider, + model: props?.model ?? "claude-opus-4-6", + ...(props?.modelOptions + ? { + options: + provider === "codex" ? props.modelOptions.codex : props.modelOptions.claudeAgent, + } + : {}), + }, runtimeMode: null, interactionMode: null, }; diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 98a6f17331..faf08519bd 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { ProjectId, ThreadId, type ModelSelection } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -67,11 +67,22 @@ function resetComposerDraftStore() { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, - stickyModel: null, - stickyModelOptions: {}, + stickyModelSelection: null, }); } +function modelSelection( + provider: "codex" | "claudeAgent", + model: string, + options?: ModelSelection["options"], +): ModelSelection { + return { + provider, + model, + ...(options ? { options } : {}), + } as ModelSelection; +} + describe("composerDraftStore addImages", () => { const threadId = ThreadId.makeUnsafe("thread-dedupe"); let originalRevokeObjectUrl: typeof URL.revokeObjectURL; @@ -593,62 +604,54 @@ describe("composerDraftStore project draft thread mapping", () => { }); }); -describe("composerDraftStore modelOptions", () => { +describe("composerDraftStore modelSelection", () => { const threadId = ThreadId.makeUnsafe("thread-model-options"); beforeEach(() => { resetComposerDraftStore(); }); - it("stores provider-scoped model options in the draft", () => { + it("stores a model selection in the draft", () => { const store = useComposerDraftStore.getState(); - store.setModelOptions(threadId, { - codex: { + store.setModelSelection( + threadId, + modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "xhigh", fastMode: true, - }, - claudeAgent: { - thinking: false, - }, - }); + }), + ); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ - codex: { + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( + modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "xhigh", fastMode: true, - }, - claudeAgent: { - thinking: false, - }, - }); + }), + ); }); - it("drops default-only model options from the draft", () => { + it("drops default-only model selections from the draft", () => { const store = useComposerDraftStore.getState(); - store.setModelOptions(threadId, { - codex: { - reasoningEffort: "high", - }, - claudeAgent: { - thinking: true, - }, - }); + store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4")); expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); }); - it("replaces only the targeted provider model options", () => { + it("replaces only the targeted provider options on the current model selection", () => { const store = useComposerDraftStore.getState(); - store.setModelOptions(threadId, { - codex: { - reasoningEffort: "xhigh", - }, - claudeAgent: { + store.setModelSelection( + threadId, + modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max", fastMode: true, - }, - }); + }), + ); + store.setStickyModelSelection( + modelSelection("claudeAgent", "claude-opus-4-6", { + effort: "max", + fastMode: true, + }), + ); store.setProviderModelOptions( threadId, @@ -659,56 +662,40 @@ describe("composerDraftStore modelOptions", () => { { persistSticky: true }, ); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ - codex: { - reasoningEffort: "xhigh", - }, - claudeAgent: { + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( + modelSelection("claudeAgent", "claude-opus-4-6", { thinking: false, - }, - }); - expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({ - codex: { - reasoningEffort: "xhigh", - }, - claudeAgent: { + }), + ); + expect(useComposerDraftStore.getState().stickyModelSelection).toEqual( + modelSelection("claudeAgent", "claude-opus-4-6", { thinking: false, - }, - }); + }), + ); }); - it("removes only the targeted provider entry when next options normalize empty", () => { + it("removes selection options when the patched provider options normalize empty", () => { const store = useComposerDraftStore.getState(); - store.setModelOptions(threadId, { - codex: { - reasoningEffort: "xhigh", - }, - claudeAgent: { + store.setModelSelection( + threadId, + modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max", - }, - }); + }), + ); store.setProviderModelOptions(threadId, "claudeAgent", { thinking: true, }); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ - codex: { - reasoningEffort: "xhigh", - }, - }); - expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({}); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + expect(useComposerDraftStore.getState().stickyModelSelection).toBeNull(); }); it("removes model options entirely when the last provider entry normalizes empty", () => { const store = useComposerDraftStore.getState(); - store.setModelOptions(threadId, { - codex: { - fastMode: true, - }, - }); + store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4", { fastMode: true })); store.setProviderModelOptions(threadId, "codex", { reasoningEffort: "high", @@ -721,52 +708,38 @@ describe("composerDraftStore modelOptions", () => { it("updates only the draft when sticky persistence is omitted", () => { const store = useComposerDraftStore.getState(); - store.setStickyModelOptions({ - codex: { - fastMode: true, - }, - }); - store.setModelOptions(threadId, { - codex: { - fastMode: true, - }, - claudeAgent: { - effort: "max", - }, - }); + store.setStickyModelSelection( + modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), + ); + store.setModelSelection( + threadId, + modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), + ); store.setProviderModelOptions(threadId, "claudeAgent", { thinking: false, }); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ - codex: { - fastMode: true, - }, - claudeAgent: { + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( + modelSelection("claudeAgent", "claude-opus-4-6", { thinking: false, - }, - }); - expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({ - codex: { - fastMode: true, - }, - }); + }), + ); + expect(useComposerDraftStore.getState().stickyModelSelection).toEqual( + modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), + ); }); it("updates only the draft when sticky persistence is disabled", () => { const store = useComposerDraftStore.getState(); - store.setStickyModelOptions({ - claudeAgent: { - effort: "max", - }, - }); - store.setModelOptions(threadId, { - claudeAgent: { - effort: "max", - }, - }); + store.setStickyModelSelection( + modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), + ); + store.setModelSelection( + threadId, + modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), + ); store.setProviderModelOptions( threadId, @@ -777,33 +750,31 @@ describe("composerDraftStore modelOptions", () => { { persistSticky: false }, ); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ - claudeAgent: { + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( + modelSelection("claudeAgent", "claude-opus-4-6", { thinking: false, - }, - }); - expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({ - claudeAgent: { - effort: "max", - }, - }); + }), + ); + expect(useComposerDraftStore.getState().stickyModelSelection).toEqual( + modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), + ); }); }); -describe("composerDraftStore setModel", () => { +describe("composerDraftStore setModelSelection", () => { const threadId = ThreadId.makeUnsafe("thread-model"); beforeEach(() => { resetComposerDraftStore(); }); - it("keeps explicit DEFAULT_MODEL overrides instead of coercing to null", () => { + it("keeps explicit model overrides instead of coercing to null", () => { const store = useComposerDraftStore.getState(); - store.setModel(threadId, "gpt-5.3-codex"); + store.setModelSelection(threadId, modelSelection("codex", "gpt-5.3-codex")); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.model).toBe( - "gpt-5.3-codex", + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( + modelSelection("codex", "gpt-5.3-codex"), ); }); }); @@ -813,21 +784,21 @@ describe("composerDraftStore sticky composer settings", () => { resetComposerDraftStore(); }); - it("stores sticky model and codex model options", () => { + it("stores a sticky model selection", () => { const store = useComposerDraftStore.getState(); - store.setStickyModel("gpt-5.3-codex"); - store.setStickyModelOptions({ - codex: { + store.setStickyModelSelection( + modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium", fastMode: true, - }, - }); + }), + ); expect(useComposerDraftStore.getState()).toMatchObject({ - stickyModel: "gpt-5.3-codex", - stickyModelOptions: { - codex: { + stickyModelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + options: { reasoningEffort: "medium", fastMode: true, }, @@ -835,40 +806,28 @@ describe("composerDraftStore sticky composer settings", () => { }); }); - it("normalizes empty sticky model options", () => { + it("normalizes empty sticky model options by dropping selection options", () => { const store = useComposerDraftStore.getState(); - store.setStickyModelOptions({ - codex: { - fastMode: false, - }, - }); + store.setStickyModelSelection(modelSelection("codex", "gpt-5.4")); - expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({}); + expect(useComposerDraftStore.getState().stickyModelSelection).toEqual( + modelSelection("codex", "gpt-5.4"), + ); }); }); -describe("composerDraftStore setProvider", () => { +describe("composerDraftStore provider-scoped option updates", () => { const threadId = ThreadId.makeUnsafe("thread-provider"); beforeEach(() => { resetComposerDraftStore(); }); - it("persists provider-only selection even when prompt/model are empty", () => { - const store = useComposerDraftStore.getState(); - - store.setProvider(threadId, "codex"); - - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.provider).toBe("codex"); - }); - - it("removes empty provider-only draft when provider is reset", () => { + it("does not patch provider options when the draft selection is on another provider", () => { const store = useComposerDraftStore.getState(); - - store.setProvider(threadId, "codex"); - store.setProvider(threadId, null); - + store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4")); + store.setProviderModelOptions(threadId, "claudeAgent", { effort: "max" }); expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index fc62cf0a92..73ec289ec0 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -3,6 +3,7 @@ import { type ClaudeCodeEffort, type CodexReasoningEffort, DEFAULT_REASONING_EFFORT_BY_PROVIDER, + type ModelSelection, ProjectId, ProviderInteractionMode, ProviderKind, @@ -73,9 +74,7 @@ const PersistedComposerThreadDraftState = Schema.Struct({ prompt: Schema.String, attachments: Schema.Array(PersistedComposerImageAttachment), terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)), - provider: Schema.optionalKey(ProviderKind), - model: Schema.optionalKey(Schema.String), - modelOptions: Schema.optionalKey(ProviderModelOptions), + modelSelection: Schema.optionalKey(Schema.NullOr(Schema.Unknown)), runtimeMode: Schema.optionalKey(RuntimeMode), interactionMode: Schema.optionalKey(ProviderInteractionMode), }); @@ -89,6 +88,11 @@ const LegacyCodexFields = Schema.Struct({ type LegacyCodexFields = typeof LegacyCodexFields.Type; type LegacyPersistedCodexThreadDraftState = PersistedComposerThreadDraftState & LegacyCodexFields; +type LegacyPersistedComposerThreadDraftState = LegacyPersistedCodexThreadDraftState & { + provider?: ProviderKind; + model?: string; + modelOptions?: ProviderModelOptions | null; +}; const PersistedDraftThreadState = Schema.Struct({ projectId: ProjectId, @@ -105,8 +109,7 @@ const PersistedComposerDraftStoreState = Schema.Struct({ draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState), draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState), projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId), - stickyModel: Schema.NullOr(Schema.String), - stickyModelOptions: ProviderModelOptions, + stickyModelSelection: Schema.NullOr(Schema.Unknown), }); type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type; @@ -121,9 +124,7 @@ interface ComposerThreadDraftState { nonPersistedImageIds: string[]; persistedAttachments: PersistedComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; - provider: ProviderKind | null; - model: string | null; - modelOptions: ProviderModelOptions | null; + modelSelection: ModelSelection | null; runtimeMode: RuntimeMode | null; interactionMode: ProviderInteractionMode | null; } @@ -146,8 +147,7 @@ interface ComposerDraftStoreState { draftsByThreadId: Record; draftThreadsByThreadId: Record; projectDraftThreadIdByProjectId: Record; - stickyModel: string | null; - stickyModelOptions: ProviderModelOptions; + stickyModelSelection: ModelSelection | null; getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null; getDraftThread: (threadId: ThreadId) => DraftThreadState | null; setProjectDraftThreadId: ( @@ -177,15 +177,12 @@ interface ComposerDraftStoreState { clearProjectDraftThreadId: (projectId: ProjectId) => void; clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; clearDraftThread: (threadId: ThreadId) => void; - setStickyModel: (model: string | null | undefined) => void; - setStickyModelOptions: (modelOptions: ProviderModelOptions | null | undefined) => void; + setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void; setPrompt: (threadId: ThreadId, prompt: string) => void; setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void; - setProvider: (threadId: ThreadId, provider: ProviderKind | null | undefined) => void; - setModel: (threadId: ThreadId, model: string | null | undefined) => void; - setModelOptions: ( + setModelSelection: ( threadId: ThreadId, - modelOptions: ProviderModelOptions | null | undefined, + modelSelection: ModelSelection | null | undefined, ) => void; setProviderModelOptions: ( threadId: ThreadId, @@ -222,14 +219,11 @@ interface ComposerDraftStoreState { clearThreadDraft: (threadId: ThreadId) => void; } -const EMPTY_PROVIDER_MODEL_OPTIONS = Object.freeze({}); - const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze({ draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, - stickyModel: null, - stickyModelOptions: EMPTY_PROVIDER_MODEL_OPTIONS, + stickyModelSelection: null, }); const EMPTY_IMAGES: ComposerImageAttachment[] = []; @@ -245,9 +239,7 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ nonPersistedImageIds: EMPTY_IDS, persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, terminalContexts: EMPTY_TERMINAL_CONTEXTS, - provider: null, - model: null, - modelOptions: null, + modelSelection: null, runtimeMode: null, interactionMode: null, }); @@ -259,9 +251,7 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], - provider: null, - model: null, - modelOptions: null, + modelSelection: null, runtimeMode: null, interactionMode: null, }; @@ -330,9 +320,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { draft.images.length === 0 && draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && - draft.provider === null && - draft.model === null && - draft.modelOptions === null && + draft.modelSelection === null && draft.runtimeMode === null && draft.interactionMode === null ); @@ -416,6 +404,41 @@ function normalizeProviderModelOptions( }; } +function normalizeModelSelection( + value: unknown, + legacy?: { + provider?: unknown; + model?: unknown; + modelOptions?: unknown; + legacyCodex?: LegacyCodexFields; + }, +): ModelSelection | null { + const candidate = value && typeof value === "object" ? (value as Record) : null; + const provider = normalizeProviderKind(candidate?.provider ?? legacy?.provider); + if (provider === null) { + return null; + } + const rawModel = candidate?.model ?? legacy?.model; + if (typeof rawModel !== "string") { + return null; + } + const model = normalizeModelSlug(rawModel, provider); + if (!model) { + return null; + } + const modelOptions = normalizeProviderModelOptions( + candidate?.options ? { [provider]: candidate.options } : legacy?.modelOptions, + provider, + provider === "codex" ? legacy?.legacyCodex : undefined, + ); + const options = provider === "codex" ? modelOptions?.codex : modelOptions?.claudeAgent; + return { + provider, + model, + ...(options ? { options } : {}), + }; +} + function replaceProviderModelOptions( currentModelOptions: ProviderModelOptions | null | undefined, provider: ProviderKind, @@ -620,10 +643,6 @@ function normalizePersistedDraftThreads( function normalizePersistedDraftsByThreadId( rawDraftMap: unknown, - resolveModelOptions: ( - draftCandidate: PersistedComposerThreadDraftState | LegacyPersistedCodexThreadDraftState, - provider: ProviderKind | null, - ) => ProviderModelOptions | null, ): PersistedComposerDraftStoreState["draftsByThreadId"] { if (!rawDraftMap || typeof rawDraftMap !== "object") { return {}; @@ -654,11 +673,6 @@ function normalizePersistedDraftsByThreadId( return normalized ? [normalized] : []; }) : []; - const provider = normalizeProviderKind(draftCandidate.provider); - const model = - typeof draftCandidate.model === "string" - ? normalizeModelSlug(draftCandidate.model, provider ?? "codex") - : null; const runtimeMode = draftCandidate.runtimeMode === "approval-required" || draftCandidate.runtimeMode === "full-access" @@ -668,18 +682,22 @@ function normalizePersistedDraftsByThreadId( draftCandidate.interactionMode === "plan" || draftCandidate.interactionMode === "default" ? draftCandidate.interactionMode : null; - const modelOptions = resolveModelOptions(draftCandidate, provider); const prompt = ensureInlineTerminalContextPlaceholders( promptCandidate, terminalContexts.length, ); + const legacyDraftCandidate = draftCandidate as LegacyPersistedComposerThreadDraftState; + const modelSelection = normalizeModelSelection(draftCandidate.modelSelection, { + provider: legacyDraftCandidate.provider, + model: legacyDraftCandidate.model, + modelOptions: legacyDraftCandidate.modelOptions, + legacyCodex: legacyDraftCandidate, + }); if ( promptCandidate.length === 0 && attachments.length === 0 && terminalContexts.length === 0 && - !provider && - !model && - modelOptions === null && + modelSelection === null && !runtimeMode && !interactionMode ) { @@ -689,9 +707,7 @@ function normalizePersistedDraftsByThreadId( prompt, attachments, ...(terminalContexts.length > 0 ? { terminalContexts } : {}), - ...(provider ? { provider } : {}), - ...(model ? { model } : {}), - ...(modelOptions ? { modelOptions } : {}), + ...(modelSelection ? { modelSelection } : {}), ...(runtimeMode ? { runtimeMode } : {}), ...(interactionMode ? { interactionMode } : {}), }; @@ -702,7 +718,6 @@ function normalizePersistedDraftsByThreadId( function migratePersistedComposerDraftStoreState( persistedState: unknown, - persistedVersion: number, ): PersistedComposerDraftStoreState { if (!persistedState || typeof persistedState !== "object") { return EMPTY_PERSISTED_DRAFT_STORE_STATE; @@ -711,31 +726,19 @@ function migratePersistedComposerDraftStoreState( const rawDraftMap = candidate.draftsByThreadId; const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId; const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId; - const stickyModel = - typeof candidate.stickyModel === "string" - ? (normalizeModelSlug(candidate.stickyModel, "codex") ?? null) - : null; - const stickyModelOptions = - normalizeProviderModelOptions(candidate.stickyModelOptions) ?? EMPTY_PROVIDER_MODEL_OPTIONS; + const stickyModelSelection = normalizeModelSelection(candidate.stickyModelSelection, { + provider: candidate.stickyProvider, + model: candidate.stickyModel, + modelOptions: candidate.stickyModelOptions, + }); const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } = normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectId); - const draftsByThreadId = normalizePersistedDraftsByThreadId( - rawDraftMap, - (draftCandidate, provider) => - persistedVersion >= COMPOSER_DRAFT_STORAGE_VERSION - ? normalizeProviderModelOptions(draftCandidate.modelOptions, provider) - : normalizeProviderModelOptions( - draftCandidate.modelOptions, - provider, - draftCandidate as LegacyPersistedCodexThreadDraftState, - ), - ); + const draftsByThreadId = normalizePersistedDraftsByThreadId(rawDraftMap); return { draftsByThreadId, draftThreadsByThreadId, projectDraftThreadIdByProjectId, - stickyModel, - stickyModelOptions, + stickyModelSelection, }; } @@ -753,9 +756,7 @@ function partializeComposerDraftStoreState( draft.prompt.length === 0 && draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && - draft.provider === null && - draft.model === null && - draft.modelOptions === null && + draft.modelSelection === null && draft.runtimeMode === null && draft.interactionMode === null ) { @@ -777,9 +778,7 @@ function partializeComposerDraftStoreState( })), } : {}), - ...(draft.model ? { model: draft.model } : {}), - ...(draft.modelOptions ? { modelOptions: draft.modelOptions } : {}), - ...(draft.provider ? { provider: draft.provider } : {}), + ...(draft.modelSelection ? { modelSelection: draft.modelSelection } : {}), ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}), ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}), }; @@ -789,8 +788,7 @@ function partializeComposerDraftStoreState( draftsByThreadId: persistedDraftsByThreadId, draftThreadsByThreadId: state.draftThreadsByThreadId, projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId, - stickyModel: state.stickyModel, - stickyModelOptions: state.stickyModelOptions, + stickyModelSelection: state.stickyModelSelection, }; } @@ -806,23 +804,19 @@ function normalizeCurrentPersistedComposerDraftStoreState( normalizedPersistedState.draftThreadsByThreadId, normalizedPersistedState.projectDraftThreadIdByProjectId, ); - const stickyModel = - typeof normalizedPersistedState.stickyModel === "string" - ? (normalizeModelSlug(normalizedPersistedState.stickyModel, "codex") ?? null) - : null; - const stickyModelOptions = - normalizeProviderModelOptions(normalizedPersistedState.stickyModelOptions) ?? - EMPTY_PROVIDER_MODEL_OPTIONS; + const stickyModelSelection = normalizeModelSelection( + normalizedPersistedState.stickyModelSelection, + { + provider: normalizedPersistedState.stickyProvider, + model: normalizedPersistedState.stickyModel, + modelOptions: normalizedPersistedState.stickyModelOptions, + }, + ); return { - draftsByThreadId: normalizePersistedDraftsByThreadId( - normalizedPersistedState.draftsByThreadId, - (draftCandidate, provider) => - normalizeProviderModelOptions(draftCandidate.modelOptions, provider), - ), + draftsByThreadId: normalizePersistedDraftsByThreadId(normalizedPersistedState.draftsByThreadId), draftThreadsByThreadId, projectDraftThreadIdByProjectId, - stickyModel, - stickyModelOptions, + stickyModelSelection, }; } @@ -959,9 +953,12 @@ function toHydratedThreadDraft( ...context, text: "", })) ?? [], - provider: persistedDraft.provider ?? null, - model: persistedDraft.model ?? null, - modelOptions: persistedDraft.modelOptions ?? null, + modelSelection: normalizeModelSelection(persistedDraft.modelSelection, { + provider: (persistedDraft as LegacyPersistedComposerThreadDraftState).provider, + model: (persistedDraft as LegacyPersistedComposerThreadDraftState).model, + modelOptions: (persistedDraft as LegacyPersistedComposerThreadDraftState).modelOptions, + legacyCodex: persistedDraft as LegacyPersistedComposerThreadDraftState, + }), runtimeMode: persistedDraft.runtimeMode ?? null, interactionMode: persistedDraft.interactionMode ?? null, }; @@ -973,8 +970,7 @@ export const useComposerDraftStore = create()( draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, - stickyModel: null, - stickyModelOptions: EMPTY_PROVIDER_MODEL_OPTIONS, + stickyModelSelection: null, getDraftThreadByProjectId: (projectId) => { if (projectId.length === 0) { return null; @@ -1210,28 +1206,13 @@ export const useComposerDraftStore = create()( }; }); }, - setStickyModel: (model) => { - const normalizedModel = normalizeModelSlug(model, "codex") ?? null; - set((state) => { - if (state.stickyModel === normalizedModel) { - return state; - } - return { - stickyModel: normalizedModel, - }; - }); - }, - setStickyModelOptions: (modelOptions) => { - const normalizedModelOptions = - normalizeProviderModelOptions(modelOptions) ?? EMPTY_PROVIDER_MODEL_OPTIONS; - set((state) => { - if (Equal.equals(state.stickyModelOptions, normalizedModelOptions)) { - return state; - } - return { - stickyModelOptions: normalizedModelOptions, - }; - }); + setStickyModelSelection: (modelSelection) => { + const normalizedModelSelection = normalizeModelSelection(modelSelection); + set((state) => + Equal.equals(state.stickyModelSelection, normalizedModelSelection) + ? state + : { stickyModelSelection: normalizedModelSelection }, + ); }, setPrompt: (threadId, prompt) => { if (threadId.length === 0) { @@ -1276,78 +1257,23 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId }; }); }, - setProvider: (threadId, provider) => { - if (threadId.length === 0) { - return; - } - const normalizedProvider = normalizeProviderKind(provider); - set((state) => { - const existing = state.draftsByThreadId[threadId]; - if (!existing && normalizedProvider === null) { - return state; - } - const base = existing ?? createEmptyThreadDraft(); - if (base.provider === normalizedProvider) { - return state; - } - const nextDraft: ComposerThreadDraftState = { - ...base, - provider: normalizedProvider, - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; - } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - setModel: (threadId, model) => { + setModelSelection: (threadId, modelSelection) => { if (threadId.length === 0) { return; } + const normalizedModelSelection = normalizeModelSelection(modelSelection); set((state) => { const existing = state.draftsByThreadId[threadId]; - const normalizedModel = normalizeModelSlug(model, existing?.provider ?? "codex") ?? null; - if (!existing && normalizedModel === null) { + if (!existing && normalizedModelSelection === null) { return state; } const base = existing ?? createEmptyThreadDraft(); - if (base.model === normalizedModel) { + if (Equal.equals(base.modelSelection, normalizedModelSelection)) { return state; } const nextDraft: ComposerThreadDraftState = { ...base, - model: normalizedModel, - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; - } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - setModelOptions: (threadId, modelOptions) => { - if (threadId.length === 0) { - return; - } - set((state) => { - const existing = state.draftsByThreadId[threadId]; - const provider = existing?.provider ?? null; - const nextModelOptions = normalizeProviderModelOptions(modelOptions, provider); - if (!existing && nextModelOptions === null) { - return state; - } - const base = existing ?? createEmptyThreadDraft(); - if (Equal.equals(base.modelOptions, nextModelOptions)) { - return state; - } - const nextDraft: ComposerThreadDraftState = { - ...base, - modelOptions: nextModelOptions, + modelSelection: normalizedModelSelection, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; if (shouldRemoveDraft(nextDraft)) { @@ -1369,26 +1295,45 @@ export const useComposerDraftStore = create()( set((state) => { const existing = state.draftsByThreadId[threadId]; const base = existing ?? createEmptyThreadDraft(); - const nextModelOptions = replaceProviderModelOptions( - base.modelOptions, + if (base.modelSelection?.provider !== normalizedProvider) { + return state; + } + const nextProviderModelOptions = replaceProviderModelOptions( + base.modelSelection?.options + ? { [normalizedProvider]: base.modelSelection.options } + : null, normalizedProvider, nextProviderOptions, ); - const nextStickyModelOptions = - options?.persistSticky === true - ? (nextModelOptions ?? EMPTY_PROVIDER_MODEL_OPTIONS) - : state.stickyModelOptions; + const nextOptions = + normalizedProvider === "codex" + ? nextProviderModelOptions?.codex + : nextProviderModelOptions?.claudeAgent; + const nextModelSelection = + base.modelSelection === null + ? null + : { + provider: base.modelSelection.provider, + model: base.modelSelection.model, + ...(nextOptions ? { options: nextOptions } : {}), + }; + const nextStickyModelSelection = + options?.persistSticky === true && + state.stickyModelSelection?.provider === normalizedProvider && + nextModelSelection !== null + ? nextModelSelection + : state.stickyModelSelection; if ( - Equal.equals(base.modelOptions, nextModelOptions) && - Equal.equals(state.stickyModelOptions, nextStickyModelOptions) + Equal.equals(base.modelSelection, nextModelSelection) && + Equal.equals(state.stickyModelSelection, nextStickyModelSelection) ) { return state; } const nextDraft: ComposerThreadDraftState = { ...base, - modelOptions: nextModelOptions, + modelSelection: nextModelSelection, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; if (shouldRemoveDraft(nextDraft)) { @@ -1400,7 +1345,7 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId, ...(options?.persistSticky === true - ? { stickyModelOptions: nextStickyModelOptions } + ? { stickyModelSelection: nextStickyModelSelection } : {}), }; }); @@ -1794,8 +1739,7 @@ export const useComposerDraftStore = create()( draftsByThreadId, draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId, projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId, - stickyModel: normalizedPersisted.stickyModel, - stickyModelOptions: normalizedPersisted.stickyModelOptions, + stickyModelSelection: normalizedPersisted.stickyModelSelection as ModelSelection | null, }; }, }, diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index e31809cdd2..40d8c68f22 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -1,7 +1,6 @@ import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts"; import { useNavigate, useParams } from "@tanstack/react-router"; import { useCallback } from "react"; -import { inferProviderForModel } from "@t3tools/shared/model"; import { type DraftThreadEnvMode, type DraftThreadState, @@ -13,8 +12,7 @@ import { useStore } from "../store"; export function useHandleNewThread() { const projects = useStore((store) => store.projects); const threads = useStore((store) => store.threads); - const stickyModel = useComposerDraftStore((store) => store.stickyModel); - const stickyModelOptions = useComposerDraftStore((store) => store.stickyModelOptions); + const stickyModelSelection = useComposerDraftStore((store) => store.stickyModelSelection); const navigate = useNavigate(); const routeThreadId = useParams({ strict: false, @@ -41,9 +39,7 @@ export function useHandleNewThread() { clearProjectDraftThreadId, getDraftThread, getDraftThreadByProjectId, - setModel, - setModelOptions, - setProvider, + setModelSelection, setDraftThreadContext, setProjectDraftThreadId, } = useComposerDraftStore.getState(); @@ -102,12 +98,8 @@ export function useHandleNewThread() { envMode: options?.envMode ?? "local", runtimeMode: DEFAULT_RUNTIME_MODE, }); - if (stickyModel) { - setProvider(threadId, inferProviderForModel(stickyModel)); - setModel(threadId, stickyModel); - } - if (Object.keys(stickyModelOptions).length > 0) { - setModelOptions(threadId, stickyModelOptions); + if (stickyModelSelection) { + setModelSelection(threadId, stickyModelSelection); } await navigate({ @@ -116,7 +108,7 @@ export function useHandleNewThread() { }); })(); }, - [navigate, routeThreadId, stickyModel, stickyModelOptions], + [navigate, routeThreadId, stickyModelSelection], ); return { diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index f1919ec724..5debe38adb 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -16,7 +16,10 @@ function makeThread(overrides: Partial = {}): Thread { codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, runtimeMode: DEFAULT_RUNTIME_MODE, interactionMode: DEFAULT_INTERACTION_MODE, session: null, @@ -40,7 +43,10 @@ function makeState(thread: Thread): AppState { id: ProjectId.makeUnsafe("project-1"), name: "Project", cwd: "/tmp/project", - model: "gpt-5-codex", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, expanded: true, scripts: [], }, @@ -55,7 +61,10 @@ function makeReadModelThread(overrides: Partial { id: project1, name: "Project 1", cwd: "/tmp/project-1", - model: DEFAULT_MODEL_BY_PROVIDER.codex, + defaultModelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, expanded: true, scripts: [], }, @@ -167,7 +185,10 @@ describe("store pure functions", () => { id: project2, name: "Project 2", cwd: "/tmp/project-2", - model: DEFAULT_MODEL_BY_PROVIDER.codex, + defaultModelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, expanded: true, scripts: [], }, @@ -175,7 +196,10 @@ describe("store pure functions", () => { id: project3, name: "Project 3", cwd: "/tmp/project-3", - model: DEFAULT_MODEL_BY_PROVIDER.codex, + defaultModelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, expanded: true, scripts: [], }, @@ -195,20 +219,26 @@ describe("store read model sync", () => { const initialState = makeState(makeThread()); const readModel = makeReadModel( makeReadModelThread({ - model: "claude-opus-4-6", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + }, }), ); const next = syncServerReadModel(initialState, readModel); - expect(next.threads[0]?.model).toBe("claude-opus-4-6"); + expect(next.threads[0]?.modelSelection.model).toBe("claude-opus-4-6"); }); it("resolves claude aliases when session provider is claudeAgent", () => { const initialState = makeState(makeThread()); const readModel = makeReadModel( makeReadModelThread({ - model: "sonnet", + modelSelection: { + provider: "claudeAgent", + model: "sonnet", + }, session: { threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", @@ -223,7 +253,7 @@ describe("store read model sync", () => { const next = syncServerReadModel(initialState, readModel); - expect(next.threads[0]?.model).toBe("claude-sonnet-4-6"); + expect(next.threads[0]?.modelSelection.model).toBe("claude-sonnet-4-6"); }); it("preserves the current project order when syncing incoming read model updates", () => { @@ -236,7 +266,10 @@ describe("store read model sync", () => { id: project2, name: "Project 2", cwd: "/tmp/project-2", - model: DEFAULT_MODEL_BY_PROVIDER.codex, + defaultModelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, expanded: true, scripts: [], }, @@ -244,7 +277,10 @@ describe("store read model sync", () => { id: project1, name: "Project 1", cwd: "/tmp/project-1", - model: DEFAULT_MODEL_BY_PROVIDER.codex, + defaultModelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, expanded: true, scripts: [], }, diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 8269b30a65..4ea0690505 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,16 +1,13 @@ import { Fragment, type ReactNode, createElement, useEffect } from "react"; import { DEFAULT_MODEL_BY_PROVIDER, + type ModelSelection, type ProviderKind, ThreadId, type OrchestrationReadModel, type OrchestrationSessionStatus, } from "@t3tools/contracts"; -import { - inferProviderForModel, - resolveModelSlug, - resolveModelSlugForProvider, -} from "@t3tools/shared/model"; +import { resolveModelSlugForProvider } from "@t3tools/shared/model"; import { create } from "zustand"; import { type ChatMessage, type Project, type Thread } from "./types"; import { Debouncer } from "@tanstack/react-pacer"; @@ -137,9 +134,17 @@ function mapProjectsFromReadModel( id: project.id, name: project.title, cwd: project.workspaceRoot, - model: - existing?.model ?? - resolveModelSlug(project.defaultModel ?? DEFAULT_MODEL_BY_PROVIDER.codex), + defaultModelSelection: + existing?.defaultModelSelection ?? + (project.defaultModelSelection + ? { + ...project.defaultModelSelection, + model: resolveModelSlugForProvider( + project.defaultModelSelection.provider, + project.defaultModelSelection.model, + ), + } + : null), expanded: existing?.expanded ?? (persistedExpandedProjectCwds.size > 0 @@ -194,16 +199,6 @@ function toLegacyProvider(providerName: string | null): ProviderKind { return "codex"; } -function inferProviderForThreadModel(input: { - readonly model: string; - readonly sessionProviderName: string | null; -}): ProviderKind { - if (input.sessionProviderName === "codex" || input.sessionProviderName === "claudeAgent") { - return input.sessionProviderName; - } - return inferProviderForModel(input.model); -} - function resolveWsHttpOrigin(): string { if (typeof window === "undefined") return ""; const bridgeWsUrl = window.desktopBridge?.getWsUrl?.(); @@ -253,13 +248,13 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea codexThreadId: null, projectId: thread.projectId, title: thread.title, - model: resolveModelSlugForProvider( - inferProviderForThreadModel({ - model: thread.model, - sessionProviderName: thread.session?.providerName ?? null, - }), - thread.model, - ), + modelSelection: { + ...thread.modelSelection, + model: resolveModelSlugForProvider( + thread.modelSelection.provider, + thread.modelSelection.model, + ), + }, runtimeMode: thread.runtimeMode, interactionMode: thread.interactionMode, session: thread.session @@ -328,6 +323,26 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea }; } +export function resolveModelSelection( + modelSelection: ModelSelection | null | undefined, + fallbackProvider: ProviderKind, + fallbackModel?: string | null, +): ModelSelection { + if (modelSelection) { + return { + ...modelSelection, + model: resolveModelSlugForProvider(modelSelection.provider, modelSelection.model), + }; + } + return { + provider: fallbackProvider, + model: resolveModelSlugForProvider( + fallbackProvider, + fallbackModel ?? DEFAULT_MODEL_BY_PROVIDER[fallbackProvider], + ), + }; +} + export function markThreadVisited( state: AppState, threadId: ThreadId, diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 32a7fe02b7..b8290e4109 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -1,4 +1,5 @@ import type { + ModelSelection, OrchestrationLatestTurn, OrchestrationProposedPlanId, OrchestrationSessionStatus, @@ -8,8 +9,8 @@ import type { ProjectId, TurnId, MessageId, - CheckpointRef, ProviderKind, + CheckpointRef, ProviderInteractionMode, RuntimeMode, } from "@t3tools/contracts"; @@ -80,7 +81,7 @@ export interface Project { id: ProjectId; name: string; cwd: string; - model: string; + defaultModelSelection: ModelSelection | null; expanded: boolean; scripts: ProjectScript[]; } @@ -90,7 +91,7 @@ export interface Thread { codexThreadId: string | null; projectId: ProjectId; title: string; - model: string; + modelSelection: ModelSelection; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; session: ThreadSession | null; diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 516df6046a..574675ae11 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -10,7 +10,10 @@ function makeThread(overrides: Partial = {}): Thread { codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", - model: "gpt-5.3-codex", + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, runtimeMode: DEFAULT_RUNTIME_MODE, interactionMode: DEFAULT_INTERACTION_MODE, session: null, diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 2323380da0..811ad24ee4 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -267,7 +267,7 @@ describe("wsNativeApi", () => { projectId: ProjectId.makeUnsafe("project-1"), title: "Project", workspaceRoot: "/tmp/workspace", - defaultModel: null, + defaultModelSelection: null, scripts: [], createdAt: "2026-02-24T00:00:00.000Z", updatedAt: "2026-02-24T00:00:00.000Z", @@ -292,7 +292,10 @@ describe("wsNativeApi", () => { projectId: ProjectId.makeUnsafe("project-1"), title: "Project", workspaceRoot: "/tmp/project", - defaultModel: "gpt-5-codex", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, createdAt: "2026-02-24T00:00:00.000Z", } as const; await api.orchestration.dispatchCommand(command); diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 6fdff4e886..7bf2274687 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -7,9 +7,12 @@ import { DEFAULT_RUNTIME_MODE, OrchestrationGetTurnDiffInput, OrchestrationLatestTurn, + ProjectCreatedPayload, + ProjectMetaUpdatedPayload, OrchestrationProposedPlan, OrchestrationSession, ProjectCreateCommand, + ThreadMetaUpdatedPayload, ThreadTurnStartCommand, ThreadCreatedPayload, ThreadTurnDiff, @@ -19,6 +22,8 @@ import { const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff); const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand); +const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload); +const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload); const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartCommand); const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( ThreadTurnStartRequestedPayload, @@ -27,6 +32,7 @@ const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLa const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan); const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload); +const decodeThreadMetaUpdatedPayload = Schema.decodeUnknownEffect(ThreadMetaUpdatedPayload); it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () => Effect.gen(function* () { @@ -75,14 +81,85 @@ it.effect("trims branded ids and command string fields at decode boundaries", () projectId: " project-1 ", title: " Project Title ", workspaceRoot: " /tmp/workspace ", - defaultModel: " gpt-5.2 ", + defaultModelSelection: { + provider: "codex", + model: " gpt-5.2 ", + }, createdAt: "2026-01-01T00:00:00.000Z", }); assert.strictEqual(parsed.commandId, "cmd-1"); assert.strictEqual(parsed.projectId, "project-1"); assert.strictEqual(parsed.title, "Project Title"); assert.strictEqual(parsed.workspaceRoot, "/tmp/workspace"); - assert.strictEqual(parsed.defaultModel, "gpt-5.2"); + assert.deepStrictEqual(parsed.defaultModelSelection, { + provider: "codex", + model: "gpt-5.2", + }); + }), +); + +it.effect("decodes historical project.created payloads with a default provider", () => + Effect.gen(function* () { + const parsed = yield* decodeProjectCreatedPayload({ + projectId: "project-1", + title: "Project Title", + workspaceRoot: "/tmp/workspace", + defaultModelSelection: { + provider: "codex", + model: "gpt-5.4", + }, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.defaultModelSelection?.provider, "codex"); + }), +); + +it.effect("decodes legacy project.created payloads into defaultModelSelection", () => + Effect.gen(function* () { + const parsed = yield* decodeProjectCreatedPayload({ + projectId: "project-legacy-1", + title: "Project Title", + workspaceRoot: "/tmp/workspace", + defaultModel: "gpt-5.4", + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + assert.deepStrictEqual(parsed.defaultModelSelection, { + provider: "codex", + model: "gpt-5.4", + }); + }), +); + +it.effect("decodes project.meta-updated payloads with explicit default provider", () => + Effect.gen(function* () { + const parsed = yield* decodeProjectMetaUpdatedPayload({ + projectId: "project-1", + defaultModelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + }, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.defaultModelSelection?.provider, "claudeAgent"); + }), +); + +it.effect("decodes legacy project.meta-updated payloads into defaultModelSelection", () => + Effect.gen(function* () { + const parsed = yield* decodeProjectMetaUpdatedPayload({ + projectId: "project-legacy-2", + defaultProvider: "claudeAgent", + defaultModel: "claude-opus-4-6", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + assert.deepStrictEqual(parsed.defaultModelSelection, { + provider: "claudeAgent", + model: "claude-opus-4-6", + }); }), ); @@ -116,7 +193,7 @@ it.effect("decodes thread.turn.start defaults for provider and runtime mode", () }, createdAt: "2026-01-01T00:00:00.000Z", }); - assert.strictEqual(parsed.provider, undefined); + assert.strictEqual(parsed.modelSelection, undefined); assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE); }), @@ -134,11 +211,14 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", ( text: "hello", attachments: [], }, - provider: "codex", + modelSelection: { + provider: "codex", + model: "gpt-5.4", + }, runtimeMode: "full-access", createdAt: "2026-01-01T00:00:00.000Z", }); - assert.strictEqual(parsed.provider, "codex"); + assert.strictEqual(parsed.modelSelection?.provider, "codex"); assert.strictEqual(parsed.runtimeMode, "full-access"); assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE); }), @@ -150,7 +230,10 @@ it.effect("decodes thread.created runtime mode for historical events", () => threadId: "thread-1", projectId: "project-1", title: "Thread title", - model: "gpt-5.4", + modelSelection: { + provider: "codex", + model: "gpt-5.4", + }, interactionMode: "default", branch: null, worktreePath: null, @@ -159,6 +242,58 @@ it.effect("decodes thread.created runtime mode for historical events", () => }); assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); + assert.strictEqual(parsed.modelSelection.provider, "codex"); + }), +); + +it.effect("decodes legacy thread.created payloads into modelSelection", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadCreatedPayload({ + threadId: "thread-legacy-1", + projectId: "project-1", + title: "Thread title", + model: "claude-opus-4-6", + provider: "claudeAgent", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + + assert.deepStrictEqual(parsed.modelSelection, { + provider: "claudeAgent", + model: "claude-opus-4-6", + }); + assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); + }), +); + +it.effect("decodes thread.meta-updated payloads with explicit provider", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadMetaUpdatedPayload({ + threadId: "thread-1", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + }, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.modelSelection?.provider, "claudeAgent"); + }), +); + +it.effect("decodes legacy thread.meta-updated payloads into modelSelection", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadMetaUpdatedPayload({ + threadId: "thread-legacy-2", + model: "gpt-5.4", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + assert.deepStrictEqual(parsed.modelSelection, { + provider: "codex", + model: "gpt-5.4", + }); }), ); @@ -174,19 +309,19 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () => text: "hello", attachments: [], }, - provider: "codex", - model: "gpt-5.3-codex", - modelOptions: { - codex: { + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + options: { reasoningEffort: "high", fastMode: true, }, }, createdAt: "2026-01-01T00:00:00.000Z", }); - assert.strictEqual(parsed.provider, "codex"); - assert.strictEqual(parsed.modelOptions?.codex?.reasoningEffort, "high"); - assert.strictEqual(parsed.modelOptions?.codex?.fastMode, true); + assert.strictEqual(parsed.modelSelection?.provider, "codex"); + assert.strictEqual(parsed.modelSelection?.options?.reasoningEffort, "high"); + assert.strictEqual(parsed.modelSelection?.options?.fastMode, true); }), ); @@ -224,7 +359,7 @@ it.effect( messageId: "msg-1", createdAt: "2026-01-01T00:00:00.000Z", }); - assert.strictEqual(parsed.provider, undefined); + assert.strictEqual(parsed.modelSelection, undefined); assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE); assert.strictEqual(parsed.sourceProposedPlan, undefined); @@ -249,6 +384,21 @@ it.effect("decodes thread.turn-start-requested source proposed plan metadata whe }), ); +it.effect("decodes legacy thread.turn-start-requested payloads into modelSelection", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartRequestedPayload({ + threadId: "thread-legacy-3", + messageId: "msg-legacy-3", + model: "gpt-5.4", + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.deepStrictEqual(parsed.modelSelection, { + provider: "codex", + model: "gpt-5.4", + }); + }), +); + it.effect("decodes latest turn source proposed plan metadata when present", () => Effect.gen(function* () { const parsed = yield* decodeOrchestrationLatestTurn({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 3208adc8bb..c6247c7a53 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,5 +1,5 @@ -import { Option, Schema, SchemaIssue, Struct } from "effect"; -import { ProviderModelOptions } from "./model"; +import { Effect, Option, Schema, SchemaIssue, SchemaTransformation, Struct } from "effect"; +import { ClaudeModelOptions, CodexModelOptions } from "./model"; import { ApprovalRequestId, CheckpointRef, @@ -44,6 +44,113 @@ export const ProviderSandboxMode = Schema.Literals([ export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; +export const CodexModelSelection = Schema.Struct({ + provider: Schema.Literal("codex"), + model: TrimmedNonEmptyString, + options: Schema.optional(CodexModelOptions), +}); +export type CodexModelSelection = typeof CodexModelSelection.Type; + +export const ClaudeModelSelection = Schema.Struct({ + provider: Schema.Literal("claudeAgent"), + model: TrimmedNonEmptyString, + options: Schema.optional(ClaudeModelOptions), +}); +export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; + +export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]); +export type ModelSelection = typeof ModelSelection.Type; +const decodeModelSelectionSync = Schema.decodeUnknownSync(ModelSelection); + +const LegacyDefaultModelSelectionFields = { + defaultProvider: Schema.optional(ProviderKind), + defaultModel: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + defaultModelOptions: Schema.optional(Schema.NullOr(Schema.Unknown)), +} as const; + +const LegacyThreadModelSelectionFields = { + provider: Schema.optional(ProviderKind), + model: Schema.optional(TrimmedNonEmptyString), + modelOptions: Schema.optional(Schema.NullOr(Schema.Unknown)), +} as const; + +function invalidLegacyModelSelectionIssue( + value: unknown, + title: string, + cause: unknown, +): SchemaIssue.InvalidValue { + return new SchemaIssue.InvalidValue(Option.some(value), { + title, + ...(Schema.isSchemaError(cause) + ? { description: SchemaIssue.makeFormatterDefault()(cause.issue) } + : {}), + }); +} + +type LegacyDefaultModelSelectionInput = { + readonly defaultModelSelection?: ModelSelection | null | undefined; + readonly defaultProvider?: ProviderKind | undefined; + readonly defaultModel?: string | null | undefined; + readonly defaultModelOptions?: unknown | null | undefined; +}; + +type LegacyThreadModelSelectionInput = { + readonly modelSelection?: ModelSelection | undefined; + readonly provider?: ProviderKind | undefined; + readonly model?: string | undefined; + readonly modelOptions?: unknown | null | undefined; +}; + +function normalizeLegacyDefaultModelSelection( + input: LegacyDefaultModelSelectionInput, +): Effect.Effect { + if (input.defaultModelSelection !== undefined) { + return Effect.succeed(input.defaultModelSelection); + } + if (input.defaultModel === undefined || input.defaultModel === null) { + return Effect.succeed(null); + } + return Effect.try({ + try: () => + decodeModelSelectionSync({ + provider: input.defaultProvider ?? DEFAULT_PROVIDER_KIND, + model: input.defaultModel, + ...(input.defaultModelOptions != null ? { options: input.defaultModelOptions } : {}), + }), + catch: (cause) => + invalidLegacyModelSelectionIssue( + input, + "Invalid legacy default model selection payload", + cause, + ), + }); +} + +function normalizeLegacyThreadModelSelection( + input: LegacyThreadModelSelectionInput, +): Effect.Effect { + if (input.modelSelection !== undefined) { + return Effect.succeed(input.modelSelection); + } + if (input.model === undefined) { + return Effect.succeed(undefined); + } + return Effect.try({ + try: () => + decodeModelSelectionSync({ + provider: input.provider ?? DEFAULT_PROVIDER_KIND, + model: input.model, + ...(input.modelOptions != null ? { options: input.modelOptions } : {}), + }), + catch: (cause) => + invalidLegacyModelSelectionIssue( + input, + "Invalid legacy thread model selection payload", + cause, + ), + }); +} + export const CodexProviderStartOptions = Schema.Struct({ binaryPath: Schema.optional(TrimmedNonEmptyString), homePath: Schema.optional(TrimmedNonEmptyString), @@ -144,7 +251,7 @@ export const OrchestrationProject = Schema.Struct({ id: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, - defaultModel: Schema.NullOr(TrimmedNonEmptyString), + defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -273,7 +380,7 @@ export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, - model: TrimmedNonEmptyString, + modelSelection: ModelSelection, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode.pipe( Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), @@ -306,7 +413,7 @@ export const ProjectCreateCommand = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, - defaultModel: Schema.optional(TrimmedNonEmptyString), + defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), createdAt: IsoDateTime, }); @@ -316,7 +423,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({ projectId: ProjectId, title: Schema.optional(TrimmedNonEmptyString), workspaceRoot: Schema.optional(TrimmedNonEmptyString), - defaultModel: Schema.optional(TrimmedNonEmptyString), + defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), scripts: Schema.optional(Schema.Array(ProjectScript)), }); @@ -332,7 +439,7 @@ const ThreadCreateCommand = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, - model: TrimmedNonEmptyString, + modelSelection: ModelSelection, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode.pipe( Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), @@ -353,7 +460,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({ commandId: CommandId, threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), - model: Schema.optional(TrimmedNonEmptyString), + modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), }); @@ -384,9 +491,7 @@ export const ThreadTurnStartCommand = Schema.Struct({ text: Schema.String, attachments: Schema.Array(ChatAttachment), }), - provider: Schema.optional(ProviderKind), - model: Schema.optional(TrimmedNonEmptyString), - modelOptions: Schema.optional(ProviderModelOptions), + modelSelection: Schema.optional(ModelSelection), providerOptions: Schema.optional(ProviderStartOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), @@ -407,9 +512,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({ text: Schema.String, attachments: Schema.Array(UploadChatAttachment), }), - provider: Schema.optional(ProviderKind), - model: Schema.optional(TrimmedNonEmptyString), - modelOptions: Schema.optional(ProviderModelOptions), + modelSelection: Schema.optional(ModelSelection), providerOptions: Schema.optional(ProviderStartOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode, @@ -606,35 +709,137 @@ export const OrchestrationAggregateKind = Schema.Literals(["project", "thread"]) export type OrchestrationAggregateKind = typeof OrchestrationAggregateKind.Type; export const OrchestrationActorKind = Schema.Literals(["client", "server", "provider"]); -export const ProjectCreatedPayload = Schema.Struct({ +const ProjectCreatedPayloadCanonical = Schema.Struct({ + projectId: ProjectId, + title: TrimmedNonEmptyString, + workspaceRoot: TrimmedNonEmptyString, + defaultModelSelection: Schema.NullOr(ModelSelection), + scripts: Schema.Array(ProjectScript), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +const ProjectCreatedPayloadSource = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, - defaultModel: Schema.NullOr(TrimmedNonEmptyString), + defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), + ...LegacyDefaultModelSelectionFields, scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); +type ProjectCreatedPayloadSource = typeof ProjectCreatedPayloadSource.Type; +export const ProjectCreatedPayload = ProjectCreatedPayloadSource.pipe( + Schema.decodeTo( + Schema.toType(ProjectCreatedPayloadCanonical), + SchemaTransformation.transformOrFail({ + decode: (input) => + normalizeLegacyDefaultModelSelection(input).pipe( + Effect.map((defaultModelSelection) => ({ + projectId: input.projectId, + title: input.title, + workspaceRoot: input.workspaceRoot, + defaultModelSelection, + scripts: input.scripts, + createdAt: input.createdAt, + updatedAt: input.updatedAt, + })), + ), + encode: (canonical) => + Effect.succeed({ + projectId: canonical.projectId, + title: canonical.title, + workspaceRoot: canonical.workspaceRoot, + defaultModelSelection: canonical.defaultModelSelection, + scripts: canonical.scripts, + createdAt: canonical.createdAt, + updatedAt: canonical.updatedAt, + } as ProjectCreatedPayloadSource), + }), + ), +); -export const ProjectMetaUpdatedPayload = Schema.Struct({ +const ProjectMetaUpdatedPayloadCanonical = Schema.Struct({ projectId: ProjectId, title: Schema.optional(TrimmedNonEmptyString), workspaceRoot: Schema.optional(TrimmedNonEmptyString), - defaultModel: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), + scripts: Schema.optional(Schema.Array(ProjectScript)), + updatedAt: IsoDateTime, +}); +type ProjectMetaUpdatedPayloadCanonical = typeof ProjectMetaUpdatedPayloadCanonical.Type; +const ProjectMetaUpdatedPayloadSource = Schema.Struct({ + projectId: ProjectId, + title: Schema.optional(TrimmedNonEmptyString), + workspaceRoot: Schema.optional(TrimmedNonEmptyString), + defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), + ...LegacyDefaultModelSelectionFields, scripts: Schema.optional(Schema.Array(ProjectScript)), updatedAt: IsoDateTime, }); +type ProjectMetaUpdatedPayloadSource = typeof ProjectMetaUpdatedPayloadSource.Type; +export const ProjectMetaUpdatedPayload = ProjectMetaUpdatedPayloadSource.pipe( + Schema.decodeTo( + Schema.toType(ProjectMetaUpdatedPayloadCanonical), + SchemaTransformation.transformOrFail({ + decode: (input: ProjectMetaUpdatedPayloadSource) => + normalizeLegacyDefaultModelSelection(input).pipe( + Effect.map((defaultModelSelection) => ({ + projectId: input.projectId, + ...(input.title !== undefined ? { title: input.title } : {}), + ...(input.workspaceRoot !== undefined ? { workspaceRoot: input.workspaceRoot } : {}), + ...(input.defaultModelSelection !== undefined || + input.defaultModel !== undefined || + input.defaultProvider !== undefined || + input.defaultModelOptions !== undefined + ? { defaultModelSelection } + : {}), + ...(input.scripts !== undefined ? { scripts: input.scripts } : {}), + updatedAt: input.updatedAt, + })), + ), + encode: (canonical: ProjectMetaUpdatedPayloadCanonical) => + Effect.succeed({ + projectId: canonical.projectId, + ...(canonical.title !== undefined ? { title: canonical.title } : {}), + ...(canonical.workspaceRoot !== undefined + ? { workspaceRoot: canonical.workspaceRoot } + : {}), + ...(canonical.defaultModelSelection !== undefined + ? { defaultModelSelection: canonical.defaultModelSelection } + : {}), + ...(canonical.scripts !== undefined ? { scripts: canonical.scripts } : {}), + updatedAt: canonical.updatedAt, + } as ProjectMetaUpdatedPayloadSource), + } as any), + ), +); export const ProjectDeletedPayload = Schema.Struct({ projectId: ProjectId, deletedAt: IsoDateTime, }); -export const ThreadCreatedPayload = Schema.Struct({ +const ThreadCreatedPayloadCanonical = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, - model: TrimmedNonEmptyString, + modelSelection: ModelSelection, + runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), + interactionMode: ProviderInteractionMode.pipe( + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), + ), + branch: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +const ThreadCreatedPayloadSource = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + title: TrimmedNonEmptyString, + modelSelection: Schema.optional(ModelSelection), + ...LegacyThreadModelSelectionFields, runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), @@ -644,20 +849,104 @@ export const ThreadCreatedPayload = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, }); +type ThreadCreatedPayloadSource = typeof ThreadCreatedPayloadSource.Type; +export const ThreadCreatedPayload = ThreadCreatedPayloadSource.pipe( + Schema.decodeTo( + Schema.toType(ThreadCreatedPayloadCanonical), + SchemaTransformation.transformOrFail({ + decode: (input) => + normalizeLegacyThreadModelSelection(input).pipe( + Effect.flatMap((modelSelection) => + modelSelection === undefined + ? Effect.fail( + new SchemaIssue.InvalidValue(Option.some(input), { + title: "Legacy thread.created payload is missing a model selection", + }), + ) + : Effect.succeed({ + threadId: input.threadId, + projectId: input.projectId, + title: input.title, + modelSelection, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + branch: input.branch, + worktreePath: input.worktreePath, + createdAt: input.createdAt, + updatedAt: input.updatedAt, + }), + ), + ), + encode: (canonical) => + Effect.succeed({ + threadId: canonical.threadId, + projectId: canonical.projectId, + title: canonical.title, + modelSelection: canonical.modelSelection, + runtimeMode: canonical.runtimeMode, + interactionMode: canonical.interactionMode, + branch: canonical.branch, + worktreePath: canonical.worktreePath, + createdAt: canonical.createdAt, + updatedAt: canonical.updatedAt, + } as ThreadCreatedPayloadSource), + }), + ), +); export const ThreadDeletedPayload = Schema.Struct({ threadId: ThreadId, deletedAt: IsoDateTime, }); -export const ThreadMetaUpdatedPayload = Schema.Struct({ +const ThreadMetaUpdatedPayloadCanonical = Schema.Struct({ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), - model: Schema.optional(TrimmedNonEmptyString), + modelSelection: Schema.optional(ModelSelection), + branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + updatedAt: IsoDateTime, +}); +type ThreadMetaUpdatedPayloadCanonical = typeof ThreadMetaUpdatedPayloadCanonical.Type; +const ThreadMetaUpdatedPayloadSource = Schema.Struct({ + threadId: ThreadId, + title: Schema.optional(TrimmedNonEmptyString), + modelSelection: Schema.optional(ModelSelection), + ...LegacyThreadModelSelectionFields, branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), updatedAt: IsoDateTime, }); +type ThreadMetaUpdatedPayloadSource = typeof ThreadMetaUpdatedPayloadSource.Type; +export const ThreadMetaUpdatedPayload = ThreadMetaUpdatedPayloadSource.pipe( + Schema.decodeTo( + Schema.toType(ThreadMetaUpdatedPayloadCanonical), + SchemaTransformation.transformOrFail({ + decode: (input: ThreadMetaUpdatedPayloadSource) => + normalizeLegacyThreadModelSelection(input).pipe( + Effect.map((modelSelection) => ({ + threadId: input.threadId, + ...(input.title !== undefined ? { title: input.title } : {}), + ...(modelSelection !== undefined ? { modelSelection } : {}), + ...(input.branch !== undefined ? { branch: input.branch } : {}), + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + updatedAt: input.updatedAt, + })), + ), + encode: (canonical: ThreadMetaUpdatedPayloadCanonical) => + Effect.succeed({ + threadId: canonical.threadId, + ...(canonical.title !== undefined ? { title: canonical.title } : {}), + ...(canonical.modelSelection !== undefined + ? { modelSelection: canonical.modelSelection } + : {}), + ...(canonical.branch !== undefined ? { branch: canonical.branch } : {}), + ...(canonical.worktreePath !== undefined ? { worktreePath: canonical.worktreePath } : {}), + updatedAt: canonical.updatedAt, + } as ThreadMetaUpdatedPayloadSource), + } as any), + ), +); export const ThreadRuntimeModeSetPayload = Schema.Struct({ threadId: ThreadId, @@ -685,12 +974,26 @@ export const ThreadMessageSentPayload = Schema.Struct({ updatedAt: IsoDateTime, }); -export const ThreadTurnStartRequestedPayload = Schema.Struct({ +const ThreadTurnStartRequestedPayloadCanonical = Schema.Struct({ threadId: ThreadId, messageId: MessageId, - provider: Schema.optional(ProviderKind), - model: Schema.optional(TrimmedNonEmptyString), - modelOptions: Schema.optional(ProviderModelOptions), + modelSelection: Schema.optional(ModelSelection), + providerOptions: Schema.optional(ProviderStartOptions), + assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), + runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), + interactionMode: ProviderInteractionMode.pipe( + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), + ), + sourceProposedPlan: Schema.optional(SourceProposedPlanReference), + createdAt: IsoDateTime, +}); +type ThreadTurnStartRequestedPayloadCanonical = + typeof ThreadTurnStartRequestedPayloadCanonical.Type; +const ThreadTurnStartRequestedPayloadSource = Schema.Struct({ + threadId: ThreadId, + messageId: MessageId, + modelSelection: Schema.optional(ModelSelection), + ...LegacyThreadModelSelectionFields, providerOptions: Schema.optional(ProviderStartOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), @@ -700,6 +1003,54 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ sourceProposedPlan: Schema.optional(SourceProposedPlanReference), createdAt: IsoDateTime, }); +type ThreadTurnStartRequestedPayloadSource = typeof ThreadTurnStartRequestedPayloadSource.Type; +export const ThreadTurnStartRequestedPayload = ThreadTurnStartRequestedPayloadSource.pipe( + Schema.decodeTo( + Schema.toType(ThreadTurnStartRequestedPayloadCanonical), + SchemaTransformation.transformOrFail({ + decode: (input: ThreadTurnStartRequestedPayloadSource) => + normalizeLegacyThreadModelSelection(input).pipe( + Effect.map((modelSelection) => ({ + threadId: input.threadId, + messageId: input.messageId, + ...(modelSelection !== undefined ? { modelSelection } : {}), + ...(input.providerOptions !== undefined + ? { providerOptions: input.providerOptions } + : {}), + ...(input.assistantDeliveryMode !== undefined + ? { assistantDeliveryMode: input.assistantDeliveryMode } + : {}), + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + ...(input.sourceProposedPlan !== undefined + ? { sourceProposedPlan: input.sourceProposedPlan } + : {}), + createdAt: input.createdAt, + })), + ), + encode: (canonical: ThreadTurnStartRequestedPayloadCanonical) => + Effect.succeed({ + threadId: canonical.threadId, + messageId: canonical.messageId, + ...(canonical.modelSelection !== undefined + ? { modelSelection: canonical.modelSelection } + : {}), + ...(canonical.providerOptions !== undefined + ? { providerOptions: canonical.providerOptions } + : {}), + ...(canonical.assistantDeliveryMode !== undefined + ? { assistantDeliveryMode: canonical.assistantDeliveryMode } + : {}), + runtimeMode: canonical.runtimeMode, + interactionMode: canonical.interactionMode, + ...(canonical.sourceProposedPlan !== undefined + ? { sourceProposedPlan: canonical.sourceProposedPlan } + : {}), + createdAt: canonical.createdAt, + } as ThreadTurnStartRequestedPayloadSource), + } as any), + ), +); export const ThreadTurnInterruptRequestedPayload = Schema.Struct({ threadId: ThreadId, diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 2c8aaf1986..75fc4be539 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -15,7 +15,6 @@ import { getDefaultReasoningEffort, getModelOptions, getReasoningEffortOptions, - inferProviderForModel, isClaudeUltrathinkPrompt, normalizeClaudeModelOptions, normalizeCodexModelOptions, @@ -188,23 +187,6 @@ describe("getReasoningEffortOptions", () => { }); }); -describe("inferProviderForModel", () => { - it("detects known provider model slugs", () => { - expect(inferProviderForModel("gpt-5.3-codex")).toBe("codex"); - expect(inferProviderForModel("claude-sonnet-4-6")).toBe("claudeAgent"); - expect(inferProviderForModel("sonnet")).toBe("claudeAgent"); - }); - - it("falls back when the model is unknown", () => { - expect(inferProviderForModel("custom/internal-model")).toBe("codex"); - expect(inferProviderForModel("custom/internal-model", "claudeAgent")).toBe("claudeAgent"); - }); - - it("treats claude-prefixed custom slugs as claude", () => { - expect(inferProviderForModel("claude-custom-internal")).toBe("claudeAgent"); - }); -}); - describe("getDefaultReasoningEffort", () => { it("returns provider-scoped defaults", () => { expect(getDefaultReasoningEffort("codex")).toBe(DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 2d46320753..4b678531fd 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -136,23 +136,6 @@ export function resolveModelSlugForProvider( return resolveModelSlug(model, provider); } -export function inferProviderForModel( - model: string | null | undefined, - fallback: ProviderKind = "codex", -): ProviderKind { - const normalizedClaude = normalizeModelSlug(model, "claudeAgent"); - if (normalizedClaude && MODEL_SLUG_SET_BY_PROVIDER.claudeAgent.has(normalizedClaude)) { - return "claudeAgent"; - } - - const normalizedCodex = normalizeModelSlug(model, "codex"); - if (normalizedCodex && MODEL_SLUG_SET_BY_PROVIDER.codex.has(normalizedCodex)) { - return "codex"; - } - - return typeof model === "string" && model.trim().startsWith("claude-") ? "claudeAgent" : fallback; -} - export function getReasoningEffortOptions(provider: "codex"): ReadonlyArray; export function getReasoningEffortOptions( provider: "claudeAgent", From 340634eeee0a3e7c7a6de8b71fcb762dd1e96bd0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 11:52:05 -0700 Subject: [PATCH 02/38] Preserve composer model options across provider changes - 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 --- .../orchestration/Layers/ProjectionSnapshotQuery.ts | 6 +++--- apps/web/src/components/ChatView.tsx | 6 ++++++ apps/web/src/composerDraftStore.test.ts | 13 +++++++++++-- apps/web/src/composerDraftStore.ts | 2 +- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 1dab0bf037..124849856e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -168,9 +168,9 @@ const decodeModelSelection = (input: { : {}), }), catch: (error) => - toPersistenceDecodeError("ProjectionSnapshotQuery.decodeModelSelection")( - error as Schema.SchemaError, - ), + Schema.isSchemaError(error) + ? toPersistenceDecodeError("ProjectionSnapshotQuery.decodeModelSelection")(error) + : toPersistenceSqlError("ProjectionSnapshotQuery.decodeModelSelection")(error), }); const makeProjectionSnapshotQuery = Effect.gen(function* () { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3cc253933c..f80ed3143b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3141,9 +3141,14 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model); + const existingModelSelection = composerDraft.modelSelection; + const preserveOptions = + existingModelSelection?.provider === provider && + existingModelSelection.options !== undefined; const nextModelSelection: ModelSelection = { provider, model: resolvedModel, + ...(preserveOptions ? { options: existingModelSelection.options } : {}), }; setComposerDraftModelSelection(activeThread.id, nextModelSelection); setStickyComposerModelSelection(nextModelSelection); @@ -3151,6 +3156,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [ activeThread, + composerDraft.modelSelection, lockedProvider, scheduleComposerFocus, setComposerDraftModelSelection, diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 5d55a995b5..9e9965982d 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -828,9 +828,18 @@ describe("composerDraftStore provider-scoped option updates", () => { it("does not patch provider options when the draft selection is on another provider", () => { const store = useComposerDraftStore.getState(); - store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4")); + store.setModelSelection( + threadId, + modelSelection("codex", "gpt-5.3-codex", { + reasoningEffort: "medium", + }), + ); store.setProviderModelOptions(threadId, "claudeAgent", { effort: "max" }); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( + modelSelection("codex", "gpt-5.3-codex", { + reasoningEffort: "medium", + }), + ); }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index ec6189e8aa..d70dcda619 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -726,7 +726,7 @@ function migratePersistedComposerDraftStoreState( const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId; const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId; const stickyModelSelection = normalizeModelSelection(candidate.stickyModelSelection, { - provider: candidate.stickyProvider, + provider: candidate.stickyProvider ?? "codex", model: candidate.stickyModel, modelOptions: candidate.stickyModelOptions, }); From 8cad01a57b3df2a50b6de65058e49734bfc215bb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 11:59:12 -0700 Subject: [PATCH 03/38] Keep model selections in composer drafts - Preserve default provider model selections in the draft state - Update composer draft store expectations for sticky/provider-specific models --- apps/web/src/composerDraftStore.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 9e9965982d..d1c0ac1f59 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -631,11 +631,13 @@ describe("composerDraftStore modelSelection", () => { ); }); - it("drops default-only model selections from the draft", () => { + it("keeps default-only model selections on the draft", () => { const store = useComposerDraftStore.getState(); store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4")); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( + modelSelection("codex", "gpt-5.4"), + ); }); it("replaces only the targeted provider options on the current model selection", () => { @@ -690,7 +692,9 @@ describe("composerDraftStore modelSelection", () => { thinking: true, }); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( + modelSelection("claudeAgent", "claude-opus-4-6"), + ); expect(useComposerDraftStore.getState().stickyModelSelection).toBeNull(); }); @@ -704,7 +708,9 @@ describe("composerDraftStore modelSelection", () => { fastMode: false, }); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( + modelSelection("codex", "gpt-5.4"), + ); }); it("updates only the draft when sticky persistence is omitted", () => { From 71ff626e3e8e936504066d8eca34a14ae13a9dea Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 12:08:58 -0700 Subject: [PATCH 04/38] Persist first sticky model snapshot from provider options - Keep sticky selection in sync when provider options change - Add regression test for creating the initial sticky snapshot --- apps/web/src/composerDraftStore.test.ts | 21 +++++++++++++++++++++ apps/web/src/composerDraftStore.ts | 4 +--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index d1c0ac1f59..68925a8988 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -738,6 +738,27 @@ describe("composerDraftStore modelSelection", () => { ); }); + it("creates the first sticky snapshot from provider option changes", () => { + const store = useComposerDraftStore.getState(); + + store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4")); + + store.setProviderModelOptions( + threadId, + "codex", + { + fastMode: true, + }, + { persistSticky: true }, + ); + + expect(useComposerDraftStore.getState().stickyModelSelection).toEqual( + modelSelection("codex", "gpt-5.4", { + fastMode: true, + }), + ); + }); + it("updates only the draft when sticky persistence is disabled", () => { const store = useComposerDraftStore.getState(); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index d70dcda619..587129fa08 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1327,9 +1327,7 @@ export const useComposerDraftStore = create()( ...(nextOptions ? { options: nextOptions } : {}), }; const nextStickyModelSelection = - options?.persistSticky === true && - state.stickyModelSelection?.provider === normalizedProvider && - nextModelSelection !== null + options?.persistSticky === true && nextModelSelection !== null ? nextModelSelection : state.stickyModelSelection; From 07e86e938bb1f91086a068dab55e5f430bc5046a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 13:27:25 -0700 Subject: [PATCH 05/38] Preserve null model options in projections and restarts - 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` --- .../Layers/ProjectionSnapshotQuery.ts | 37 +++--- .../Layers/ProviderCommandReactor.test.ts | 44 +++++++ .../Layers/ProviderCommandReactor.ts | 16 +-- .../persistence/Layers/ProjectionProjects.ts | 6 +- .../Layers/ProjectionRepositories.test.ts | 110 ++++++++++++++++++ .../persistence/Layers/ProjectionThreads.ts | 2 +- apps/web/src/components/ChatView.tsx | 19 +-- packages/shared/src/model.test.ts | 40 +++++++ packages/shared/src/model.ts | 13 +++ 9 files changed, 231 insertions(+), 56 deletions(-) create mode 100644 apps/server/src/persistence/Layers/ProjectionRepositories.test.ts diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 124849856e..e51afc8233 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -43,7 +43,7 @@ import { type ProjectionSnapshotQueryShape, } from "../Services/ProjectionSnapshotQuery.ts"; -const decodeReadModelSync = Schema.decodeUnknownSync(OrchestrationReadModel); +const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); const ProjectionProjectDbRowSchema = Schema.Struct({ projectId: ProjectionProject.fields.projectId, title: ProjectionProject.fields.title, @@ -103,7 +103,7 @@ const ProjectionLatestTurnDbRowSchema = Schema.Struct({ sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), }); const ProjectionStateDbRowSchema = ProjectionState; -const decodeModelSelectionSync = Schema.decodeUnknownSync(ModelSelection); +const decodeModelSelectionSchema = Schema.decodeUnknownEffect(ModelSelection); const REQUIRED_SNAPSHOT_PROJECTORS = [ ORCHESTRATION_PROJECTOR_NAMES.projects, @@ -158,20 +158,13 @@ const decodeModelSelection = (input: { readonly model: string; readonly options: unknown; }) => - Effect.try({ - try: () => - decodeModelSelectionSync({ - provider: input.provider, - model: input.model, - ...(input.options !== null && input.options !== undefined - ? { options: input.options } - : {}), - }), - catch: (error) => - Schema.isSchemaError(error) - ? toPersistenceDecodeError("ProjectionSnapshotQuery.decodeModelSelection")(error) - : toPersistenceSqlError("ProjectionSnapshotQuery.decodeModelSelection")(error), - }); + decodeModelSelectionSchema({ + provider: input.provider, + model: input.model, + ...(input.options !== null && input.options !== undefined ? { options: input.options } : {}), + }).pipe( + Effect.mapError(toPersistenceDecodeError("ProjectionSnapshotQuery.decodeModelSelection")), + ); const makeProjectionSnapshotQuery = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -646,13 +639,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updatedAt: updatedAt ?? new Date(0).toISOString(), }; - return yield* Effect.try({ - try: () => decodeReadModelSync(snapshot), - catch: (error) => - toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel")( - error as Schema.SchemaError, - ), - }); + return yield* decodeReadModel(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"), + ), + ); }), ) .pipe( diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c7b232b81c..82e8aca45f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -804,6 +804,50 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + it("does not inject derived model options when restarting claude on runtime mode changes", async () => { + const harness = await createHarness({ + threadModel: "claude-opus-4-6", + }); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-runtime-mode-claude"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-claude-no-options"), + threadId: ThreadId.makeUnsafe("thread-1"), + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + provider: "claudeAgent", + model: "claude-opus-4-6", + runtimeMode: "approval-required", + }); + expect(harness.startSession.mock.calls[0]?.[1]).not.toHaveProperty("modelOptions"); + }); + it("rejects provider changes after a thread is already bound to a session provider", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 5147e32aa3..8dc600b66a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -16,6 +16,7 @@ import { } 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 @@ const sameModelOptions = ( 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): boolean { const error = Cause.squash(cause); if (Schema.is(ProviderAdapterRequestError)(error)) { @@ -705,9 +695,7 @@ const make = Effect.gen(function* () { 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/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index 9eb46a2633..3f7d566425 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -77,7 +77,11 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${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/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts new file mode 100644 index 0000000000..c65f2e9455 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -0,0 +1,110 @@ +import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "./Sqlite.ts"; +import { ProjectionProjectRepositoryLive } from "./ProjectionProjects.ts"; +import { ProjectionThreadRepositoryLive } from "./ProjectionThreads.ts"; +import { ProjectionProjectRepository } from "../Services/ProjectionProjects.ts"; +import { ProjectionThreadRepository } from "../Services/ProjectionThreads.ts"; + +const projectionRepositoriesLayer = it.layer( + Layer.mergeAll( + ProjectionProjectRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + ProjectionThreadRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + SqlitePersistenceMemory, + ), +); + +projectionRepositoriesLayer("Projection repositories", (it) => { + it.effect("stores SQL NULL for missing project model options", () => + Effect.gen(function* () { + const projects = yield* ProjectionProjectRepository; + const sql = yield* SqlClient.SqlClient; + + yield* projects.upsert({ + projectId: ProjectId.makeUnsafe("project-null-options"), + title: "Null options project", + workspaceRoot: "/tmp/project-null-options", + defaultModelSelection: { + provider: "codex", + model: "gpt-5.4", + }, + scripts: [], + createdAt: "2026-03-24T00:00:00.000Z", + updatedAt: "2026-03-24T00:00:00.000Z", + deletedAt: null, + }); + + const rows = yield* sql<{ + readonly defaultModelOptions: string | null; + }>` + SELECT default_model_options_json AS "defaultModelOptions" + FROM projection_projects + WHERE project_id = 'project-null-options' + `; + const row = rows[0]; + if (!row) { + return yield* Effect.fail(new Error("Expected projection_projects row to exist.")); + } + + assert.strictEqual(row.defaultModelOptions, null); + + const persisted = yield* projects.getById({ + projectId: ProjectId.makeUnsafe("project-null-options"), + }); + assert.deepStrictEqual(Option.getOrNull(persisted)?.defaultModelSelection, { + provider: "codex", + model: "gpt-5.4", + }); + }), + ); + + it.effect("stores SQL NULL for missing thread model options", () => + Effect.gen(function* () { + const threads = yield* ProjectionThreadRepository; + const sql = yield* SqlClient.SqlClient; + + yield* threads.upsert({ + threadId: ThreadId.makeUnsafe("thread-null-options"), + projectId: ProjectId.makeUnsafe("project-null-options"), + title: "Null options thread", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurnId: null, + createdAt: "2026-03-24T00:00:00.000Z", + updatedAt: "2026-03-24T00:00:00.000Z", + deletedAt: null, + }); + + const rows = yield* sql<{ + readonly modelOptions: string | null; + }>` + SELECT model_options_json AS "modelOptions" + FROM projection_threads + WHERE thread_id = 'thread-null-options' + `; + const row = rows[0]; + if (!row) { + return yield* Effect.fail(new Error("Expected projection_threads row to exist.")); + } + + assert.strictEqual(row.modelOptions, null); + + const persisted = yield* threads.getById({ + threadId: ThreadId.makeUnsafe("thread-null-options"), + }); + assert.deepStrictEqual(Option.getOrNull(persisted)?.modelSelection, { + provider: "claudeAgent", + model: "claude-opus-4-6", + }); + }), + ); +}); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index e74a657f88..6b379aa0e3 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -89,7 +89,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${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 index f80ed3143b..46ea348fb1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -28,6 +28,7 @@ import { 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"; @@ -210,17 +211,6 @@ function extractModelSelectionOptions( ): ModelSelection["options"] | undefined { 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; @@ -3035,12 +3025,7 @@ export default function ChatView({ threadId }: ChatViewProps) { 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"); diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 75fc4be539..5eb2aba94c 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -27,6 +27,7 @@ import { supportsClaudeFastMode, supportsClaudeMaxEffort, supportsClaudeThinkingToggle, + toProviderModelOptions, supportsClaudeUltrathinkKeyword, } from "./model"; @@ -272,6 +273,45 @@ describe("normalizeClaudeModelOptions", () => { }); }); +describe("toProviderModelOptions", () => { + it("returns undefined when no model options exist", () => { + expect(toProviderModelOptions({ provider: "codex", model: "gpt-5.4" })).toBeUndefined(); + expect(toProviderModelOptions(null)).toBeUndefined(); + }); + + it("scopes codex options under the codex provider key", () => { + expect( + toProviderModelOptions({ + provider: "codex", + model: "gpt-5.4", + options: { + fastMode: true, + }, + }), + ).toEqual({ + codex: { + fastMode: true, + }, + }); + }); + + it("scopes claude options under the claudeAgent provider key", () => { + expect( + toProviderModelOptions({ + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { + effort: "max", + }, + }), + ).toEqual({ + claudeAgent: { + effort: "max", + }, + }); + }); +}); + describe("supportsClaudeAdaptiveReasoning", () => { it("only enables adaptive reasoning for Opus 4.6 and Sonnet 4.6", () => { expect(supportsClaudeAdaptiveReasoning("claude-opus-4-6")).toBe(true); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 4b678531fd..9440985283 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -10,7 +10,9 @@ import { type ClaudeCodeEffort, type CodexModelOptions, type CodexReasoningEffort, + type ModelSelection, type ModelSlug, + type ProviderModelOptions, type ProviderReasoningEffort, type ProviderKind, } from "@t3tools/contracts"; @@ -266,4 +268,15 @@ export function applyClaudePromptEffortPrefix( 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 }; From 97ff0ac4a903ce7c6ece89b6058263718bc44613 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 14:09:22 -0700 Subject: [PATCH 06/38] Canonicalize legacy model selections - Add migration for legacy provider/model payloads - Cover project, thread, and orchestration event records --- apps/server/src/persistence/Migrations.ts | 2 + ..._CanonicalizeLegacyModelSelections.test.ts | 180 ++++++++++++++++++ .../018_CanonicalizeLegacyModelSelections.ts | 172 +++++++++++++++++ 3 files changed, 354 insertions(+) create mode 100644 apps/server/src/persistence/Migrations/018_CanonicalizeLegacyModelSelections.test.ts create mode 100644 apps/server/src/persistence/Migrations/018_CanonicalizeLegacyModelSelections.ts diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 46224b6cd6..bbec6e131e 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -29,6 +29,7 @@ import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplemen import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts"; import Migration0016 from "./Migrations/016_ProjectionProviders.ts"; import Migration0017 from "./Migrations/017_ProjectionModelSelectionOptions.ts"; +import Migration0018 from "./Migrations/018_CanonicalizeLegacyModelSelections.ts"; import { Effect } from "effect"; /** @@ -59,6 +60,7 @@ const loader = Migrator.fromRecord({ "15_ProjectionTurnsSourceProposedPlan": Migration0015, "16_ProjectionProviders": Migration0016, "17_ProjectionModelSelectionOptions": Migration0017, + "18_CanonicalizeLegacyModelSelections": Migration0018, }); /** diff --git a/apps/server/src/persistence/Migrations/018_CanonicalizeLegacyModelSelections.test.ts b/apps/server/src/persistence/Migrations/018_CanonicalizeLegacyModelSelections.test.ts new file mode 100644 index 0000000000..cd28a30272 --- /dev/null +++ b/apps/server/src/persistence/Migrations/018_CanonicalizeLegacyModelSelections.test.ts @@ -0,0 +1,180 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import Migration0018 from "./018_CanonicalizeLegacyModelSelections.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("018_CanonicalizeLegacyModelSelections", (it) => { + it.effect("canonicalizes legacy projection rows and orchestration event payloads", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE projection_projects ( + project_id TEXT PRIMARY KEY, + default_provider TEXT, + default_model TEXT, + default_model_options_json TEXT + ) + `; + yield* sql` + CREATE TABLE projection_threads ( + thread_id TEXT PRIMARY KEY, + provider TEXT, + model TEXT NOT NULL, + model_options_json TEXT + ) + `; + yield* sql` + CREATE TABLE orchestration_events ( + event_type TEXT NOT NULL, + payload_json TEXT NOT NULL + ) + `; + + yield* sql` + INSERT INTO projection_projects ( + project_id, + default_provider, + default_model, + default_model_options_json + ) + VALUES ( + 'project-1', + 'codex', + 'claude-opus-4-6', + '{"codex":{"reasoningEffort":"high"},"claudeAgent":{"effort":"max","thinking":false}}' + ) + `; + yield* sql` + INSERT INTO projection_threads ( + thread_id, + provider, + model, + model_options_json + ) + VALUES ( + 'thread-1', + 'codex', + 'claude-opus-4-6', + '{"codex":{"reasoningEffort":"high"},"claudeAgent":{"effort":"max"}}' + ) + `; + yield* sql` + INSERT INTO orchestration_events ( + event_type, + payload_json + ) + VALUES + ( + 'project.created', + '{"projectId":"project-1","title":"Project","workspaceRoot":"/tmp/project","defaultModel":"claude-opus-4-6","defaultModelOptions":{"codex":{"reasoningEffort":"high"},"claudeAgent":{"effort":"max"}},"scripts":[],"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}' + ), + ( + 'thread.created', + '{"threadId":"thread-1","projectId":"project-1","title":"Thread","model":"claude-opus-4-6","modelOptions":{"codex":{"reasoningEffort":"high"},"claudeAgent":{"effort":"max","thinking":false}},"runtimeMode":"full-access","interactionMode":"default","branch":null,"worktreePath":null,"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}' + ), + ( + 'thread.turn-start-requested', + '{"threadId":"thread-1","turnId":"turn-1","input":"hi","model":"gpt-5.4","modelOptions":{"codex":{"fastMode":true},"claudeAgent":{"effort":"max"}},"deliveryMode":"buffered"}' + ) + `; + + yield* Migration0018; + + const projectRows = yield* sql<{ + readonly defaultProvider: string | null; + readonly defaultModelOptions: string | null; + }>` + SELECT + default_provider AS "defaultProvider", + default_model_options_json AS "defaultModelOptions" + FROM projection_projects + WHERE project_id = 'project-1' + `; + assert.deepStrictEqual(projectRows[0], { + defaultProvider: "claudeAgent", + defaultModelOptions: '{"effort":"max","thinking":false}', + }); + + const threadRows = yield* sql<{ + readonly provider: string | null; + readonly modelOptions: string | null; + }>` + SELECT + provider, + model_options_json AS "modelOptions" + FROM projection_threads + WHERE thread_id = 'thread-1' + `; + assert.deepStrictEqual(threadRows[0], { + provider: "claudeAgent", + modelOptions: '{"effort":"max"}', + }); + + const eventRows = yield* sql<{ + readonly eventType: string; + readonly payloadJson: string; + }>` + SELECT + event_type AS "eventType", + payload_json AS "payloadJson" + FROM orchestration_events + ORDER BY rowid ASC + `; + + assert.deepStrictEqual(JSON.parse(eventRows[0]!.payloadJson), { + projectId: "project-1", + title: "Project", + workspaceRoot: "/tmp/project", + defaultModelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { + effort: "max", + }, + }, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + + assert.deepStrictEqual(JSON.parse(eventRows[1]!.payloadJson), { + threadId: "thread-1", + projectId: "project-1", + title: "Thread", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { + effort: "max", + thinking: false, + }, + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + + assert.deepStrictEqual(JSON.parse(eventRows[2]!.payloadJson), { + threadId: "thread-1", + turnId: "turn-1", + input: "hi", + modelSelection: { + provider: "codex", + model: "gpt-5.4", + options: { + fastMode: true, + }, + }, + deliveryMode: "buffered", + }); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/018_CanonicalizeLegacyModelSelections.ts b/apps/server/src/persistence/Migrations/018_CanonicalizeLegacyModelSelections.ts new file mode 100644 index 0000000000..b4b2912002 --- /dev/null +++ b/apps/server/src/persistence/Migrations/018_CanonicalizeLegacyModelSelections.ts @@ -0,0 +1,172 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + UPDATE projection_projects + SET + default_provider = CASE + WHEN default_model IS NULL THEN NULL + WHEN lower(default_model) LIKE '%claude%' THEN 'claudeAgent' + ELSE 'codex' + END, + default_model_options_json = CASE + WHEN default_model_options_json IS NULL THEN NULL + WHEN json_valid(default_model_options_json) = 0 THEN default_model_options_json + WHEN json_type(default_model_options_json, '$.codex') IS NOT NULL + OR json_type(default_model_options_json, '$.claudeAgent') IS NOT NULL + THEN CASE + WHEN lower(default_model) LIKE '%claude%' THEN json_extract( + default_model_options_json, + '$.claudeAgent' + ) + ELSE json_extract(default_model_options_json, '$.codex') + END + ELSE default_model_options_json + END + WHERE default_model IS NOT NULL + `; + + yield* sql` + UPDATE projection_threads + SET + provider = CASE + WHEN lower(model) LIKE '%claude%' THEN 'claudeAgent' + ELSE 'codex' + END, + model_options_json = CASE + WHEN model_options_json IS NULL THEN NULL + WHEN json_valid(model_options_json) = 0 THEN model_options_json + WHEN json_type(model_options_json, '$.codex') IS NOT NULL + OR json_type(model_options_json, '$.claudeAgent') IS NOT NULL + THEN CASE + WHEN lower(model) LIKE '%claude%' THEN json_extract(model_options_json, '$.claudeAgent') + ELSE json_extract(model_options_json, '$.codex') + END + ELSE model_options_json + END + `; + + yield* sql` + UPDATE orchestration_events + SET payload_json = CASE + WHEN json_type(payload_json, '$.defaultModel') = 'null' THEN json_remove( + json_set(payload_json, '$.defaultModelSelection', json('null')), + '$.defaultProvider', + '$.defaultModel', + '$.defaultModelOptions' + ) + ELSE json_remove( + json_set( + payload_json, + '$.defaultModelSelection', + json_patch( + json_object( + 'provider', + CASE + WHEN json_extract(payload_json, '$.defaultProvider') IS NOT NULL + THEN json_extract(payload_json, '$.defaultProvider') + WHEN lower(json_extract(payload_json, '$.defaultModel')) LIKE '%claude%' + THEN 'claudeAgent' + ELSE 'codex' + END, + 'model', + json_extract(payload_json, '$.defaultModel') + ), + CASE + WHEN json_type(payload_json, '$.defaultModelOptions') IS NULL THEN '{}' + WHEN json_type(payload_json, '$.defaultModelOptions.codex') IS NOT NULL + OR json_type(payload_json, '$.defaultModelOptions.claudeAgent') IS NOT NULL + THEN CASE + WHEN ( + CASE + WHEN json_extract(payload_json, '$.defaultProvider') IS NOT NULL + THEN json_extract(payload_json, '$.defaultProvider') + WHEN lower(json_extract(payload_json, '$.defaultModel')) LIKE '%claude%' + THEN 'claudeAgent' + ELSE 'codex' + END + ) = 'claudeAgent' + THEN json_object( + 'options', + json(json_extract(payload_json, '$.defaultModelOptions.claudeAgent')) + ) + ELSE json_object( + 'options', + json(json_extract(payload_json, '$.defaultModelOptions.codex')) + ) + END + ELSE json_object( + 'options', + json(json_extract(payload_json, '$.defaultModelOptions')) + ) + END + ) + ), + '$.defaultProvider', + '$.defaultModel', + '$.defaultModelOptions' + ) + END + WHERE event_type IN ('project.created', 'project.meta-updated') + AND json_type(payload_json, '$.defaultModelSelection') IS NULL + AND json_type(payload_json, '$.defaultModel') IS NOT NULL + `; + + yield* sql` + UPDATE orchestration_events + SET payload_json = json_remove( + json_set( + payload_json, + '$.modelSelection', + json_patch( + json_object( + 'provider', + CASE + WHEN json_extract(payload_json, '$.provider') IS NOT NULL + THEN json_extract(payload_json, '$.provider') + WHEN lower(json_extract(payload_json, '$.model')) LIKE '%claude%' + THEN 'claudeAgent' + ELSE 'codex' + END, + 'model', + json_extract(payload_json, '$.model') + ), + CASE + WHEN json_type(payload_json, '$.modelOptions') IS NULL THEN '{}' + WHEN json_type(payload_json, '$.modelOptions.codex') IS NOT NULL + OR json_type(payload_json, '$.modelOptions.claudeAgent') IS NOT NULL + THEN CASE + WHEN ( + CASE + WHEN json_extract(payload_json, '$.provider') IS NOT NULL + THEN json_extract(payload_json, '$.provider') + WHEN lower(json_extract(payload_json, '$.model')) LIKE '%claude%' + THEN 'claudeAgent' + ELSE 'codex' + END + ) = 'claudeAgent' + THEN json_object( + 'options', + json(json_extract(payload_json, '$.modelOptions.claudeAgent')) + ) + ELSE json_object( + 'options', + json(json_extract(payload_json, '$.modelOptions.codex')) + ) + END + ELSE json_object('options', json(json_extract(payload_json, '$.modelOptions'))) + END + ) + ), + '$.provider', + '$.model', + '$.modelOptions' + ) + WHERE event_type IN ('thread.created', 'thread.meta-updated', 'thread.turn-start-requested') + AND json_type(payload_json, '$.modelSelection') IS NULL + AND json_type(payload_json, '$.model') IS NOT NULL + `; +}); From 96d6160ea583bad66ee92ed403c90a85e1958d73 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 14:35:29 -0700 Subject: [PATCH 07/38] Unify provider model selection across orchestration and UI - 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 --- .../Layers/ProviderCommandReactor.test.ts | 74 ++++++++------- .../Layers/ProviderCommandReactor.ts | 56 +++++------- .../src/provider/Layers/ClaudeAdapter.test.ts | 89 +++++++++++-------- .../src/provider/Layers/ClaudeAdapter.ts | 44 +++++---- .../src/provider/Layers/CodexAdapter.test.ts | 14 +-- .../src/provider/Layers/CodexAdapter.ts | 22 +++-- .../provider/Layers/ProviderService.test.ts | 14 +-- .../src/provider/Layers/ProviderService.ts | 29 +++--- apps/web/src/components/ChatView.tsx | 22 +---- .../chat/composerProviderRegistry.test.tsx | 70 ++------------- .../chat/composerProviderRegistry.tsx | 40 ++++++--- packages/contracts/src/orchestration.ts | 2 +- packages/contracts/src/provider.test.ts | 70 ++++++++++----- packages/contracts/src/provider.ts | 8 +- packages/shared/src/model.test.ts | 40 --------- packages/shared/src/model.ts | 13 --- 16 files changed, 287 insertions(+), 320 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 82e8aca45f..2259d971d8 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -313,7 +313,10 @@ describe("ProviderCommandReactor", () => { expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.makeUnsafe("thread-1")); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ cwd: "/tmp/provider-project", - model: "gpt-5-codex", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, runtimeMode: "approval-required", }); @@ -355,9 +358,10 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - model: "gpt-5.3-codex", - modelOptions: { - codex: { + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + options: { reasoningEffort: "high", fastMode: true, }, @@ -365,9 +369,10 @@ describe("ProviderCommandReactor", () => { }); expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ threadId: ThreadId.makeUnsafe("thread-1"), - model: "gpt-5.3-codex", - modelOptions: { - codex: { + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + options: { reasoningEffort: "high", fastMode: true, }, @@ -406,19 +411,20 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - provider: "claudeAgent", - model: "claude-sonnet-4-6", - modelOptions: { - claudeAgent: { + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + options: { effort: "max", }, }, }); expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ threadId: ThreadId.makeUnsafe("thread-1"), - model: "claude-sonnet-4-6", - modelOptions: { - claudeAgent: { + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + options: { effort: "max", }, }, @@ -456,19 +462,20 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - provider: "claudeAgent", - model: "claude-opus-4-6", - modelOptions: { - claudeAgent: { + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { fastMode: true, }, }, }); expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ threadId: ThreadId.makeUnsafe("thread-1"), - model: "claude-opus-4-6", - modelOptions: { - claudeAgent: { + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { fastMode: true, }, }, @@ -594,12 +601,17 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.sendTurn.mock.calls.length === 1); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - provider: "codex", - model: "claude-sonnet-4-6", + modelSelection: { + provider: "codex", + model: "claude-sonnet-4-6", + }, }); expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ threadId: ThreadId.makeUnsafe("thread-1"), - model: "claude-sonnet-4-6", + modelSelection: { + provider: "codex", + model: "claude-sonnet-4-6", + }, }); }); @@ -707,10 +719,11 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 2); await waitFor(() => harness.sendTurn.mock.calls.length === 2); expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ - provider: "claudeAgent", resumeCursor: { opaque: "resume-1" }, - modelOptions: { - claudeAgent: { + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + options: { effort: "max", }, }, @@ -841,11 +854,12 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - provider: "claudeAgent", - model: "claude-opus-4-6", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + }, runtimeMode: "approval-required", }); - expect(harness.startSession.mock.calls[0]?.[1]).not.toHaveProperty("modelOptions"); }); it("rejects provider changes after a thread is already bound to a session provider", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 8dc600b66a..299468230e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -5,7 +5,6 @@ import { EventId, type ModelSelection, type OrchestrationEvent, - type ProviderModelOptions, ProviderKind, type ProviderStartOptions, type OrchestrationSession, @@ -16,7 +15,6 @@ import { } 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"; @@ -77,9 +75,9 @@ const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const WORKTREE_BRANCH_PREFIX = "t3code"; const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); -const sameModelOptions = ( - left: ProviderModelOptions | undefined, - right: ProviderModelOptions | undefined, +const sameModelSelectionOptions = ( + left: ModelSelection | undefined, + right: ModelSelection | undefined, ): boolean => JSON.stringify(left ?? null) === JSON.stringify(right ?? null); function isUnknownPendingApprovalRequestError(cause: Cause.Cause): boolean { @@ -159,7 +157,7 @@ const make = Effect.gen(function* () { ); const threadProviderOptions = new Map(); - const threadModelOptions = new Map(); + const threadModelSelections = new Map(); const appendProviderFailureActivity = (input: { readonly threadId: ThreadId; @@ -217,7 +215,6 @@ const make = Effect.gen(function* () { createdAt: string, options?: { readonly modelSelection?: ModelSelection; - readonly modelOptions?: ProviderModelOptions; readonly providerOptions?: ProviderStartOptions; }, ) { @@ -247,9 +244,6 @@ const make = Effect.gen(function* () { } const preferredProvider: ProviderKind = currentProvider ?? threadProvider; const desiredModelSelection = requestedModelSelection ?? thread.modelSelection; - const desiredModel = desiredModelSelection.model; - const desiredModelOptions = - options?.modelOptions ?? toProviderModelOptions(desiredModelSelection); const effectiveCwd = resolveThreadWorkspaceCwd({ thread, projects: readModel.projects, @@ -268,8 +262,7 @@ const make = Effect.gen(function* () { threadId, ...(preferredProvider ? { provider: preferredProvider } : {}), ...(effectiveCwd ? { cwd: effectiveCwd } : {}), - ...(desiredModel ? { model: desiredModel } : {}), - ...(desiredModelOptions !== undefined ? { modelOptions: desiredModelOptions } : {}), + modelSelection: desiredModelSelection, ...(options?.providerOptions !== undefined ? { providerOptions: options.providerOptions } : {}), @@ -309,17 +302,17 @@ const make = Effect.gen(function* () { requestedModelSelection !== undefined && requestedModelSelection.model !== activeSession?.model; const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "restart-session"; - const previousModelOptions = threadModelOptions.get(threadId); - const shouldRestartForModelOptionsChange = + const previousModelSelection = threadModelSelections.get(threadId); + const shouldRestartForModelSelectionChange = currentProvider === "claudeAgent" && - options?.modelOptions !== undefined && - !sameModelOptions(previousModelOptions, options.modelOptions); + requestedModelSelection !== undefined && + !sameModelSelectionOptions(previousModelSelection, requestedModelSelection); if ( !runtimeModeChanged && !providerChanged && !shouldRestartForModelChange && - !shouldRestartForModelOptionsChange + !shouldRestartForModelSelectionChange ) { return existingSessionThreadId; } @@ -339,7 +332,7 @@ const make = Effect.gen(function* () { providerChanged, modelChanged, shouldRestartForModelChange, - shouldRestartForModelOptionsChange, + shouldRestartForModelSelectionChange, hasResumeCursor: resumeCursor !== undefined, }); const restartedSession = yield* startProviderSession( @@ -366,7 +359,6 @@ const make = Effect.gen(function* () { readonly messageText: string; readonly attachments?: ReadonlyArray; readonly modelSelection?: ModelSelection; - readonly modelOptions?: ProviderModelOptions; readonly providerOptions?: ProviderStartOptions; readonly interactionMode?: "default" | "plan"; readonly createdAt: string; @@ -377,14 +369,13 @@ const make = Effect.gen(function* () { } yield* ensureSessionForThread(input.threadId, input.createdAt, { ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), - ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), }); if (input.providerOptions !== undefined) { threadProviderOptions.set(input.threadId, input.providerOptions); } - if (input.modelOptions !== undefined) { - threadModelOptions.set(input.threadId, input.modelOptions); + if (input.modelSelection !== undefined) { + threadModelSelections.set(input.threadId, input.modelSelection); } const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; @@ -398,14 +389,20 @@ const make = Effect.gen(function* () { ? "in-session" : (yield* providerService.getCapabilities(activeSession.provider)).sessionModelSwitch; const modelForTurn = - sessionModelSwitch === "unsupported" ? activeSession?.model : input.modelSelection?.model; + sessionModelSwitch === "unsupported" + ? input.modelSelection + ? { + ...input.modelSelection, + model: activeSession?.model ?? input.modelSelection.model, + } + : undefined + : input.modelSelection; yield* providerService.sendTurn({ threadId: input.threadId, ...(normalizedInput ? { input: normalizedInput } : {}), ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), - ...(modelForTurn !== undefined ? { model: modelForTurn } : {}), - ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), + ...(modelForTurn !== undefined ? { modelSelection: modelForTurn } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), }); }); @@ -514,10 +511,6 @@ const make = Effect.gen(function* () { ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), }).pipe(Effect.forkScoped); - const requestedModelOptions = event.payload.modelSelection - ? toProviderModelOptions(event.payload.modelSelection) - : undefined; - yield* sendTurnForThread({ threadId: event.payload.threadId, messageText: message.text, @@ -525,7 +518,6 @@ const make = Effect.gen(function* () { ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), - ...(requestedModelOptions !== undefined ? { modelOptions: requestedModelOptions } : {}), ...(event.payload.providerOptions !== undefined ? { providerOptions: event.payload.providerOptions } : {}), @@ -695,12 +687,12 @@ const make = Effect.gen(function* () { return; } const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId); - const cachedModelOptions = threadModelOptions.get(event.payload.threadId); + const cachedModelSelection = threadModelSelections.get(event.payload.threadId); yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, { ...(cachedProviderOptions !== undefined ? { providerOptions: cachedProviderOptions } : {}), - ...(cachedModelOptions !== undefined ? { modelOptions: cachedModelOptions } : {}), + ...(cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {}), }); return; } diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index cf91bf2d52..b1a82fc3d3 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -332,13 +332,14 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - model: "claude-opus-4-6", - runtimeMode: "full-access", - modelOptions: { - claudeAgent: { + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { effort: "max", }, }, + runtimeMode: "full-access", }); const createInput = harness.getLastCreateQueryInput(); @@ -356,13 +357,14 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - model: "claude-sonnet-4-6", - runtimeMode: "full-access", - modelOptions: { - claudeAgent: { + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + options: { effort: "max", }, }, + runtimeMode: "full-access", }); const createInput = harness.getLastCreateQueryInput(); @@ -380,13 +382,14 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - model: "claude-haiku-4-5", - runtimeMode: "full-access", - modelOptions: { - claudeAgent: { + modelSelection: { + provider: "claudeAgent", + model: "claude-haiku-4-5", + options: { effort: "high", }, }, + runtimeMode: "full-access", }); const createInput = harness.getLastCreateQueryInput(); @@ -404,13 +407,14 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - model: "claude-haiku-4-5", - runtimeMode: "full-access", - modelOptions: { - claudeAgent: { + modelSelection: { + provider: "claudeAgent", + model: "claude-haiku-4-5", + options: { thinking: false, }, }, + runtimeMode: "full-access", }); const createInput = harness.getLastCreateQueryInput(); @@ -430,13 +434,14 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - model: "claude-sonnet-4-6", - runtimeMode: "full-access", - modelOptions: { - claudeAgent: { + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + options: { thinking: false, }, }, + runtimeMode: "full-access", }); const createInput = harness.getLastCreateQueryInput(); @@ -454,13 +459,14 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - model: "claude-opus-4-6", - runtimeMode: "full-access", - modelOptions: { - claudeAgent: { + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { fastMode: true, }, }, + runtimeMode: "full-access", }); const createInput = harness.getLastCreateQueryInput(); @@ -480,13 +486,14 @@ describe("ClaudeAdapterLive", () => { yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - model: "claude-sonnet-4-6", - runtimeMode: "full-access", - modelOptions: { - claudeAgent: { + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + options: { fastMode: true, }, }, + runtimeMode: "full-access", }); const createInput = harness.getLastCreateQueryInput(); @@ -504,22 +511,24 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - model: "claude-sonnet-4-6", - runtimeMode: "full-access", - modelOptions: { - claudeAgent: { + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + options: { effort: "ultrathink", }, }, + runtimeMode: "full-access", }); yield* adapter.sendTurn({ threadId: session.threadId, input: "Investigate the edge cases", attachments: [], - model: "claude-sonnet-4-6", - modelOptions: { - claudeAgent: { + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + options: { effort: "ultrathink", }, }, @@ -613,7 +622,10 @@ describe("ClaudeAdapterLive", () => { const session = yield* adapter.startSession({ threadId: THREAD_ID, provider: "claudeAgent", - model: "claude-sonnet-4-5", + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-5", + }, runtimeMode: "full-access", }); @@ -2239,7 +2251,10 @@ describe("ClaudeAdapterLive", () => { yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", - model: "claude-opus-4-6", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + }, attachments: [], }); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index c1b320860d..1301f36658 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -436,11 +436,17 @@ const CLAUDE_SETTING_SOURCES = [ function buildPromptText(input: ProviderSendTurnInput): string { const requestedEffort = resolveReasoningEffortForProvider( "claudeAgent", - input.modelOptions?.claudeAgent?.effort ?? null, + input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null, + ); + const supportedEffortOptions = getReasoningEffortOptions( + "claudeAgent", + input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined, ); - const supportedEffortOptions = getReasoningEffortOptions("claudeAgent", input.model); const promptEffort = - requestedEffort === "ultrathink" && supportsClaudeUltrathinkKeyword(input.model) + requestedEffort === "ultrathink" && + supportsClaudeUltrathinkKeyword( + input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined, + ) ? "ultrathink" : requestedEffort && supportedEffortOptions.includes(requestedEffort) ? requestedEffort @@ -2542,21 +2548,27 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { ); const providerOptions = input.providerOptions?.claudeAgent; + const modelSelection = + input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; const requestedEffort = resolveReasoningEffortForProvider( "claudeAgent", - input.modelOptions?.claudeAgent?.effort ?? null, + modelSelection?.options?.effort ?? null, + ); + const supportedEffortOptions = getReasoningEffortOptions( + "claudeAgent", + modelSelection?.model, ); - const supportedEffortOptions = getReasoningEffortOptions("claudeAgent", input.model); const effort = requestedEffort && supportedEffortOptions.includes(requestedEffort) ? requestedEffort : null; const fastMode = - input.modelOptions?.claudeAgent?.fastMode === true && supportsClaudeFastMode(input.model); + modelSelection?.options?.fastMode === true && + supportsClaudeFastMode(modelSelection?.model); const thinking = - typeof input.modelOptions?.claudeAgent?.thinking === "boolean" && - supportsClaudeThinkingToggle(input.model) - ? input.modelOptions.claudeAgent.thinking + typeof modelSelection?.options?.thinking === "boolean" && + supportsClaudeThinkingToggle(modelSelection?.model) + ? modelSelection.options.thinking : undefined; const effectiveEffort = getEffectiveClaudeCodeEffort(effort); const permissionMode = @@ -2569,7 +2581,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), - ...(input.model ? { model: input.model } : {}), + ...(modelSelection?.model ? { model: modelSelection.model } : {}), pathToClaudeCodeExecutable: providerOptions?.binaryPath ?? "claude", settingSources: [...CLAUDE_SETTING_SOURCES], ...(effectiveEffort ? { effort: effectiveEffort } : {}), @@ -2610,7 +2622,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { status: "ready", runtimeMode: input.runtimeMode, ...(input.cwd ? { cwd: input.cwd } : {}), - ...(input.model ? { model: input.model } : {}), + ...(modelSelection?.model ? { model: modelSelection.model } : {}), ...(threadId ? { threadId } : {}), resumeCursor: { ...(threadId ? { threadId } : {}), @@ -2664,7 +2676,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { threadId, payload: { config: { - ...(input.model ? { model: input.model } : {}), + ...(modelSelection?.model ? { model: modelSelection.model } : {}), ...(input.cwd ? { cwd: input.cwd } : {}), ...(effectiveEffort ? { effort: effectiveEffort } : {}), ...(permissionMode ? { permissionMode } : {}), @@ -2710,6 +2722,8 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { const sendTurn: ClaudeAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { const context = yield* requireSession(input.threadId); + const modelSelection = + input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; if (context.turnState) { // Auto-close a stale synthetic turn (from background agent responses @@ -2717,9 +2731,9 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { yield* completeTurn(context, "completed"); } - if (input.model) { + if (modelSelection?.model) { yield* Effect.tryPromise({ - try: () => context.query.setModel(input.model), + try: () => context.query.setModel(modelSelection.model), catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause), }); } @@ -2769,7 +2783,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { createdAt: turnStartedStamp.createdAt, threadId: context.session.threadId, turnId, - payload: input.model ? { model: input.model } : {}, + payload: modelSelection?.model ? { model: modelSelection.model } : {}, providerRefs: {}, }); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 31d394c3ec..0c2cc0084e 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -188,9 +188,10 @@ validationLayer("CodexAdapterLive validation", (it) => { yield* adapter.startSession({ provider: "codex", threadId: asThreadId("thread-1"), - model: "gpt-5.3-codex", - modelOptions: { - codex: { + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + options: { fastMode: true, }, }, @@ -256,9 +257,10 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { adapter.sendTurn({ threadId: asThreadId("sess-missing"), input: "hello", - model: "gpt-5.3-codex", - modelOptions: { - codex: { + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + options: { reasoningEffort: "high", fastMode: true, }, diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index e2fcebe1bc..f9bf502929 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1313,8 +1313,12 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => ...(input.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), runtimeMode: input.runtimeMode, - ...(input.model !== undefined ? { model: input.model } : {}), - ...(input.modelOptions?.codex?.fastMode ? { serviceTier: "fast" } : {}), + ...(input.modelSelection?.provider === "codex" + ? { model: input.modelSelection.model } + : {}), + ...(input.modelSelection?.provider === "codex" && input.modelSelection.options?.fastMode + ? { serviceTier: "fast" } + : {}), }; return Effect.tryPromise({ @@ -1370,11 +1374,17 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => const managerInput = { threadId: input.threadId, ...(input.input !== undefined ? { input: input.input } : {}), - ...(input.model !== undefined ? { model: input.model } : {}), - ...(input.modelOptions?.codex?.reasoningEffort !== undefined - ? { effort: input.modelOptions.codex.reasoningEffort } + ...(input.modelSelection?.provider === "codex" + ? { model: input.modelSelection.model } + : {}), + ...(input.modelSelection?.provider === "codex" && + input.modelSelection.options?.reasoningEffort !== undefined + ? { effort: input.modelSelection.options.reasoningEffort } + : {}), + ...(input.modelSelection?.provider === "codex" && + input.modelSelection.options?.fastMode + ? { serviceTier: "fast" } : {}), - ...(input.modelOptions?.codex?.fastMode ? { serviceTier: "fast" } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index a305c8aa64..7af85aafd2 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -633,8 +633,10 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: "claudeAgent", threadId: asThreadId("thread-claude-send-turn"), cwd: "/tmp/project-claude-send-turn", - modelOptions: { - claudeAgent: { + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { effort: "max", }, }, @@ -658,14 +660,16 @@ routing.layer("ProviderServiceLive routing", (it) => { const startPayload = resumedStartInput as { provider?: string; cwd?: string; - modelOptions?: unknown; + modelSelection?: unknown; resumeCursor?: unknown; threadId?: string; }; assert.equal(startPayload.provider, "claudeAgent"); assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn"); - assert.deepEqual(startPayload.modelOptions, { - claudeAgent: { + assert.deepEqual(startPayload.modelSelection, { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { effort: "max", }, }); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 4c0edd4ac2..364e30fd0e 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -10,6 +10,7 @@ * @module ProviderServiceLive */ import { + ModelSelection, NonNegativeInt, ThreadId, ProviderInterruptTurnInput, @@ -89,7 +90,7 @@ function toRuntimeStatus(session: ProviderSession): "starting" | "running" | "st function toRuntimePayloadFromSession( session: ProviderSession, extra?: { - readonly modelOptions?: unknown; + readonly modelSelection?: unknown; readonly providerOptions?: unknown; readonly lastRuntimeEvent?: string; readonly lastRuntimeEventAt?: string; @@ -100,7 +101,7 @@ function toRuntimePayloadFromSession( model: session.model ?? null, activeTurnId: session.activeTurnId ?? null, lastError: session.lastError ?? null, - ...(extra?.modelOptions !== undefined ? { modelOptions: extra.modelOptions } : {}), + ...(extra?.modelSelection !== undefined ? { modelSelection: extra.modelSelection } : {}), ...(extra?.providerOptions !== undefined ? { providerOptions: extra.providerOptions } : {}), ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}), ...(extra?.lastRuntimeEventAt !== undefined @@ -109,15 +110,14 @@ function toRuntimePayloadFromSession( }; } -function readPersistedModelOptions( +function readPersistedModelSelection( runtimePayload: ProviderRuntimeBinding["runtimePayload"], -): Record | undefined { +): ModelSelection | undefined { if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { return undefined; } - const raw = "modelOptions" in runtimePayload ? runtimePayload.modelOptions : undefined; - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; - return raw as Record; + const raw = "modelSelection" in runtimePayload ? runtimePayload.modelSelection : undefined; + return Schema.is(ModelSelection)(raw) ? raw : undefined; } function readPersistedProviderOptions( @@ -172,7 +172,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => session: ProviderSession, threadId: ThreadId, extra?: { - readonly modelOptions?: unknown; + readonly modelSelection?: unknown; readonly providerOptions?: unknown; readonly lastRuntimeEvent?: string; readonly lastRuntimeEventAt?: string; @@ -238,14 +238,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => } const persistedCwd = readPersistedCwd(input.binding.runtimePayload); - const persistedModelOptions = readPersistedModelOptions(input.binding.runtimePayload); + const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); const persistedProviderOptions = readPersistedProviderOptions(input.binding.runtimePayload); const resumed = yield* adapter.startSession({ threadId: input.binding.threadId, provider: input.binding.provider, ...(persistedCwd ? { cwd: persistedCwd } : {}), - ...(persistedModelOptions ? { modelOptions: persistedModelOptions } : {}), + ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), ...(persistedProviderOptions ? { providerOptions: persistedProviderOptions } : {}), ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), runtimeMode: input.binding.runtimeMode ?? "full-access", @@ -328,7 +328,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => } yield* upsertSessionBinding(session, threadId, { - modelOptions: input.modelOptions, + modelSelection: input.modelSelection, providerOptions: input.providerOptions, }); yield* analytics.record("provider.session.started", { @@ -336,7 +336,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => runtimeMode: input.runtimeMode, hasResumeCursor: session.resumeCursor !== undefined, hasCwd: typeof input.cwd === "string" && input.cwd.trim().length > 0, - hasModel: typeof input.model === "string" && input.model.trim().length > 0, + hasModel: + typeof input.modelSelection?.model === "string" && + input.modelSelection.model.trim().length > 0, }); return session; @@ -372,6 +374,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => status: "running", ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}), runtimePayload: { + ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), activeTurnId: turn.turnId, lastRuntimeEvent: "provider.sendTurn", lastRuntimeEventAt: new Date().toISOString(), @@ -379,7 +382,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => }); yield* analytics.record("provider.turn.sent", { provider: routed.adapter.provider, - model: input.model, + model: input.modelSelection?.model, interactionMode: input.interactionMode, attachmentCount: input.attachments.length, hasInput: typeof input.input === "string" && input.input.trim().length > 0, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 46ea348fb1..72e182ae43 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7,7 +7,6 @@ import { type ProjectScript, type ModelSlug, type ProviderKind, - type ProviderModelOptions, type ProjectEntry, type ProjectId, type ProviderApprovalDecision, @@ -28,7 +27,6 @@ import { 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"; @@ -205,12 +203,6 @@ function formatOutgoingPrompt(params: { } return params.text; } -function extractModelSelectionOptions( - provider: ProviderKind, - modelOptions: ProviderModelOptions | undefined, -): ModelSelection["options"] | undefined { - return provider === "codex" ? modelOptions?.codex : modelOptions?.claudeAgent; -} const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; @@ -630,16 +622,15 @@ export default function ChatView({ threadId }: ChatViewProps) { customModelsByProvider, selectedProvider, ]); - const draftModelOptions = toProviderModelOptions(composerDraft.modelSelection); const composerProviderState = useMemo( () => getComposerProviderState({ provider: selectedProvider, model: selectedModel, prompt, - modelOptions: draftModelOptions, + modelOptions: composerDraft.modelSelection?.options, }), - [draftModelOptions, prompt, selectedModel, selectedProvider], + [composerDraft.modelSelection?.options, prompt, selectedModel, selectedProvider], ); const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; @@ -647,14 +638,7 @@ export default function ChatView({ threadId }: ChatViewProps) { () => ({ provider: selectedProvider, model: selectedModel, - ...(extractModelSelectionOptions(selectedProvider, selectedModelOptionsForDispatch) - ? { - options: extractModelSelectionOptions( - selectedProvider, - selectedModelOptionsForDispatch, - ), - } - : {}), + ...(selectedModelOptionsForDispatch ? { options: selectedModelOptionsForDispatch } : {}), }), [selectedModel, selectedModelOptionsForDispatch, selectedProvider], ); diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index 139876d6fa..1a58164c93 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -23,10 +23,8 @@ describe("getComposerProviderState", () => { model: "gpt-5.4", prompt: "", modelOptions: { - codex: { - reasoningEffort: "low", - fastMode: true, - }, + reasoningEffort: "low", + fastMode: true, }, }); @@ -34,10 +32,8 @@ describe("getComposerProviderState", () => { provider: "codex", promptEffort: "low", modelOptionsForDispatch: { - codex: { - reasoningEffort: "low", - fastMode: true, - }, + reasoningEffort: "low", + fastMode: true, }, }); }); @@ -63,9 +59,7 @@ describe("getComposerProviderState", () => { model: "claude-sonnet-4-6", prompt: "Ultrathink:\nInvestigate this failure", modelOptions: { - claudeAgent: { - effort: "medium", - }, + effort: "medium", }, }); @@ -73,9 +67,7 @@ describe("getComposerProviderState", () => { provider: "claudeAgent", promptEffort: "medium", modelOptionsForDispatch: { - claudeAgent: { - effort: "medium", - }, + effort: "medium", }, composerFrameClassName: "ultrathink-frame", composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]", @@ -89,10 +81,8 @@ describe("getComposerProviderState", () => { model: "claude-haiku-4-5", prompt: "", modelOptions: { - claudeAgent: { - effort: "max", - thinking: false, - }, + effort: "max", + thinking: false, }, }); @@ -100,50 +90,8 @@ describe("getComposerProviderState", () => { provider: "claudeAgent", promptEffort: null, modelOptionsForDispatch: { - claudeAgent: { - thinking: false, - }, + thinking: false, }, }); }); - - it("ignores codex options while resolving Claude state", () => { - const state = getComposerProviderState({ - provider: "claudeAgent", - model: "claude-opus-4-6", - prompt: "", - modelOptions: { - codex: { - reasoningEffort: "low", - fastMode: true, - }, - }, - }); - - expect(state).toEqual({ - provider: "claudeAgent", - promptEffort: "high", - modelOptionsForDispatch: undefined, - }); - }); - - it("ignores Claude options while resolving codex state", () => { - const state = getComposerProviderState({ - provider: "codex", - model: "gpt-5.4", - prompt: "Ultrathink:\nThis should not matter", - modelOptions: { - claudeAgent: { - effort: "max", - fastMode: true, - }, - }, - }); - - expect(state).toEqual({ - provider: "codex", - promptEffort: "high", - modelOptionsForDispatch: undefined, - }); - }); }); diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index c1ad0156ad..d211c21d22 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -1,7 +1,9 @@ import { + type ClaudeModelOptions, + type CodexModelOptions, type ModelSlug, + type ModelSelection, type ProviderKind, - type ProviderModelOptions, type ThreadId, } from "@t3tools/contracts"; import { @@ -21,13 +23,13 @@ export type ComposerProviderStateInput = { provider: ProviderKind; model: ModelSlug; prompt: string; - modelOptions: ProviderModelOptions | null | undefined; + modelOptions: ModelSelection["options"] | null | undefined; }; export type ComposerProviderState = { provider: ProviderKind; promptEffort: string | null; - modelOptionsForDispatch: ProviderModelOptions | undefined; + modelOptionsForDispatch: ModelSelection["options"] | undefined; composerFrameClassName?: string; composerSurfaceClassName?: string; modelPickerIconClassName?: string; @@ -47,20 +49,33 @@ type ProviderRegistryEntry = { }) => ReactNode; }; +function asCodexModelOptions( + modelOptions: ModelSelection["options"] | null | undefined, +): CodexModelOptions | undefined { + return modelOptions && "reasoningEffort" in modelOptions ? modelOptions : undefined; +} + +function asClaudeModelOptions( + modelOptions: ModelSelection["options"] | null | undefined, +): ClaudeModelOptions | undefined { + return modelOptions && ("effort" in modelOptions || "thinking" in modelOptions) + ? modelOptions + : undefined; +} + const composerProviderRegistry: Record = { codex: { getState: ({ modelOptions }) => { + const codexModelOptions = asCodexModelOptions(modelOptions); const promptEffort = - resolveReasoningEffortForProvider("codex", modelOptions?.codex?.reasoningEffort) ?? + resolveReasoningEffortForProvider("codex", codexModelOptions?.reasoningEffort) ?? getDefaultReasoningEffort("codex"); - const normalizedCodexOptions = normalizeCodexModelOptions(modelOptions?.codex); + const normalizedCodexOptions = normalizeCodexModelOptions(codexModelOptions); return { provider: "codex", promptEffort, - modelOptionsForDispatch: normalizedCodexOptions - ? { codex: normalizedCodexOptions } - : undefined, + modelOptionsForDispatch: normalizedCodexOptions, }; }, renderTraitsMenuContent: ({ threadId }) => , @@ -68,10 +83,11 @@ const composerProviderRegistry: Record = { }, claudeAgent: { getState: ({ model, prompt, modelOptions }) => { + const claudeModelOptions = asClaudeModelOptions(modelOptions); const reasoningOptions = getReasoningEffortOptions("claudeAgent", model); const draftEffort = resolveReasoningEffortForProvider( "claudeAgent", - modelOptions?.claudeAgent?.effort, + claudeModelOptions?.effort, ); const defaultEffort = getDefaultReasoningEffort("claudeAgent"); const promptEffort = @@ -80,16 +96,14 @@ const composerProviderRegistry: Record = { : reasoningOptions.includes(defaultEffort) ? defaultEffort : null; - const normalizedClaudeOptions = normalizeClaudeModelOptions(model, modelOptions?.claudeAgent); + const normalizedClaudeOptions = normalizeClaudeModelOptions(model, claudeModelOptions); const ultrathinkActive = supportsClaudeUltrathinkKeyword(model) && isClaudeUltrathinkPrompt(prompt); return { provider: "claudeAgent", promptEffort, - modelOptionsForDispatch: normalizedClaudeOptions - ? { claudeAgent: normalizedClaudeOptions } - : undefined, + modelOptionsForDispatch: normalizedClaudeOptions, ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame" } : {}), ...(ultrathinkActive ? { composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]" } diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index c6247c7a53..c1cc1fcf02 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -133,7 +133,7 @@ function normalizeLegacyThreadModelSelection( return Effect.succeed(input.modelSelection); } if (input.model === undefined) { - return Effect.succeed(undefined); + return Effect.succeed(undefined); } return Effect.try({ try: () => diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 5e034f74f2..0c24b1da99 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -12,9 +12,10 @@ describe("ProviderSessionStartInput", () => { threadId: "thread-1", provider: "codex", cwd: "/tmp/workspace", - model: "gpt-5.3-codex", - modelOptions: { - codex: { + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + options: { reasoningEffort: "high", fastMode: true, }, @@ -28,8 +29,13 @@ describe("ProviderSessionStartInput", () => { }, }); expect(parsed.runtimeMode).toBe("full-access"); - expect(parsed.modelOptions?.codex?.reasoningEffort).toBe("high"); - expect(parsed.modelOptions?.codex?.fastMode).toBe(true); + expect(parsed.modelSelection?.provider).toBe("codex"); + expect(parsed.modelSelection?.model).toBe("gpt-5.3-codex"); + if (parsed.modelSelection?.provider !== "codex") { + throw new Error("Expected codex modelSelection"); + } + expect(parsed.modelSelection.options?.reasoningEffort).toBe("high"); + expect(parsed.modelSelection.options?.fastMode).toBe(true); expect(parsed.providerOptions?.codex?.binaryPath).toBe("/usr/local/bin/codex"); expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex"); }); @@ -48,9 +54,10 @@ describe("ProviderSessionStartInput", () => { threadId: "thread-1", provider: "claudeAgent", cwd: "/tmp/workspace", - model: "claude-sonnet-4-6", - modelOptions: { - claudeAgent: { + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + options: { thinking: true, effort: "max", fastMode: true, @@ -66,9 +73,14 @@ describe("ProviderSessionStartInput", () => { runtimeMode: "full-access", }); expect(parsed.provider).toBe("claudeAgent"); - expect(parsed.modelOptions?.claudeAgent?.thinking).toBe(true); - expect(parsed.modelOptions?.claudeAgent?.effort).toBe("max"); - expect(parsed.modelOptions?.claudeAgent?.fastMode).toBe(true); + expect(parsed.modelSelection?.provider).toBe("claudeAgent"); + expect(parsed.modelSelection?.model).toBe("claude-sonnet-4-6"); + if (parsed.modelSelection?.provider !== "claudeAgent") { + throw new Error("Expected claude modelSelection"); + } + expect(parsed.modelSelection.options?.thinking).toBe(true); + expect(parsed.modelSelection.options?.effort).toBe("max"); + expect(parsed.modelSelection.options?.fastMode).toBe(true); expect(parsed.providerOptions?.claudeAgent?.binaryPath).toBe("/usr/local/bin/claude"); expect(parsed.providerOptions?.claudeAgent?.permissionMode).toBe("plan"); expect(parsed.providerOptions?.claudeAgent?.maxThinkingTokens).toBe(12_000); @@ -77,36 +89,46 @@ describe("ProviderSessionStartInput", () => { }); describe("ProviderSendTurnInput", () => { - it("accepts provider-scoped model options", () => { + it("accepts codex modelSelection", () => { const parsed = decodeProviderSendTurnInput({ threadId: "thread-1", - model: "gpt-5.3-codex", - modelOptions: { - codex: { + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + options: { reasoningEffort: "xhigh", fastMode: true, }, }, }); - expect(parsed.model).toBe("gpt-5.3-codex"); - expect(parsed.modelOptions?.codex?.reasoningEffort).toBe("xhigh"); - expect(parsed.modelOptions?.codex?.fastMode).toBe(true); + expect(parsed.modelSelection?.provider).toBe("codex"); + expect(parsed.modelSelection?.model).toBe("gpt-5.3-codex"); + if (parsed.modelSelection?.provider !== "codex") { + throw new Error("Expected codex modelSelection"); + } + expect(parsed.modelSelection.options?.reasoningEffort).toBe("xhigh"); + expect(parsed.modelSelection.options?.fastMode).toBe(true); }); - it("accepts claude provider effort options including ultrathink", () => { + it("accepts claude modelSelection including ultrathink", () => { const parsed = decodeProviderSendTurnInput({ threadId: "thread-1", - model: "claude-sonnet-4-6", - modelOptions: { - claudeAgent: { + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + options: { effort: "ultrathink", fastMode: true, }, }, }); - expect(parsed.modelOptions?.claudeAgent?.effort).toBe("ultrathink"); - expect(parsed.modelOptions?.claudeAgent?.fastMode).toBe(true); + expect(parsed.modelSelection?.provider).toBe("claudeAgent"); + if (parsed.modelSelection?.provider !== "claudeAgent") { + throw new Error("Expected claude modelSelection"); + } + expect(parsed.modelSelection.options?.effort).toBe("ultrathink"); + expect(parsed.modelSelection.options?.fastMode).toBe(true); }); }); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index db8e24954f..e28088dc92 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -1,6 +1,5 @@ import { Schema } from "effect"; import { TrimmedNonEmptyString } from "./baseSchemas"; -import { ProviderModelOptions } from "./model"; import { ApprovalRequestId, EventId, @@ -11,6 +10,7 @@ import { } from "./baseSchemas"; import { ChatAttachment, + ModelSelection, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, ProviderApprovalDecision, @@ -51,8 +51,7 @@ export const ProviderSessionStartInput = Schema.Struct({ threadId: ThreadId, provider: Schema.optional(ProviderKind), cwd: Schema.optional(TrimmedNonEmptyString), - model: Schema.optional(TrimmedNonEmptyString), - modelOptions: Schema.optional(ProviderModelOptions), + modelSelection: Schema.optional(ModelSelection), resumeCursor: Schema.optional(Schema.Unknown), approvalPolicy: Schema.optional(ProviderApprovalPolicy), sandboxMode: Schema.optional(ProviderSandboxMode), @@ -69,8 +68,7 @@ export const ProviderSendTurnInput = Schema.Struct({ attachments: Schema.optional( Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)), ), - model: Schema.optional(TrimmedNonEmptyString), - modelOptions: Schema.optional(ProviderModelOptions), + modelSelection: Schema.optional(ModelSelection), interactionMode: Schema.optional(ProviderInteractionMode), }); export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type; diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 5eb2aba94c..75fc4be539 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -27,7 +27,6 @@ import { supportsClaudeFastMode, supportsClaudeMaxEffort, supportsClaudeThinkingToggle, - toProviderModelOptions, supportsClaudeUltrathinkKeyword, } from "./model"; @@ -273,45 +272,6 @@ describe("normalizeClaudeModelOptions", () => { }); }); -describe("toProviderModelOptions", () => { - it("returns undefined when no model options exist", () => { - expect(toProviderModelOptions({ provider: "codex", model: "gpt-5.4" })).toBeUndefined(); - expect(toProviderModelOptions(null)).toBeUndefined(); - }); - - it("scopes codex options under the codex provider key", () => { - expect( - toProviderModelOptions({ - provider: "codex", - model: "gpt-5.4", - options: { - fastMode: true, - }, - }), - ).toEqual({ - codex: { - fastMode: true, - }, - }); - }); - - it("scopes claude options under the claudeAgent provider key", () => { - expect( - toProviderModelOptions({ - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - }, - }), - ).toEqual({ - claudeAgent: { - effort: "max", - }, - }); - }); -}); - describe("supportsClaudeAdaptiveReasoning", () => { it("only enables adaptive reasoning for Opus 4.6 and Sonnet 4.6", () => { expect(supportsClaudeAdaptiveReasoning("claude-opus-4-6")).toBe(true); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 9440985283..4b678531fd 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -10,9 +10,7 @@ import { type ClaudeCodeEffort, type CodexModelOptions, type CodexReasoningEffort, - type ModelSelection, type ModelSlug, - type ProviderModelOptions, type ProviderReasoningEffort, type ProviderKind, } from "@t3tools/contracts"; @@ -268,15 +266,4 @@ export function applyClaudePromptEffortPrefix( 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 }; From e12d2903f208ddee477b9cda7b2bfcfe0b9341c9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 15:28:10 -0700 Subject: [PATCH 08/38] Update sidebar test fixtures for provider-aware model selection - Switch project and thread fixtures to provider-scoped model selection - Keep sidebar logic tests aligned with the new Codex provider model shape --- apps/web/src/components/Sidebar.logic.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 6925b5391c..61c894deba 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -347,7 +347,11 @@ function makeProject(overrides: Partial = {}): Project { id: ProjectId.makeUnsafe("project-1"), name: "Project", cwd: "/tmp/project", - model: "gpt-5.4", + defaultModelSelection: { + provider: "codex", + model: "gpt-5.4", + ...overrides?.defaultModelSelection, + }, expanded: true, createdAt: "2026-03-09T10:00:00.000Z", updatedAt: "2026-03-09T10:00:00.000Z", @@ -362,7 +366,11 @@ function makeThread(overrides: Partial = {}): Thread { codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", - model: "gpt-5.4", + modelSelection: { + provider: "codex", + model: "gpt-5.4", + ...overrides?.modelSelection, + }, runtimeMode: DEFAULT_RUNTIME_MODE, interactionMode: DEFAULT_INTERACTION_MODE, session: null, From c44d17f1747f781e7c267f1687aeec00bcc934a0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 15:42:54 -0700 Subject: [PATCH 09/38] simplify migrations --- apps/server/src/persistence/Migrations.ts | 8 +- .../016_CanonicalizeModelSelections.test.ts | 182 ++++++++++++++++++ ....ts => 016_CanonicalizeModelSelections.ts} | 63 ++++-- .../Migrations/016_ProjectionProviders.ts | 38 ---- .../017_ProjectionModelSelectionOptions.ts | 28 --- ..._CanonicalizeLegacyModelSelections.test.ts | 180 ----------------- 6 files changed, 234 insertions(+), 265 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts rename apps/server/src/persistence/Migrations/{018_CanonicalizeLegacyModelSelections.ts => 016_CanonicalizeModelSelections.ts} (83%) delete mode 100644 apps/server/src/persistence/Migrations/016_ProjectionProviders.ts delete mode 100644 apps/server/src/persistence/Migrations/017_ProjectionModelSelectionOptions.ts delete mode 100644 apps/server/src/persistence/Migrations/018_CanonicalizeLegacyModelSelections.test.ts diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index bbec6e131e..279930bb7b 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -27,9 +27,7 @@ import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts" import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts"; import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplementation.ts"; import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts"; -import Migration0016 from "./Migrations/016_ProjectionProviders.ts"; -import Migration0017 from "./Migrations/017_ProjectionModelSelectionOptions.ts"; -import Migration0018 from "./Migrations/018_CanonicalizeLegacyModelSelections.ts"; +import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts"; import { Effect } from "effect"; /** @@ -58,9 +56,7 @@ const loader = Migrator.fromRecord({ "13_ProjectionThreadProposedPlans": Migration0013, "14_ProjectionThreadProposedPlanImplementation": Migration0014, "15_ProjectionTurnsSourceProposedPlan": Migration0015, - "16_ProjectionProviders": Migration0016, - "17_ProjectionModelSelectionOptions": Migration0017, - "18_CanonicalizeLegacyModelSelections": Migration0018, + "16_CanonicalizeModelSelections": Migration0016, }); /** diff --git a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts new file mode 100644 index 0000000000..92f56a2fdd --- /dev/null +++ b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts @@ -0,0 +1,182 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import Migration0016 from "./016_CanonicalizeModelSelections.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("016_CanonicalizeModelSelections", (it) => { + it.effect( + "migrates legacy projection rows and event payloads to the canonical model-selection shape", + () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE projection_projects ( + project_id TEXT PRIMARY KEY, + default_model TEXT + ) + `; + yield* sql` + CREATE TABLE projection_threads ( + thread_id TEXT PRIMARY KEY, + model TEXT NOT NULL + ) + `; + yield* sql` + CREATE TABLE projection_thread_sessions ( + thread_id TEXT PRIMARY KEY, + provider_name TEXT + ) + `; + yield* sql` + CREATE TABLE orchestration_events ( + event_type TEXT NOT NULL, + payload_json TEXT NOT NULL + ) + `; + + yield* sql` + INSERT INTO projection_projects (project_id, default_model) + VALUES + ('project-codex', 'gpt-5.4'), + ('project-claude', 'claude-sonnet-4-6'), + ('project-null', NULL) + `; + yield* sql` + UPDATE projection_projects + SET default_model = 'claude-opus-4-6' + WHERE project_id = 'project-claude' + `; + yield* sql` + INSERT INTO projection_threads (thread_id, model) + VALUES + ('thread-session', 'gpt-5.4'), + ('thread-claude', 'claude-opus-4-6'), + ('thread-codex', 'gpt-5.4'), + ('thread-legacy-options', 'claude-opus-4-6') + `; + yield* sql` + INSERT INTO projection_thread_sessions (thread_id, provider_name) + VALUES ('thread-session', 'claudeAgent') + `; + yield* sql` + INSERT INTO orchestration_events ( + event_type, + payload_json + ) + VALUES + ( + 'project.created', + '{"projectId":"project-1","title":"Project","workspaceRoot":"/tmp/project","defaultModel":"claude-opus-4-6","defaultModelOptions":{"codex":{"reasoningEffort":"high"},"claudeAgent":{"effort":"max"}},"scripts":[],"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}' + ), + ( + 'thread.created', + '{"threadId":"thread-1","projectId":"project-1","title":"Thread","model":"claude-opus-4-6","modelOptions":{"codex":{"reasoningEffort":"high"},"claudeAgent":{"effort":"max","thinking":false}},"runtimeMode":"full-access","interactionMode":"default","branch":null,"worktreePath":null,"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}' + ), + ( + 'thread.turn-start-requested', + '{"threadId":"thread-1","turnId":"turn-1","input":"hi","model":"gpt-5.4","modelOptions":{"codex":{"fastMode":true},"claudeAgent":{"effort":"max"}},"deliveryMode":"buffered"}' + ) + `; + + yield* Migration0016; + + const projectRows = yield* sql<{ + readonly projectId: string; + readonly defaultProvider: string | null; + }>` + SELECT + project_id AS "projectId", + default_provider AS "defaultProvider" + FROM projection_projects + ORDER BY project_id + `; + assert.deepStrictEqual(projectRows, [ + { projectId: "project-claude", defaultProvider: "claudeAgent" }, + { projectId: "project-codex", defaultProvider: "codex" }, + { projectId: "project-null", defaultProvider: null }, + ]); + + const threadRows = yield* sql<{ + readonly threadId: string; + readonly provider: string | null; + readonly modelOptions: string | null; + }>` + SELECT + thread_id AS "threadId", + provider, + model_options_json AS "modelOptions" + FROM projection_threads + ORDER BY thread_id + `; + assert.deepStrictEqual(threadRows, [ + { threadId: "thread-claude", provider: "claudeAgent", modelOptions: null }, + { threadId: "thread-codex", provider: "codex", modelOptions: null }, + { threadId: "thread-legacy-options", provider: "claudeAgent", modelOptions: null }, + { threadId: "thread-session", provider: "claudeAgent", modelOptions: null }, + ]); + + const eventRows = yield* sql<{ + readonly payloadJson: string; + }>` + SELECT payload_json AS "payloadJson" + FROM orchestration_events + ORDER BY rowid ASC + `; + + assert.deepStrictEqual(JSON.parse(eventRows[0]!.payloadJson), { + projectId: "project-1", + title: "Project", + workspaceRoot: "/tmp/project", + defaultModelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { + effort: "max", + }, + }, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + + assert.deepStrictEqual(JSON.parse(eventRows[1]!.payloadJson), { + threadId: "thread-1", + projectId: "project-1", + title: "Thread", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { + effort: "max", + thinking: false, + }, + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + + assert.deepStrictEqual(JSON.parse(eventRows[2]!.payloadJson), { + threadId: "thread-1", + turnId: "turn-1", + input: "hi", + modelSelection: { + provider: "codex", + model: "gpt-5.4", + options: { + fastMode: true, + }, + }, + deliveryMode: "buffered", + }); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/018_CanonicalizeLegacyModelSelections.ts b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.ts similarity index 83% rename from apps/server/src/persistence/Migrations/018_CanonicalizeLegacyModelSelections.ts rename to apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.ts index b4b2912002..bdffa9bd6b 100644 --- a/apps/server/src/persistence/Migrations/018_CanonicalizeLegacyModelSelections.ts +++ b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.ts @@ -4,24 +4,64 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN default_provider TEXT + `; + yield* sql` UPDATE projection_projects - SET - default_provider = CASE - WHEN default_model IS NULL THEN NULL - WHEN lower(default_model) LIKE '%claude%' THEN 'claudeAgent' + SET default_provider = CASE + WHEN default_model IS NULL THEN NULL + WHEN lower(default_model) LIKE '%claude%' THEN 'claudeAgent' + ELSE 'codex' + END + WHERE default_provider IS NULL + `; + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN provider TEXT + `; + + yield* sql` + UPDATE projection_threads + SET provider = COALESCE( + ( + SELECT provider_name + FROM projection_thread_sessions + WHERE projection_thread_sessions.thread_id = projection_threads.thread_id + ), + CASE + WHEN lower(model) LIKE '%claude%' THEN 'claudeAgent' ELSE 'codex' END, + 'codex' + ) + WHERE provider IS NULL + `; + + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN default_model_options_json TEXT + `; + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN model_options_json TEXT + `; + + yield* sql` + UPDATE projection_projects + SET default_model_options_json = CASE WHEN default_model_options_json IS NULL THEN NULL WHEN json_valid(default_model_options_json) = 0 THEN default_model_options_json WHEN json_type(default_model_options_json, '$.codex') IS NOT NULL OR json_type(default_model_options_json, '$.claudeAgent') IS NOT NULL THEN CASE - WHEN lower(default_model) LIKE '%claude%' THEN json_extract( - default_model_options_json, - '$.claudeAgent' - ) + WHEN default_provider = 'claudeAgent' + THEN json_extract(default_model_options_json, '$.claudeAgent') ELSE json_extract(default_model_options_json, '$.codex') END ELSE default_model_options_json @@ -32,17 +72,14 @@ export default Effect.gen(function* () { yield* sql` UPDATE projection_threads SET - provider = CASE - WHEN lower(model) LIKE '%claude%' THEN 'claudeAgent' - ELSE 'codex' - END, model_options_json = CASE WHEN model_options_json IS NULL THEN NULL WHEN json_valid(model_options_json) = 0 THEN model_options_json WHEN json_type(model_options_json, '$.codex') IS NOT NULL OR json_type(model_options_json, '$.claudeAgent') IS NOT NULL THEN CASE - WHEN lower(model) LIKE '%claude%' THEN json_extract(model_options_json, '$.claudeAgent') + WHEN provider = 'claudeAgent' + THEN json_extract(model_options_json, '$.claudeAgent') ELSE json_extract(model_options_json, '$.codex') END ELSE model_options_json diff --git a/apps/server/src/persistence/Migrations/016_ProjectionProviders.ts b/apps/server/src/persistence/Migrations/016_ProjectionProviders.ts deleted file mode 100644 index 4b1ffbd2fd..0000000000 --- a/apps/server/src/persistence/Migrations/016_ProjectionProviders.ts +++ /dev/null @@ -1,38 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; - -export default Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - yield* sql` - ALTER TABLE projection_projects - ADD COLUMN default_provider TEXT - `; - - yield* sql` - UPDATE projection_projects - SET default_provider = CASE - WHEN default_model IS NULL THEN NULL - ELSE 'codex' - END - WHERE default_provider IS NULL - `; - - yield* sql` - ALTER TABLE projection_threads - ADD COLUMN provider TEXT - `; - - yield* sql` - UPDATE projection_threads - SET provider = COALESCE( - ( - SELECT provider_name - FROM projection_thread_sessions - WHERE projection_thread_sessions.thread_id = projection_threads.thread_id - ), - 'codex' - ) - WHERE provider IS NULL - `; -}); diff --git a/apps/server/src/persistence/Migrations/017_ProjectionModelSelectionOptions.ts b/apps/server/src/persistence/Migrations/017_ProjectionModelSelectionOptions.ts deleted file mode 100644 index d17bd4b4c3..0000000000 --- a/apps/server/src/persistence/Migrations/017_ProjectionModelSelectionOptions.ts +++ /dev/null @@ -1,28 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; - -export default Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - yield* sql` - ALTER TABLE projection_projects - ADD COLUMN default_model_options_json TEXT - `; - - yield* sql` - UPDATE projection_projects - SET default_model_options_json = NULL - WHERE default_model_options_json IS NULL - `; - - yield* sql` - ALTER TABLE projection_threads - ADD COLUMN model_options_json TEXT - `; - - yield* sql` - UPDATE projection_threads - SET model_options_json = NULL - WHERE model_options_json IS NULL - `; -}); diff --git a/apps/server/src/persistence/Migrations/018_CanonicalizeLegacyModelSelections.test.ts b/apps/server/src/persistence/Migrations/018_CanonicalizeLegacyModelSelections.test.ts deleted file mode 100644 index cd28a30272..0000000000 --- a/apps/server/src/persistence/Migrations/018_CanonicalizeLegacyModelSelections.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; - -import Migration0018 from "./018_CanonicalizeLegacyModelSelections.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; - -const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); - -layer("018_CanonicalizeLegacyModelSelections", (it) => { - it.effect("canonicalizes legacy projection rows and orchestration event payloads", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - yield* sql` - CREATE TABLE projection_projects ( - project_id TEXT PRIMARY KEY, - default_provider TEXT, - default_model TEXT, - default_model_options_json TEXT - ) - `; - yield* sql` - CREATE TABLE projection_threads ( - thread_id TEXT PRIMARY KEY, - provider TEXT, - model TEXT NOT NULL, - model_options_json TEXT - ) - `; - yield* sql` - CREATE TABLE orchestration_events ( - event_type TEXT NOT NULL, - payload_json TEXT NOT NULL - ) - `; - - yield* sql` - INSERT INTO projection_projects ( - project_id, - default_provider, - default_model, - default_model_options_json - ) - VALUES ( - 'project-1', - 'codex', - 'claude-opus-4-6', - '{"codex":{"reasoningEffort":"high"},"claudeAgent":{"effort":"max","thinking":false}}' - ) - `; - yield* sql` - INSERT INTO projection_threads ( - thread_id, - provider, - model, - model_options_json - ) - VALUES ( - 'thread-1', - 'codex', - 'claude-opus-4-6', - '{"codex":{"reasoningEffort":"high"},"claudeAgent":{"effort":"max"}}' - ) - `; - yield* sql` - INSERT INTO orchestration_events ( - event_type, - payload_json - ) - VALUES - ( - 'project.created', - '{"projectId":"project-1","title":"Project","workspaceRoot":"/tmp/project","defaultModel":"claude-opus-4-6","defaultModelOptions":{"codex":{"reasoningEffort":"high"},"claudeAgent":{"effort":"max"}},"scripts":[],"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}' - ), - ( - 'thread.created', - '{"threadId":"thread-1","projectId":"project-1","title":"Thread","model":"claude-opus-4-6","modelOptions":{"codex":{"reasoningEffort":"high"},"claudeAgent":{"effort":"max","thinking":false}},"runtimeMode":"full-access","interactionMode":"default","branch":null,"worktreePath":null,"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}' - ), - ( - 'thread.turn-start-requested', - '{"threadId":"thread-1","turnId":"turn-1","input":"hi","model":"gpt-5.4","modelOptions":{"codex":{"fastMode":true},"claudeAgent":{"effort":"max"}},"deliveryMode":"buffered"}' - ) - `; - - yield* Migration0018; - - const projectRows = yield* sql<{ - readonly defaultProvider: string | null; - readonly defaultModelOptions: string | null; - }>` - SELECT - default_provider AS "defaultProvider", - default_model_options_json AS "defaultModelOptions" - FROM projection_projects - WHERE project_id = 'project-1' - `; - assert.deepStrictEqual(projectRows[0], { - defaultProvider: "claudeAgent", - defaultModelOptions: '{"effort":"max","thinking":false}', - }); - - const threadRows = yield* sql<{ - readonly provider: string | null; - readonly modelOptions: string | null; - }>` - SELECT - provider, - model_options_json AS "modelOptions" - FROM projection_threads - WHERE thread_id = 'thread-1' - `; - assert.deepStrictEqual(threadRows[0], { - provider: "claudeAgent", - modelOptions: '{"effort":"max"}', - }); - - const eventRows = yield* sql<{ - readonly eventType: string; - readonly payloadJson: string; - }>` - SELECT - event_type AS "eventType", - payload_json AS "payloadJson" - FROM orchestration_events - ORDER BY rowid ASC - `; - - assert.deepStrictEqual(JSON.parse(eventRows[0]!.payloadJson), { - projectId: "project-1", - title: "Project", - workspaceRoot: "/tmp/project", - defaultModelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - }, - }, - scripts: [], - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }); - - assert.deepStrictEqual(JSON.parse(eventRows[1]!.payloadJson), { - threadId: "thread-1", - projectId: "project-1", - title: "Thread", - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - thinking: false, - }, - }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }); - - assert.deepStrictEqual(JSON.parse(eventRows[2]!.payloadJson), { - threadId: "thread-1", - turnId: "turn-1", - input: "hi", - modelSelection: { - provider: "codex", - model: "gpt-5.4", - options: { - fastMode: true, - }, - }, - deliveryMode: "buffered", - }); - }), - ); -}); From 3003850f37d655265be7247e1bfa1894798203f4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 15:51:59 -0700 Subject: [PATCH 10/38] migration test harness --- apps/server/src/persistence/Layers/Sqlite.ts | 2 +- apps/server/src/persistence/Migrations.ts | 65 ++-- .../016_CanonicalizeModelSelections.test.ts | 292 +++++++++++------- 3 files changed, 220 insertions(+), 139 deletions(-) diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index c430e79efb..2dddfc3bfa 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -31,7 +31,7 @@ const setup = Layer.effectDiscard( const sql = yield* SqlClient.SqlClient; yield* sql`PRAGMA journal_mode = WAL;`; yield* sql`PRAGMA foreign_keys = ON;`; - yield* runMigrations; + yield* runMigrations(); }), ); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 279930bb7b..43c4e49f6f 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -10,6 +10,7 @@ import * as Migrator from "effect/unstable/sql/Migrator"; import * as Layer from "effect/Layer"; +import * as Effect from "effect/Effect"; // Import all migrations statically import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; @@ -28,7 +29,6 @@ import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts"; import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplementation.ts"; import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts"; import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts"; -import { Effect } from "effect"; /** * Migration loader with all migrations defined inline. @@ -40,24 +40,33 @@ import { Effect } from "effect"; * Uses Migrator.fromRecord which parses the key format and * returns migrations sorted by ID. */ -const loader = Migrator.fromRecord({ - "1_OrchestrationEvents": Migration0001, - "2_OrchestrationCommandReceipts": Migration0002, - "3_CheckpointDiffBlobs": Migration0003, - "4_ProviderSessionRuntime": Migration0004, - "5_Projections": Migration0005, - "6_ProjectionThreadSessionRuntimeModeColumns": Migration0006, - "7_ProjectionThreadMessageAttachments": Migration0007, - "8_ProjectionThreadActivitySequence": Migration0008, - "9_ProviderSessionRuntimeMode": Migration0009, - "10_ProjectionThreadsRuntimeMode": Migration0010, - "11_OrchestrationThreadCreatedRuntimeMode": Migration0011, - "12_ProjectionThreadsInteractionMode": Migration0012, - "13_ProjectionThreadProposedPlans": Migration0013, - "14_ProjectionThreadProposedPlanImplementation": Migration0014, - "15_ProjectionTurnsSourceProposedPlan": Migration0015, - "16_CanonicalizeModelSelections": Migration0016, -}); +export const migrationEntries = [ + [1, "OrchestrationEvents", Migration0001], + [2, "OrchestrationCommandReceipts", Migration0002], + [3, "CheckpointDiffBlobs", Migration0003], + [4, "ProviderSessionRuntime", Migration0004], + [5, "Projections", Migration0005], + [6, "ProjectionThreadSessionRuntimeModeColumns", Migration0006], + [7, "ProjectionThreadMessageAttachments", Migration0007], + [8, "ProjectionThreadActivitySequence", Migration0008], + [9, "ProviderSessionRuntimeMode", Migration0009], + [10, "ProjectionThreadsRuntimeMode", Migration0010], + [11, "OrchestrationThreadCreatedRuntimeMode", Migration0011], + [12, "ProjectionThreadsInteractionMode", Migration0012], + [13, "ProjectionThreadProposedPlans", Migration0013], + [14, "ProjectionThreadProposedPlanImplementation", Migration0014], + [15, "ProjectionTurnsSourceProposedPlan", Migration0015], + [16, "CanonicalizeModelSelections", Migration0016], +] as const; + +export const makeMigrationLoader = (throughId?: number) => + Migrator.fromRecord( + Object.fromEntries( + migrationEntries + .filter(([id]) => throughId === undefined || id <= throughId) + .map(([id, name, migration]) => [`${id}_${name}`, migration]), + ), + ); /** * Migrator run function - no schema dumping needed @@ -65,6 +74,10 @@ const loader = Migrator.fromRecord({ */ const run = Migrator.make({}); +export interface RunMigrationsOptions { + readonly toMigrationInclusive?: number | undefined; +} + /** * Run all pending migrations. * @@ -75,11 +88,13 @@ const run = Migrator.make({}); * * @returns Effect containing array of executed migrations */ -export const runMigrations = Effect.gen(function* () { - yield* Effect.log("Running migrations..."); - yield* run({ loader }); - yield* Effect.log("Migrations ran successfully"); -}); +export const runMigrations = ({ toMigrationInclusive }: RunMigrationsOptions = {}) => + Effect.gen(function* () { + yield* Effect.log(`Running migrations 1 through ${toMigrationInclusive}...`); + const executedMigrations = yield* run({ loader: makeMigrationLoader(toMigrationInclusive) }); + yield* Effect.log("Migrations ran successfully"); + return executedMigrations; + }); /** * Layer that runs migrations when the layer is built. @@ -98,4 +113,4 @@ export const runMigrations = Effect.gen(function* () { * ) * ``` */ -export const MigrationsLive = Layer.effectDiscard(runMigrations); +export const MigrationsLive = Layer.effectDiscard(runMigrations()); diff --git a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts index 92f56a2fdd..d7fda7c6d9 100644 --- a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts +++ b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts @@ -2,7 +2,7 @@ import { assert, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import Migration0016 from "./016_CanonicalizeModelSelections.ts"; +import { runMigrations } from "../Migrations.ts"; import * as NodeSqliteClient from "../NodeSqliteClient.ts"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); @@ -14,98 +14,163 @@ layer("016_CanonicalizeModelSelections", (it) => { Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - yield* sql` - CREATE TABLE projection_projects ( - project_id TEXT PRIMARY KEY, - default_model TEXT - ) - `; - yield* sql` - CREATE TABLE projection_threads ( - thread_id TEXT PRIMARY KEY, - model TEXT NOT NULL - ) - `; - yield* sql` - CREATE TABLE projection_thread_sessions ( - thread_id TEXT PRIMARY KEY, - provider_name TEXT - ) - `; - yield* sql` - CREATE TABLE orchestration_events ( - event_type TEXT NOT NULL, - payload_json TEXT NOT NULL - ) - `; + // Setup base state + { + yield* runMigrations({ toMigrationInclusive: 15 }); - yield* sql` - INSERT INTO projection_projects (project_id, default_model) + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model, + scripts_json, + created_at, + updated_at, + deleted_at + ) VALUES - ('project-codex', 'gpt-5.4'), - ('project-claude', 'claude-sonnet-4-6'), - ('project-null', NULL) + ('project-codex', 'Codex project', '/tmp/project-codex', 'gpt-5.4', '[]', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL), + ('project-claude', 'Claude project', '/tmp/project-claude', 'claude-sonnet-4-6', '[]', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL), + ('project-null', 'Null project', '/tmp/project-null', NULL, '[]', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL) `; - yield* sql` + yield* sql` UPDATE projection_projects SET default_model = 'claude-opus-4-6' WHERE project_id = 'project-claude' `; - yield* sql` - INSERT INTO projection_threads (thread_id, model) + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + deleted_at, + runtime_mode, + interaction_mode + ) VALUES - ('thread-session', 'gpt-5.4'), - ('thread-claude', 'claude-opus-4-6'), - ('thread-codex', 'gpt-5.4'), - ('thread-legacy-options', 'claude-opus-4-6') + ('thread-session', 'project-codex', 'Session thread', 'gpt-5.4', NULL, NULL, NULL, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL, 'full-access', 'default'), + ('thread-claude', 'project-claude', 'Claude thread', 'claude-opus-4-6', NULL, NULL, NULL, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL, 'full-access', 'default'), + ('thread-codex', 'project-codex', 'Codex thread', 'gpt-5.4', NULL, NULL, NULL, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL, 'full-access', 'default'), + ('thread-legacy-options', 'project-claude', 'Legacy options thread', 'claude-opus-4-6', NULL, NULL, NULL, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL, 'full-access', 'default') `; - yield* sql` - INSERT INTO projection_thread_sessions (thread_id, provider_name) - VALUES ('thread-session', 'claudeAgent') + yield* sql` + INSERT INTO projection_thread_sessions ( + thread_id, + status, + provider_name, + provider_session_id, + provider_thread_id, + active_turn_id, + last_error, + updated_at, + runtime_mode + ) + VALUES ( + 'thread-session', + 'running', + 'claudeAgent', + 'provider-session-1', + 'provider-thread-1', + NULL, + NULL, + '2026-01-01T00:00:00.000Z', + 'full-access' + ) `; - yield* sql` + yield* sql` INSERT INTO orchestration_events ( + event_id, + aggregate_kind, + stream_id, + stream_version, event_type, - payload_json + occurred_at, + command_id, + causation_event_id, + correlation_id, + actor_kind, + payload_json, + metadata_json ) VALUES ( + 'event-project-created', + 'project', + 'project-1', + 1, 'project.created', - '{"projectId":"project-1","title":"Project","workspaceRoot":"/tmp/project","defaultModel":"claude-opus-4-6","defaultModelOptions":{"codex":{"reasoningEffort":"high"},"claudeAgent":{"effort":"max"}},"scripts":[],"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}' + '2026-01-01T00:00:00.000Z', + 'command-project-created', + NULL, + 'correlation-project-created', + 'user', + '{"projectId":"project-1","title":"Project","workspaceRoot":"/tmp/project","defaultModel":"claude-opus-4-6","defaultModelOptions":{"codex":{"reasoningEffort":"high"},"claudeAgent":{"effort":"max"}},"scripts":[],"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' ), ( + 'event-thread-created', + 'thread', + 'thread-1', + 1, 'thread.created', - '{"threadId":"thread-1","projectId":"project-1","title":"Thread","model":"claude-opus-4-6","modelOptions":{"codex":{"reasoningEffort":"high"},"claudeAgent":{"effort":"max","thinking":false}},"runtimeMode":"full-access","interactionMode":"default","branch":null,"worktreePath":null,"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}' + '2026-01-01T00:00:00.000Z', + 'command-thread-created', + NULL, + 'correlation-thread-created', + 'user', + '{"threadId":"thread-1","projectId":"project-1","title":"Thread","model":"claude-opus-4-6","modelOptions":{"codex":{"reasoningEffort":"high"},"claudeAgent":{"effort":"max","thinking":false}},"runtimeMode":"full-access","interactionMode":"default","branch":null,"worktreePath":null,"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' ), ( + 'event-turn-start-requested', + 'thread', + 'thread-1', + 2, 'thread.turn-start-requested', - '{"threadId":"thread-1","turnId":"turn-1","input":"hi","model":"gpt-5.4","modelOptions":{"codex":{"fastMode":true},"claudeAgent":{"effort":"max"}},"deliveryMode":"buffered"}' + '2026-01-01T00:00:00.000Z', + 'command-turn-start-requested', + NULL, + 'correlation-turn-start-requested', + 'user', + '{"threadId":"thread-1","turnId":"turn-1","input":"hi","model":"gpt-5.4","modelOptions":{"codex":{"fastMode":true},"claudeAgent":{"effort":"max"}},"deliveryMode":"buffered"}', + '{}' ) `; + } - yield* Migration0016; + // Execute migration under test + yield* runMigrations({ toMigrationInclusive: 16 }); - const projectRows = yield* sql<{ - readonly projectId: string; - readonly defaultProvider: string | null; - }>` + // Assert expected state + { + const projectRows = yield* sql<{ + readonly projectId: string; + readonly defaultProvider: string | null; + }>` SELECT project_id AS "projectId", default_provider AS "defaultProvider" FROM projection_projects ORDER BY project_id `; - assert.deepStrictEqual(projectRows, [ - { projectId: "project-claude", defaultProvider: "claudeAgent" }, - { projectId: "project-codex", defaultProvider: "codex" }, - { projectId: "project-null", defaultProvider: null }, - ]); + assert.deepStrictEqual(projectRows, [ + { projectId: "project-claude", defaultProvider: "claudeAgent" }, + { projectId: "project-codex", defaultProvider: "codex" }, + { projectId: "project-null", defaultProvider: null }, + ]); - const threadRows = yield* sql<{ - readonly threadId: string; - readonly provider: string | null; - readonly modelOptions: string | null; - }>` + const threadRows = yield* sql<{ + readonly threadId: string; + readonly provider: string | null; + readonly modelOptions: string | null; + }>` SELECT thread_id AS "threadId", provider, @@ -113,70 +178,71 @@ layer("016_CanonicalizeModelSelections", (it) => { FROM projection_threads ORDER BY thread_id `; - assert.deepStrictEqual(threadRows, [ - { threadId: "thread-claude", provider: "claudeAgent", modelOptions: null }, - { threadId: "thread-codex", provider: "codex", modelOptions: null }, - { threadId: "thread-legacy-options", provider: "claudeAgent", modelOptions: null }, - { threadId: "thread-session", provider: "claudeAgent", modelOptions: null }, - ]); + assert.deepStrictEqual(threadRows, [ + { threadId: "thread-claude", provider: "claudeAgent", modelOptions: null }, + { threadId: "thread-codex", provider: "codex", modelOptions: null }, + { threadId: "thread-legacy-options", provider: "claudeAgent", modelOptions: null }, + { threadId: "thread-session", provider: "claudeAgent", modelOptions: null }, + ]); - const eventRows = yield* sql<{ - readonly payloadJson: string; - }>` + const eventRows = yield* sql<{ + readonly payloadJson: string; + }>` SELECT payload_json AS "payloadJson" FROM orchestration_events ORDER BY rowid ASC `; - assert.deepStrictEqual(JSON.parse(eventRows[0]!.payloadJson), { - projectId: "project-1", - title: "Project", - workspaceRoot: "/tmp/project", - defaultModelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", + assert.deepStrictEqual(JSON.parse(eventRows[0]!.payloadJson), { + projectId: "project-1", + title: "Project", + workspaceRoot: "/tmp/project", + defaultModelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { + effort: "max", + }, }, - }, - scripts: [], - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }); + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); - assert.deepStrictEqual(JSON.parse(eventRows[1]!.payloadJson), { - threadId: "thread-1", - projectId: "project-1", - title: "Thread", - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - thinking: false, + assert.deepStrictEqual(JSON.parse(eventRows[1]!.payloadJson), { + threadId: "thread-1", + projectId: "project-1", + title: "Thread", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { + effort: "max", + thinking: false, + }, }, - }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }); + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); - assert.deepStrictEqual(JSON.parse(eventRows[2]!.payloadJson), { - threadId: "thread-1", - turnId: "turn-1", - input: "hi", - modelSelection: { - provider: "codex", - model: "gpt-5.4", - options: { - fastMode: true, + assert.deepStrictEqual(JSON.parse(eventRows[2]!.payloadJson), { + threadId: "thread-1", + turnId: "turn-1", + input: "hi", + modelSelection: { + provider: "codex", + model: "gpt-5.4", + options: { + fastMode: true, + }, }, - }, - deliveryMode: "buffered", - }); + deliveryMode: "buffered", + }); + } }), ); }); From 2cadee4050bd525fdf09970a184d277126ed3fc5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 16:02:05 -0700 Subject: [PATCH 11/38] json --- .../Layers/ProjectionPipeline.test.ts | 6 +- .../Layers/ProjectionSnapshotQuery.test.ts | 12 +- .../Layers/ProjectionSnapshotQuery.ts | 145 ++++++------------ .../persistence/Layers/ProjectionProjects.ts | 128 +++++----------- .../Layers/ProjectionRepositories.test.ts | 24 ++- .../persistence/Layers/ProjectionThreads.ts | 84 ++-------- .../016_CanonicalizeModelSelections.test.ts | 42 +++-- .../016_CanonicalizeModelSelections.ts | 85 ++++------ 8 files changed, 179 insertions(+), 347 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 355f452d48..77b5d4d619 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1933,11 +1933,11 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { const projectRows = yield* sql<{ readonly scriptsJson: string; - readonly defaultModel: string; + readonly defaultModelSelection: string; }>` SELECT scripts_json AS "scriptsJson", - default_model AS "defaultModel" + default_model_selection_json AS "defaultModelSelection" FROM projection_projects WHERE project_id = 'project-scripts' `; @@ -1945,7 +1945,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { { scriptsJson: '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]', - defaultModel: "gpt-5", + defaultModelSelection: '{"provider":"codex","model":"gpt-5"}', }, ]); }), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 172b90f057..5080ea8c48 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -34,8 +34,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { project_id, title, workspace_root, - default_provider, - default_model, + default_model_selection_json, scripts_json, created_at, updated_at, @@ -45,8 +44,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 'project-1', 'Project 1', '/tmp/project-1', - 'codex', - 'gpt-5-codex', + '{"provider":"codex","model":"gpt-5-codex"}', '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]', '2026-02-24T00:00:00.000Z', '2026-02-24T00:00:01.000Z', @@ -59,8 +57,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { thread_id, project_id, title, - provider, - model, + model_selection_json, branch, worktree_path, latest_turn_id, @@ -72,8 +69,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 'thread-1', 'project-1', 'Thread 1', - 'codex', - 'gpt-5-codex', + '{"provider":"codex","model":"gpt-5-codex"}', NULL, NULL, 'turn-1', diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index e51afc8233..17bd6edbe1 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1,7 +1,6 @@ import { ChatAttachment, IsoDateTime, - ModelSelection, MessageId, NonNegativeInt, OrchestrationCheckpointFile, @@ -44,18 +43,14 @@ import { } from "../Services/ProjectionSnapshotQuery.ts"; const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); -const ProjectionProjectDbRowSchema = Schema.Struct({ - projectId: ProjectionProject.fields.projectId, - title: ProjectionProject.fields.title, - workspaceRoot: ProjectionProject.fields.workspaceRoot, - defaultProvider: Schema.NullOr(Schema.Literals(["codex", "claudeAgent"])), - defaultModel: Schema.NullOr(Schema.String), - defaultModelOptions: Schema.NullOr(Schema.fromJsonString(Schema.Unknown)), - scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), - createdAt: ProjectionProject.fields.createdAt, - updatedAt: ProjectionProject.fields.updatedAt, - deletedAt: ProjectionProject.fields.deletedAt, -}); +const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( + Struct.assign({ + defaultModelSelection: Schema.NullOr( + Schema.fromJsonString(ProjectionProject.fields.defaultModelSelection), + ), + scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), + }), +); const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( Struct.assign({ isStreaming: Schema.Number, @@ -63,22 +58,11 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( }), ); const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; -const ProjectionThreadDbRowSchema = Schema.Struct({ - threadId: ProjectionThread.fields.threadId, - projectId: ProjectionThread.fields.projectId, - title: ProjectionThread.fields.title, - provider: Schema.Literals(["codex", "claudeAgent"]), - model: Schema.String, - modelOptions: Schema.NullOr(Schema.fromJsonString(Schema.Unknown)), - runtimeMode: ProjectionThread.fields.runtimeMode, - interactionMode: ProjectionThread.fields.interactionMode, - branch: ProjectionThread.fields.branch, - worktreePath: ProjectionThread.fields.worktreePath, - latestTurnId: ProjectionThread.fields.latestTurnId, - createdAt: ProjectionThread.fields.createdAt, - updatedAt: ProjectionThread.fields.updatedAt, - deletedAt: ProjectionThread.fields.deletedAt, -}); +const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( + Struct.assign({ + modelSelection: Schema.fromJsonString(ProjectionThread.fields.modelSelection), + }), +); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( Struct.assign({ payload: Schema.fromJsonString(Schema.Unknown), @@ -103,7 +87,6 @@ const ProjectionLatestTurnDbRowSchema = Schema.Struct({ sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), }); const ProjectionStateDbRowSchema = ProjectionState; -const decodeModelSelectionSchema = Schema.decodeUnknownEffect(ModelSelection); const REQUIRED_SNAPSHOT_PROJECTORS = [ ORCHESTRATION_PROJECTOR_NAMES.projects, @@ -153,19 +136,6 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st : toPersistenceSqlError(sqlOperation)(cause); } -const decodeModelSelection = (input: { - readonly provider: "codex" | "claudeAgent"; - readonly model: string; - readonly options: unknown; -}) => - decodeModelSelectionSchema({ - provider: input.provider, - model: input.model, - ...(input.options !== null && input.options !== undefined ? { options: input.options } : {}), - }).pipe( - Effect.mapError(toPersistenceDecodeError("ProjectionSnapshotQuery.decodeModelSelection")), - ); - const makeProjectionSnapshotQuery = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -178,9 +148,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { project_id AS "projectId", title, workspace_root AS "workspaceRoot", - default_provider AS "defaultProvider", - default_model AS "defaultModel", - default_model_options_json AS "defaultModelOptions", + default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -199,9 +167,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, - provider, - model, - model_options_json AS "modelOptions", + model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", branch, @@ -574,62 +540,41 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const projects: ReadonlyArray = yield* Effect.forEach( projectRows, - (row) => { - const defaultModelSelectionEffect: Effect.Effect< - OrchestrationProject["defaultModelSelection"], - ProjectionRepositoryError - > = - row.defaultProvider === null || row.defaultModel === null - ? Effect.succeed(null) - : decodeModelSelection({ - provider: row.defaultProvider, - model: row.defaultModel, - options: row.defaultModelOptions, - }); - - return defaultModelSelectionEffect.pipe( - Effect.map((defaultModelSelection) => ({ - id: row.projectId, - title: row.title, - workspaceRoot: row.workspaceRoot, - defaultModelSelection, - scripts: row.scripts, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - })), - ); - }, + (row) => + Effect.succeed({ + id: row.projectId, + title: row.title, + workspaceRoot: row.workspaceRoot, + defaultModelSelection: row.defaultModelSelection, + scripts: row.scripts, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + }), ); const threads: ReadonlyArray = yield* Effect.forEach( threadRows, (row) => - decodeModelSelection({ - provider: row.provider, - model: row.model, - options: row.modelOptions, - }).pipe( - Effect.map((modelSelection) => ({ - id: row.threadId, - projectId: row.projectId, - title: row.title, - modelSelection, - runtimeMode: row.runtimeMode, - interactionMode: row.interactionMode, - branch: row.branch, - worktreePath: row.worktreePath, - latestTurn: latestTurnByThread.get(row.threadId) ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - messages: messagesByThread.get(row.threadId) ?? [], - proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], - activities: activitiesByThread.get(row.threadId) ?? [], - checkpoints: checkpointsByThread.get(row.threadId) ?? [], - session: sessionsByThread.get(row.threadId) ?? null, - })), - ), + Effect.succeed({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + messages: messagesByThread.get(row.threadId) ?? [], + proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], + activities: activitiesByThread.get(row.threadId) ?? [], + checkpoints: checkpointsByThread.get(row.threadId) ?? [], + session: sessionsByThread.get(row.threadId) ?? null, + }), ); const snapshot = { diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index 3f7d566425..d45b5cf951 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -1,13 +1,9 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Option, Schema } from "effect"; +import { Effect, Layer, Option, Schema, Struct } from "effect"; import { ModelSelection, ProjectScript } from "@t3tools/contracts"; -import { - PersistenceDecodeError, - toPersistenceDecodeError, - toPersistenceSqlError, -} from "../Errors.ts"; +import { toPersistenceSqlError } from "../Errors.ts"; import { DeleteProjectionProjectInput, GetProjectionProjectInput, @@ -16,42 +12,14 @@ import { type ProjectionProjectRepositoryShape, } from "../Services/ProjectionProjects.ts"; -const ProjectionProjectDbRow = Schema.Struct({ - projectId: ProjectionProject.fields.projectId, - title: ProjectionProject.fields.title, - workspaceRoot: ProjectionProject.fields.workspaceRoot, - defaultProvider: Schema.NullOr(Schema.Literals(["codex", "claudeAgent"])), - defaultModel: Schema.NullOr(Schema.String), - defaultModelOptions: Schema.NullOr(Schema.fromJsonString(Schema.Unknown)), - scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), - createdAt: ProjectionProject.fields.createdAt, - updatedAt: ProjectionProject.fields.updatedAt, - deletedAt: ProjectionProject.fields.deletedAt, -}); +const ProjectionProjectDbRow = ProjectionProject.mapFields( + Struct.assign({ + defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), + scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), + }), +); type ProjectionProjectDbRow = typeof ProjectionProjectDbRow.Type; -const decodeModelSelectionSync = Schema.decodeUnknownSync(ModelSelection); - -function decodeDefaultModelSelection( - row: ProjectionProjectDbRow, -): Effect.Effect { - if (row.defaultProvider === null || row.defaultModel === null) { - return Effect.succeed(null); - } - return Effect.try({ - try: () => - decodeModelSelectionSync({ - provider: row.defaultProvider, - model: row.defaultModel, - ...(row.defaultModelOptions !== null ? { options: row.defaultModelOptions } : {}), - }), - catch: (error) => - toPersistenceDecodeError("ProjectionProjectRepository.decodeDefaultModelSelection")( - error as Schema.SchemaError, - ), - }); -} - const makeProjectionProjectRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -63,9 +31,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { project_id, title, workspace_root, - default_provider, - default_model, - default_model_options_json, + default_model_selection_json, scripts_json, created_at, updated_at, @@ -75,13 +41,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.projectId}, ${row.title}, ${row.workspaceRoot}, - ${row.defaultModelSelection?.provider ?? null}, - ${row.defaultModelSelection?.model ?? null}, - ${ - row.defaultModelSelection?.options != null - ? JSON.stringify(row.defaultModelSelection.options) - : null - }, + ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, ${JSON.stringify(row.scripts)}, ${row.createdAt}, ${row.updatedAt}, @@ -91,9 +51,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { DO UPDATE SET title = excluded.title, workspace_root = excluded.workspace_root, - default_provider = excluded.default_provider, - default_model = excluded.default_model, - default_model_options_json = excluded.default_model_options_json, + default_model_selection_json = excluded.default_model_selection_json, scripts_json = excluded.scripts_json, created_at = excluded.created_at, updated_at = excluded.updated_at, @@ -110,9 +68,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { project_id AS "projectId", title, workspace_root AS "workspaceRoot", - default_provider AS "defaultProvider", - default_model AS "defaultModel", - default_model_options_json AS "defaultModelOptions", + default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -131,9 +87,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { project_id AS "projectId", title, workspace_root AS "workspaceRoot", - default_provider AS "defaultProvider", - default_model AS "defaultModel", - default_model_options_json AS "defaultModelOptions", + default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -160,46 +114,34 @@ const makeProjectionProjectRepository = Effect.gen(function* () { const getById: ProjectionProjectRepositoryShape["getById"] = (input) => getProjectionProjectRow(input).pipe( Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.getById:query")), - Effect.flatMap( - Option.match({ - onNone: () => Effect.succeed(Option.none()), - onSome: (row) => - decodeDefaultModelSelection(row).pipe( - Effect.map((defaultModelSelection) => - Option.some({ - projectId: row.projectId, - title: row.title, - workspaceRoot: row.workspaceRoot, - defaultModelSelection, - scripts: row.scripts, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - }), - ), - ), - }), + Effect.map( + Option.map((row) => ({ + projectId: row.projectId, + title: row.title, + workspaceRoot: row.workspaceRoot, + defaultModelSelection: row.defaultModelSelection, + scripts: row.scripts, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + })), ), ); const listAll: ProjectionProjectRepositoryShape["listAll"] = () => listProjectionProjectRows().pipe( Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.listAll:query")), - Effect.flatMap((rows) => - Effect.forEach(rows, (row) => - decodeDefaultModelSelection(row).pipe( - Effect.map((defaultModelSelection) => ({ - projectId: row.projectId, - title: row.title, - workspaceRoot: row.workspaceRoot, - defaultModelSelection, - scripts: row.scripts, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - })), - ), - ), + Effect.map((rows) => + rows.map((row) => ({ + projectId: row.projectId, + title: row.title, + workspaceRoot: row.workspaceRoot, + defaultModelSelection: row.defaultModelSelection, + scripts: row.scripts, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + })), ), ); diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index c65f2e9455..b44846d136 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -38,9 +38,9 @@ projectionRepositoriesLayer("Projection repositories", (it) => { }); const rows = yield* sql<{ - readonly defaultModelOptions: string | null; + readonly defaultModelSelection: string | null; }>` - SELECT default_model_options_json AS "defaultModelOptions" + SELECT default_model_selection_json AS "defaultModelSelection" FROM projection_projects WHERE project_id = 'project-null-options' `; @@ -49,7 +49,13 @@ projectionRepositoriesLayer("Projection repositories", (it) => { return yield* Effect.fail(new Error("Expected projection_projects row to exist.")); } - assert.strictEqual(row.defaultModelOptions, null); + assert.strictEqual( + row.defaultModelSelection, + JSON.stringify({ + provider: "codex", + model: "gpt-5.4", + }), + ); const persisted = yield* projects.getById({ projectId: ProjectId.makeUnsafe("project-null-options"), @@ -85,9 +91,9 @@ projectionRepositoriesLayer("Projection repositories", (it) => { }); const rows = yield* sql<{ - readonly modelOptions: string | null; + readonly modelSelection: string | null; }>` - SELECT model_options_json AS "modelOptions" + SELECT model_selection_json AS "modelSelection" FROM projection_threads WHERE thread_id = 'thread-null-options' `; @@ -96,7 +102,13 @@ projectionRepositoriesLayer("Projection repositories", (it) => { return yield* Effect.fail(new Error("Expected projection_threads row to exist.")); } - assert.strictEqual(row.modelOptions, null); + assert.strictEqual( + row.modelSelection, + JSON.stringify({ + provider: "claudeAgent", + model: "claude-opus-4-6", + }), + ); const persisted = yield* threads.getById({ threadId: ThreadId.makeUnsafe("thread-null-options"), diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 6b379aa0e3..abda27a560 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -1,9 +1,8 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Option, Schema } from "effect"; +import { Effect, Layer, Option, Schema, Struct } from "effect"; -import { ModelSelection } from "@t3tools/contracts"; -import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; +import { toPersistenceSqlError } from "../Errors.ts"; import { DeleteProjectionThreadInput, GetProjectionThreadInput, @@ -13,53 +12,13 @@ import { type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; -const ProjectionThreadDbRow = Schema.Struct({ - threadId: ProjectionThread.fields.threadId, - projectId: ProjectionThread.fields.projectId, - title: ProjectionThread.fields.title, - provider: Schema.Literals(["codex", "claudeAgent"]), - model: Schema.String, - modelOptions: Schema.NullOr(Schema.fromJsonString(Schema.Unknown)), - runtimeMode: ProjectionThread.fields.runtimeMode, - interactionMode: ProjectionThread.fields.interactionMode, - branch: ProjectionThread.fields.branch, - worktreePath: ProjectionThread.fields.worktreePath, - latestTurnId: ProjectionThread.fields.latestTurnId, - createdAt: ProjectionThread.fields.createdAt, - updatedAt: ProjectionThread.fields.updatedAt, - deletedAt: ProjectionThread.fields.deletedAt, -}); +const ProjectionThreadDbRow = ProjectionThread.mapFields( + Struct.assign({ + modelSelection: Schema.fromJsonString(ProjectionThread.fields.modelSelection), + }), +); type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; -const decodeModelSelectionSync = Schema.decodeUnknownSync(ModelSelection); - -function decodeProjectionThread(row: ProjectionThreadDbRow) { - return Effect.try({ - try: () => ({ - threadId: row.threadId, - projectId: row.projectId, - title: row.title, - modelSelection: decodeModelSelectionSync({ - provider: row.provider, - model: row.model, - ...(row.modelOptions !== null ? { options: row.modelOptions } : {}), - }), - runtimeMode: row.runtimeMode, - interactionMode: row.interactionMode, - branch: row.branch, - worktreePath: row.worktreePath, - latestTurnId: row.latestTurnId, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - }), - catch: (error) => - toPersistenceDecodeError("ProjectionThreadRepository.decodeProjectionThread")( - error as Schema.SchemaError, - ), - }); -} - const makeProjectionThreadRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -71,9 +30,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id, project_id, title, - provider, - model, - model_options_json, + model_selection_json, runtime_mode, interaction_mode, branch, @@ -87,9 +44,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.threadId}, ${row.projectId}, ${row.title}, - ${row.modelSelection.provider}, - ${row.modelSelection.model}, - ${row.modelSelection.options != null ? JSON.stringify(row.modelSelection.options) : null}, + ${JSON.stringify(row.modelSelection)}, ${row.runtimeMode}, ${row.interactionMode}, ${row.branch}, @@ -103,9 +58,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { DO UPDATE SET project_id = excluded.project_id, title = excluded.title, - provider = excluded.provider, - model = excluded.model, - model_options_json = excluded.model_options_json, + model_selection_json = excluded.model_selection_json, runtime_mode = excluded.runtime_mode, interaction_mode = excluded.interaction_mode, branch = excluded.branch, @@ -126,9 +79,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, - provider, - model, - model_options_json AS "modelOptions", + model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", branch, @@ -151,9 +102,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, - provider, - model, - model_options_json AS "modelOptions", + model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", branch, @@ -185,18 +134,13 @@ const makeProjectionThreadRepository = Effect.gen(function* () { const getById: ProjectionThreadRepositoryShape["getById"] = (input) => getProjectionThreadRow(input).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.getById:query")), - Effect.flatMap( - Option.match({ - onNone: () => Effect.succeed(Option.none()), - onSome: (row) => decodeProjectionThread(row).pipe(Effect.map(Option.some)), - }), - ), + Effect.map(Option.map((row) => row)), ); const listByProjectId: ProjectionThreadRepositoryShape["listByProjectId"] = (input) => listProjectionThreadRows(input).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.listByProjectId:query")), - Effect.flatMap((rows) => Effect.forEach(rows, decodeProjectionThread)), + Effect.map((rows) => rows), ); const deleteById: ProjectionThreadRepositoryShape["deleteById"] = (input) => diff --git a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts index d7fda7c6d9..954e4f014c 100644 --- a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts +++ b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts @@ -152,37 +152,53 @@ layer("016_CanonicalizeModelSelections", (it) => { { const projectRows = yield* sql<{ readonly projectId: string; - readonly defaultProvider: string | null; + readonly defaultModelSelection: string | null; }>` SELECT project_id AS "projectId", - default_provider AS "defaultProvider" + default_model_selection_json AS "defaultModelSelection" FROM projection_projects ORDER BY project_id `; assert.deepStrictEqual(projectRows, [ - { projectId: "project-claude", defaultProvider: "claudeAgent" }, - { projectId: "project-codex", defaultProvider: "codex" }, - { projectId: "project-null", defaultProvider: null }, + { + projectId: "project-claude", + defaultModelSelection: '{"provider":"claudeAgent","model":"claude-opus-4-6"}', + }, + { + projectId: "project-codex", + defaultModelSelection: '{"provider":"codex","model":"gpt-5.4"}', + }, + { projectId: "project-null", defaultModelSelection: null }, ]); const threadRows = yield* sql<{ readonly threadId: string; - readonly provider: string | null; - readonly modelOptions: string | null; + readonly modelSelection: string | null; }>` SELECT thread_id AS "threadId", - provider, - model_options_json AS "modelOptions" + model_selection_json AS "modelSelection" FROM projection_threads ORDER BY thread_id `; assert.deepStrictEqual(threadRows, [ - { threadId: "thread-claude", provider: "claudeAgent", modelOptions: null }, - { threadId: "thread-codex", provider: "codex", modelOptions: null }, - { threadId: "thread-legacy-options", provider: "claudeAgent", modelOptions: null }, - { threadId: "thread-session", provider: "claudeAgent", modelOptions: null }, + { + threadId: "thread-claude", + modelSelection: '{"provider":"claudeAgent","model":"claude-opus-4-6"}', + }, + { + threadId: "thread-codex", + modelSelection: '{"provider":"codex","model":"gpt-5.4"}', + }, + { + threadId: "thread-legacy-options", + modelSelection: '{"provider":"claudeAgent","model":"claude-opus-4-6"}', + }, + { + threadId: "thread-session", + modelSelection: '{"provider":"claudeAgent","model":"gpt-5.4"}', + }, ]); const eventRows = yield* sql<{ diff --git a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.ts b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.ts index bdffa9bd6b..8154bfde9b 100644 --- a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.ts +++ b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.ts @@ -6,84 +6,61 @@ export default Effect.gen(function* () { yield* sql` ALTER TABLE projection_projects - ADD COLUMN default_provider TEXT + ADD COLUMN default_model_selection_json TEXT `; yield* sql` UPDATE projection_projects - SET default_provider = CASE + SET default_model_selection_json = CASE WHEN default_model IS NULL THEN NULL - WHEN lower(default_model) LIKE '%claude%' THEN 'claudeAgent' - ELSE 'codex' + ELSE json_object( + 'provider', + CASE + WHEN lower(default_model) LIKE '%claude%' THEN 'claudeAgent' + ELSE 'codex' + END, + 'model', + default_model + ) END - WHERE default_provider IS NULL + WHERE default_model_selection_json IS NULL `; yield* sql` ALTER TABLE projection_threads - ADD COLUMN provider TEXT + ADD COLUMN model_selection_json TEXT `; yield* sql` UPDATE projection_threads - SET provider = COALESCE( - ( - SELECT provider_name - FROM projection_thread_sessions - WHERE projection_thread_sessions.thread_id = projection_threads.thread_id + SET model_selection_json = json_object( + 'provider', + COALESCE( + ( + SELECT provider_name + FROM projection_thread_sessions + WHERE projection_thread_sessions.thread_id = projection_threads.thread_id + ), + CASE + WHEN lower(model) LIKE '%claude%' THEN 'claudeAgent' + ELSE 'codex' + END, + 'codex' ), - CASE - WHEN lower(model) LIKE '%claude%' THEN 'claudeAgent' - ELSE 'codex' - END, - 'codex' + 'model', + model ) - WHERE provider IS NULL + WHERE model_selection_json IS NULL `; yield* sql` ALTER TABLE projection_projects - ADD COLUMN default_model_options_json TEXT + DROP COLUMN default_model `; yield* sql` ALTER TABLE projection_threads - ADD COLUMN model_options_json TEXT - `; - - yield* sql` - UPDATE projection_projects - SET - default_model_options_json = CASE - WHEN default_model_options_json IS NULL THEN NULL - WHEN json_valid(default_model_options_json) = 0 THEN default_model_options_json - WHEN json_type(default_model_options_json, '$.codex') IS NOT NULL - OR json_type(default_model_options_json, '$.claudeAgent') IS NOT NULL - THEN CASE - WHEN default_provider = 'claudeAgent' - THEN json_extract(default_model_options_json, '$.claudeAgent') - ELSE json_extract(default_model_options_json, '$.codex') - END - ELSE default_model_options_json - END - WHERE default_model IS NOT NULL - `; - - yield* sql` - UPDATE projection_threads - SET - model_options_json = CASE - WHEN model_options_json IS NULL THEN NULL - WHEN json_valid(model_options_json) = 0 THEN model_options_json - WHEN json_type(model_options_json, '$.codex') IS NOT NULL - OR json_type(model_options_json, '$.claudeAgent') IS NOT NULL - THEN CASE - WHEN provider = 'claudeAgent' - THEN json_extract(model_options_json, '$.claudeAgent') - ELSE json_extract(model_options_json, '$.codex') - END - ELSE model_options_json - END + DROP COLUMN model `; yield* sql` From 6348bea7c3e445c0f38d31c73911a232c03917d1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 16:03:40 -0700 Subject: [PATCH 12/38] kewl --- .../src/orchestration/Layers/ProjectionSnapshotQuery.ts | 7 +++---- apps/server/src/persistence/Layers/ProjectionThreads.ts | 3 ++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 17bd6edbe1..4fe26dc366 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -17,6 +17,7 @@ import { 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"; @@ -45,9 +46,7 @@ import { const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ - defaultModelSelection: Schema.NullOr( - Schema.fromJsonString(ProjectionProject.fields.defaultModelSelection), - ), + defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }), ); @@ -60,7 +59,7 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ - modelSelection: Schema.fromJsonString(ProjectionThread.fields.modelSelection), + modelSelection: Schema.fromJsonString(ModelSelection), }), ); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index abda27a560..16270ecc7c 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -11,10 +11,11 @@ import { ProjectionThreadRepository, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; +import { ModelSelection } from "@t3tools/contracts"; const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ - modelSelection: Schema.fromJsonString(ProjectionThread.fields.modelSelection), + modelSelection: Schema.fromJsonString(ModelSelection), }), ); type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; From cc7245e770d9fa3a595312933972370f37f72ca9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 16:05:20 -0700 Subject: [PATCH 13/38] kewl --- .../orchestrationEngine.integration.test.ts | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index e3a79551d2..d6b1004749 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -11,6 +11,7 @@ import { ProjectId, ProviderKind, ThreadId, + ModelSelection, } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { Effect, Option, Schema } from "effect"; @@ -145,7 +146,7 @@ const startTurn = (input: { readonly commandId: string; readonly messageId: string; readonly text: string; - readonly provider?: IntegrationProvider; + readonly modelSelection?: ModelSelection; }) => input.harness.engine.dispatch({ type: "thread.turn.start", @@ -157,12 +158,9 @@ const startTurn = (input: { text: input.text, attachments: [], }, - ...(input.provider !== undefined + ...(input.modelSelection !== undefined ? { - modelSelection: { - provider: input.provider, - model: DEFAULT_MODEL_BY_PROVIDER[input.provider], - }, + modelSelection: input.modelSelection, } : {}), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -941,7 +939,10 @@ it.live("starts a claudeAgent session on first turn when provider is requested", commandId: "cmd-turn-start-claude-initial", messageId: "msg-user-claude-initial", text: "Use Claude", - provider: "claudeAgent", + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + }, }); const thread = yield* harness.waitForThread( @@ -995,7 +996,10 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re commandId: "cmd-turn-start-claude-recover-1", messageId: "msg-user-claude-recover-1", text: "Before restart", - provider: "claudeAgent", + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + }, }); yield* harness.waitForThread( @@ -1102,7 +1106,10 @@ it.live("forwards claudeAgent approval responses to the provider session", () => commandId: "cmd-turn-start-claude-approval", messageId: "msg-user-claude-approval", text: "Need approval", - provider: "claudeAgent", + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + }, }); const thread = yield* harness.waitForThread(THREAD_ID, (entry) => @@ -1171,7 +1178,10 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () => commandId: "cmd-turn-start-claude-interrupt", messageId: "msg-user-claude-interrupt", text: "Start long turn", - provider: "claudeAgent", + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + }, }); const thread = yield* harness.waitForThread( @@ -1241,7 +1251,10 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", commandId: "cmd-turn-start-claude-revert-1", messageId: "msg-user-claude-revert-1", text: "First Claude edit", - provider: "claudeAgent", + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + }, }); yield* harness.waitForThread( From 1040a2a6249929acf9ea07e7b00539f816816988 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 16:08:33 -0700 Subject: [PATCH 14/38] rm unnecessary effect --- .../Layers/ProjectionSnapshotQuery.ts | 68 ++++++++----------- 1 file changed, 30 insertions(+), 38 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 4fe26dc366..cc2f4f87e7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -537,44 +537,36 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); } - const projects: ReadonlyArray = yield* Effect.forEach( - projectRows, - (row) => - Effect.succeed({ - id: row.projectId, - title: row.title, - workspaceRoot: row.workspaceRoot, - defaultModelSelection: row.defaultModelSelection, - scripts: row.scripts, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - }), - ); - - const threads: ReadonlyArray = yield* Effect.forEach( - threadRows, - (row) => - Effect.succeed({ - id: row.threadId, - projectId: row.projectId, - title: row.title, - modelSelection: row.modelSelection, - runtimeMode: row.runtimeMode, - interactionMode: row.interactionMode, - branch: row.branch, - worktreePath: row.worktreePath, - latestTurn: latestTurnByThread.get(row.threadId) ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - messages: messagesByThread.get(row.threadId) ?? [], - proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], - activities: activitiesByThread.get(row.threadId) ?? [], - checkpoints: checkpointsByThread.get(row.threadId) ?? [], - session: sessionsByThread.get(row.threadId) ?? null, - }), - ); + const projects: ReadonlyArray = projectRows.map((row) => ({ + id: row.projectId, + title: row.title, + workspaceRoot: row.workspaceRoot, + defaultModelSelection: row.defaultModelSelection, + scripts: row.scripts, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + })); + + const threads: ReadonlyArray = threadRows.map((row) => ({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + messages: messagesByThread.get(row.threadId) ?? [], + proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], + activities: activitiesByThread.get(row.threadId) ?? [], + checkpoints: checkpointsByThread.get(row.threadId) ?? [], + session: sessionsByThread.get(row.threadId) ?? null, + })); const snapshot = { snapshotSequence: computeSnapshotSequence(stateRows), From 7984a23fbdf08d59ac39c943f584fb8b03575297 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 16:11:15 -0700 Subject: [PATCH 15/38] update test --- .../Layers/ProviderCommandReactor.test.ts | 55 +++++++------------ 1 file changed, 20 insertions(+), 35 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 2259d971d8..c81c4169f4 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; +import type { ModelSelection, ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; import { ApprovalRequestId, CommandId, @@ -63,9 +63,6 @@ async function waitFor( return poll(); } -const inferTestProviderForModel = (model: string): "codex" | "claudeAgent" => - model.startsWith("claude-") ? "claudeAgent" : "codex"; - describe("ProviderCommandReactor", () => { let runtime: ManagedRuntime.ManagedRuntime< OrchestrationEngineService | ProviderCommandReactor, @@ -96,38 +93,26 @@ describe("ProviderCommandReactor", () => { async function createHarness(input?: { readonly baseDir?: string; - readonly threadModel?: string; + readonly threadModelSelection?: ModelSelection; }) { const now = new Date().toISOString(); const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); createdBaseDirs.add(baseDir); const { stateDir } = deriveServerPathsSync(baseDir, undefined); createdStateDirs.add(stateDir); - const threadModel = input?.threadModel ?? "gpt-5-codex"; - const threadProvider = inferTestProviderForModel(threadModel); const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); let nextSessionIndex = 1; const runtimeSessions: Array = []; + const modelSelection = input?.threadModelSelection ?? { + provider: "codex", + model: "gpt-5-codex", + }; const startSession = vi.fn((_: unknown, input: unknown) => { const sessionIndex = nextSessionIndex++; - const provider = - typeof input === "object" && - input !== null && - "provider" in input && - (input.provider === "codex" || input.provider === "claudeAgent") - ? input.provider - : "codex"; const resumeCursor = typeof input === "object" && input !== null && "resumeCursor" in input ? input.resumeCursor : undefined; - const model = - typeof input === "object" && - input !== null && - "model" in input && - typeof input.model === "string" - ? input.model - : undefined; const threadId = typeof input === "object" && input !== null && @@ -136,7 +121,7 @@ describe("ProviderCommandReactor", () => { ? ThreadId.makeUnsafe(input.threadId) : ThreadId.makeUnsafe(`thread-${sessionIndex}`); const session: ProviderSession = { - provider, + provider: modelSelection.provider, status: "ready" as const, runtimeMode: typeof input === "object" && @@ -145,7 +130,7 @@ describe("ProviderCommandReactor", () => { (input.runtimeMode === "approval-required" || input.runtimeMode === "full-access") ? input.runtimeMode : "full-access", - ...(model !== undefined ? { model } : {}), + ...(modelSelection.model !== undefined ? { model: modelSelection.model } : {}), threadId, resumeCursor: resumeCursor ?? { opaque: `resume-${sessionIndex}` }, createdAt: now, @@ -246,10 +231,7 @@ describe("ProviderCommandReactor", () => { projectId: asProjectId("project-1"), title: "Provider Project", workspaceRoot: "/tmp/provider-project", - defaultModelSelection: { - provider: threadProvider, - model: threadModel, - }, + defaultModelSelection: modelSelection, createdAt: now, }), ); @@ -260,10 +242,7 @@ describe("ProviderCommandReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", - modelSelection: { - provider: threadProvider, - model: threadModel, - }, + modelSelection: modelSelection, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, @@ -381,7 +360,9 @@ describe("ProviderCommandReactor", () => { }); it("forwards claude effort options through session start and turn send", async () => { - const harness = await createHarness({ threadModel: "claude-sonnet-4-6" }); + const harness = await createHarness({ + threadModelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" }, + }); const now = new Date().toISOString(); await Effect.runPromise( @@ -432,7 +413,9 @@ describe("ProviderCommandReactor", () => { }); it("forwards claude fast mode options through session start and turn send", async () => { - const harness = await createHarness({ threadModel: "claude-opus-4-6" }); + const harness = await createHarness({ + threadModelSelection: { provider: "claudeAgent", model: "claude-opus-4-6" }, + }); const now = new Date().toISOString(); await Effect.runPromise( @@ -662,7 +645,9 @@ describe("ProviderCommandReactor", () => { }); it("restarts claude sessions when claude effort changes", async () => { - const harness = await createHarness({ threadModel: "claude-sonnet-4-6" }); + const harness = await createHarness({ + threadModelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" }, + }); const now = new Date().toISOString(); await Effect.runPromise( @@ -819,7 +804,7 @@ describe("ProviderCommandReactor", () => { it("does not inject derived model options when restarting claude on runtime mode changes", async () => { const harness = await createHarness({ - threadModel: "claude-opus-4-6", + threadModelSelection: { provider: "claudeAgent", model: "claude-opus-4-6" }, }); const now = new Date().toISOString(); From e0d4388c77a453e9b2b0ef767beb76b640b2d47a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 16:12:16 -0700 Subject: [PATCH 16/38] kewl --- .../src/orchestration/Layers/ProviderCommandReactor.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 299468230e..f7f7447a05 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -13,7 +13,7 @@ import { type RuntimeMode, type TurnId, } from "@t3tools/contracts"; -import { Cache, Cause, Duration, Effect, Layer, Option, Schema, Stream } from "effect"; +import { Cache, Cause, Duration, Effect, Equal, Layer, Option, Schema, Stream } from "effect"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; @@ -75,11 +75,6 @@ const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const WORKTREE_BRANCH_PREFIX = "t3code"; const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); -const sameModelSelectionOptions = ( - left: ModelSelection | undefined, - right: ModelSelection | undefined, -): boolean => JSON.stringify(left ?? null) === JSON.stringify(right ?? null); - function isUnknownPendingApprovalRequestError(cause: Cause.Cause): boolean { const error = Cause.squash(cause); if (Schema.is(ProviderAdapterRequestError)(error)) { @@ -306,7 +301,7 @@ const make = Effect.gen(function* () { const shouldRestartForModelSelectionChange = currentProvider === "claudeAgent" && requestedModelSelection !== undefined && - !sameModelSelectionOptions(previousModelSelection, requestedModelSelection); + !Equal.equals(previousModelSelection, requestedModelSelection); if ( !runtimeModeChanged && From fb0327692c8392a4495b8ca535e5cee45ea9fd77 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 16:16:01 -0700 Subject: [PATCH 17/38] explicit --- .../src/orchestration/Layers/ProviderCommandReactor.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c81c4169f4..4cfe646ec1 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -504,7 +504,9 @@ describe("ProviderCommandReactor", () => { }); it("rejects a first turn when requested provider conflicts with the thread model", async () => { - const harness = await createHarness(); + const harness = await createHarness({ + threadModelSelection: { provider: "codex", model: "gpt-5-codex" }, + }); const now = new Date().toISOString(); await Effect.runPromise( From 0a1e9b0d98f2deac139ad93e727bed1a09fdeb9a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 16:16:15 -0700 Subject: [PATCH 18/38] rm unneceesary --- .../Layers/ProviderCommandReactor.test.ts | 43 ------------------- 1 file changed, 43 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 4cfe646ec1..20515a4eda 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -557,49 +557,6 @@ describe("ProviderCommandReactor", () => { }); }); - it("allows custom model strings on the thread's explicit provider", async () => { - const harness = await createHarness(); - const now = new Date().toISOString(); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-custom-model"), - threadId: ThreadId.makeUnsafe("thread-1"), - message: { - messageId: asMessageId("user-message-custom-model"), - role: "user", - text: "hello", - attachments: [], - }, - modelSelection: { - provider: "codex", - model: "claude-sonnet-4-6", - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(() => harness.startSession.mock.calls.length === 1); - await waitFor(() => harness.sendTurn.mock.calls.length === 1); - - expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - modelSelection: { - provider: "codex", - model: "claude-sonnet-4-6", - }, - }); - expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ - threadId: ThreadId.makeUnsafe("thread-1"), - modelSelection: { - provider: "codex", - model: "claude-sonnet-4-6", - }, - }); - }); - it("reuses the same provider session when runtime mode is unchanged", async () => { const harness = await createHarness(); const now = new Date().toISOString(); From 23dfe7d0a0be531e0fd0fe32a3148ca734fa97cb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 16:25:49 -0700 Subject: [PATCH 19/38] Remove legacy orchestration payload coercions - Simplify orchestration contracts to canonical model-selection shapes - Add a Cursor ACP probe for listing available models --- packages/contracts/src/orchestration.test.ts | 85 ----- packages/contracts/src/orchestration.ts | 352 +------------------ scripts/cursor-acp-models-probe.ts | 206 +++++++++++ scripts/package.json | 1 + 4 files changed, 213 insertions(+), 431 deletions(-) create mode 100644 scripts/cursor-acp-models-probe.ts diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 7bf2274687..19cef5a392 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -116,24 +116,6 @@ it.effect("decodes historical project.created payloads with a default provider", }), ); -it.effect("decodes legacy project.created payloads into defaultModelSelection", () => - Effect.gen(function* () { - const parsed = yield* decodeProjectCreatedPayload({ - projectId: "project-legacy-1", - title: "Project Title", - workspaceRoot: "/tmp/workspace", - defaultModel: "gpt-5.4", - scripts: [], - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }); - assert.deepStrictEqual(parsed.defaultModelSelection, { - provider: "codex", - model: "gpt-5.4", - }); - }), -); - it.effect("decodes project.meta-updated payloads with explicit default provider", () => Effect.gen(function* () { const parsed = yield* decodeProjectMetaUpdatedPayload({ @@ -148,21 +130,6 @@ it.effect("decodes project.meta-updated payloads with explicit default provider" }), ); -it.effect("decodes legacy project.meta-updated payloads into defaultModelSelection", () => - Effect.gen(function* () { - const parsed = yield* decodeProjectMetaUpdatedPayload({ - projectId: "project-legacy-2", - defaultProvider: "claudeAgent", - defaultModel: "claude-opus-4-6", - updatedAt: "2026-01-01T00:00:00.000Z", - }); - assert.deepStrictEqual(parsed.defaultModelSelection, { - provider: "claudeAgent", - model: "claude-opus-4-6", - }); - }), -); - it.effect("rejects command fields that become empty after trim", () => Effect.gen(function* () { const result = yield* Effect.exit( @@ -246,29 +213,6 @@ it.effect("decodes thread.created runtime mode for historical events", () => }), ); -it.effect("decodes legacy thread.created payloads into modelSelection", () => - Effect.gen(function* () { - const parsed = yield* decodeThreadCreatedPayload({ - threadId: "thread-legacy-1", - projectId: "project-1", - title: "Thread title", - model: "claude-opus-4-6", - provider: "claudeAgent", - interactionMode: "default", - branch: null, - worktreePath: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }); - - assert.deepStrictEqual(parsed.modelSelection, { - provider: "claudeAgent", - model: "claude-opus-4-6", - }); - assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); - }), -); - it.effect("decodes thread.meta-updated payloads with explicit provider", () => Effect.gen(function* () { const parsed = yield* decodeThreadMetaUpdatedPayload({ @@ -283,20 +227,6 @@ it.effect("decodes thread.meta-updated payloads with explicit provider", () => }), ); -it.effect("decodes legacy thread.meta-updated payloads into modelSelection", () => - Effect.gen(function* () { - const parsed = yield* decodeThreadMetaUpdatedPayload({ - threadId: "thread-legacy-2", - model: "gpt-5.4", - updatedAt: "2026-01-01T00:00:00.000Z", - }); - assert.deepStrictEqual(parsed.modelSelection, { - provider: "codex", - model: "gpt-5.4", - }); - }), -); - it.effect("accepts provider-scoped model options in thread.turn.start", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartCommand({ @@ -384,21 +314,6 @@ it.effect("decodes thread.turn-start-requested source proposed plan metadata whe }), ); -it.effect("decodes legacy thread.turn-start-requested payloads into modelSelection", () => - Effect.gen(function* () { - const parsed = yield* decodeThreadTurnStartRequestedPayload({ - threadId: "thread-legacy-3", - messageId: "msg-legacy-3", - model: "gpt-5.4", - createdAt: "2026-01-01T00:00:00.000Z", - }); - assert.deepStrictEqual(parsed.modelSelection, { - provider: "codex", - model: "gpt-5.4", - }); - }), -); - it.effect("decodes latest turn source proposed plan metadata when present", () => Effect.gen(function* () { const parsed = yield* decodeOrchestrationLatestTurn({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index c1cc1fcf02..333d5ca1eb 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,4 +1,4 @@ -import { Effect, Option, Schema, SchemaIssue, SchemaTransformation, Struct } from "effect"; +import { Option, Schema, SchemaIssue, Struct } from "effect"; import { ClaudeModelOptions, CodexModelOptions } from "./model"; import { ApprovalRequestId, @@ -60,96 +60,6 @@ export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]); export type ModelSelection = typeof ModelSelection.Type; -const decodeModelSelectionSync = Schema.decodeUnknownSync(ModelSelection); - -const LegacyDefaultModelSelectionFields = { - defaultProvider: Schema.optional(ProviderKind), - defaultModel: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), - defaultModelOptions: Schema.optional(Schema.NullOr(Schema.Unknown)), -} as const; - -const LegacyThreadModelSelectionFields = { - provider: Schema.optional(ProviderKind), - model: Schema.optional(TrimmedNonEmptyString), - modelOptions: Schema.optional(Schema.NullOr(Schema.Unknown)), -} as const; - -function invalidLegacyModelSelectionIssue( - value: unknown, - title: string, - cause: unknown, -): SchemaIssue.InvalidValue { - return new SchemaIssue.InvalidValue(Option.some(value), { - title, - ...(Schema.isSchemaError(cause) - ? { description: SchemaIssue.makeFormatterDefault()(cause.issue) } - : {}), - }); -} - -type LegacyDefaultModelSelectionInput = { - readonly defaultModelSelection?: ModelSelection | null | undefined; - readonly defaultProvider?: ProviderKind | undefined; - readonly defaultModel?: string | null | undefined; - readonly defaultModelOptions?: unknown | null | undefined; -}; - -type LegacyThreadModelSelectionInput = { - readonly modelSelection?: ModelSelection | undefined; - readonly provider?: ProviderKind | undefined; - readonly model?: string | undefined; - readonly modelOptions?: unknown | null | undefined; -}; - -function normalizeLegacyDefaultModelSelection( - input: LegacyDefaultModelSelectionInput, -): Effect.Effect { - if (input.defaultModelSelection !== undefined) { - return Effect.succeed(input.defaultModelSelection); - } - if (input.defaultModel === undefined || input.defaultModel === null) { - return Effect.succeed(null); - } - return Effect.try({ - try: () => - decodeModelSelectionSync({ - provider: input.defaultProvider ?? DEFAULT_PROVIDER_KIND, - model: input.defaultModel, - ...(input.defaultModelOptions != null ? { options: input.defaultModelOptions } : {}), - }), - catch: (cause) => - invalidLegacyModelSelectionIssue( - input, - "Invalid legacy default model selection payload", - cause, - ), - }); -} - -function normalizeLegacyThreadModelSelection( - input: LegacyThreadModelSelectionInput, -): Effect.Effect { - if (input.modelSelection !== undefined) { - return Effect.succeed(input.modelSelection); - } - if (input.model === undefined) { - return Effect.succeed(undefined); - } - return Effect.try({ - try: () => - decodeModelSelectionSync({ - provider: input.provider ?? DEFAULT_PROVIDER_KIND, - model: input.model, - ...(input.modelOptions != null ? { options: input.modelOptions } : {}), - }), - catch: (cause) => - invalidLegacyModelSelectionIssue( - input, - "Invalid legacy thread model selection payload", - cause, - ), - }); -} export const CodexProviderStartOptions = Schema.Struct({ binaryPath: Schema.optional(TrimmedNonEmptyString), @@ -709,7 +619,7 @@ export const OrchestrationAggregateKind = Schema.Literals(["project", "thread"]) export type OrchestrationAggregateKind = typeof OrchestrationAggregateKind.Type; export const OrchestrationActorKind = Schema.Literals(["client", "server", "provider"]); -const ProjectCreatedPayloadCanonical = Schema.Struct({ +export const ProjectCreatedPayload = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, @@ -718,109 +628,22 @@ const ProjectCreatedPayloadCanonical = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, }); -const ProjectCreatedPayloadSource = Schema.Struct({ - projectId: ProjectId, - title: TrimmedNonEmptyString, - workspaceRoot: TrimmedNonEmptyString, - defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), - ...LegacyDefaultModelSelectionFields, - scripts: Schema.Array(ProjectScript), - createdAt: IsoDateTime, - updatedAt: IsoDateTime, -}); -type ProjectCreatedPayloadSource = typeof ProjectCreatedPayloadSource.Type; -export const ProjectCreatedPayload = ProjectCreatedPayloadSource.pipe( - Schema.decodeTo( - Schema.toType(ProjectCreatedPayloadCanonical), - SchemaTransformation.transformOrFail({ - decode: (input) => - normalizeLegacyDefaultModelSelection(input).pipe( - Effect.map((defaultModelSelection) => ({ - projectId: input.projectId, - title: input.title, - workspaceRoot: input.workspaceRoot, - defaultModelSelection, - scripts: input.scripts, - createdAt: input.createdAt, - updatedAt: input.updatedAt, - })), - ), - encode: (canonical) => - Effect.succeed({ - projectId: canonical.projectId, - title: canonical.title, - workspaceRoot: canonical.workspaceRoot, - defaultModelSelection: canonical.defaultModelSelection, - scripts: canonical.scripts, - createdAt: canonical.createdAt, - updatedAt: canonical.updatedAt, - } as ProjectCreatedPayloadSource), - }), - ), -); -const ProjectMetaUpdatedPayloadCanonical = Schema.Struct({ - projectId: ProjectId, - title: Schema.optional(TrimmedNonEmptyString), - workspaceRoot: Schema.optional(TrimmedNonEmptyString), - defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), - scripts: Schema.optional(Schema.Array(ProjectScript)), - updatedAt: IsoDateTime, -}); -type ProjectMetaUpdatedPayloadCanonical = typeof ProjectMetaUpdatedPayloadCanonical.Type; -const ProjectMetaUpdatedPayloadSource = Schema.Struct({ +export const ProjectMetaUpdatedPayload = Schema.Struct({ projectId: ProjectId, title: Schema.optional(TrimmedNonEmptyString), workspaceRoot: Schema.optional(TrimmedNonEmptyString), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), - ...LegacyDefaultModelSelectionFields, scripts: Schema.optional(Schema.Array(ProjectScript)), updatedAt: IsoDateTime, }); -type ProjectMetaUpdatedPayloadSource = typeof ProjectMetaUpdatedPayloadSource.Type; -export const ProjectMetaUpdatedPayload = ProjectMetaUpdatedPayloadSource.pipe( - Schema.decodeTo( - Schema.toType(ProjectMetaUpdatedPayloadCanonical), - SchemaTransformation.transformOrFail({ - decode: (input: ProjectMetaUpdatedPayloadSource) => - normalizeLegacyDefaultModelSelection(input).pipe( - Effect.map((defaultModelSelection) => ({ - projectId: input.projectId, - ...(input.title !== undefined ? { title: input.title } : {}), - ...(input.workspaceRoot !== undefined ? { workspaceRoot: input.workspaceRoot } : {}), - ...(input.defaultModelSelection !== undefined || - input.defaultModel !== undefined || - input.defaultProvider !== undefined || - input.defaultModelOptions !== undefined - ? { defaultModelSelection } - : {}), - ...(input.scripts !== undefined ? { scripts: input.scripts } : {}), - updatedAt: input.updatedAt, - })), - ), - encode: (canonical: ProjectMetaUpdatedPayloadCanonical) => - Effect.succeed({ - projectId: canonical.projectId, - ...(canonical.title !== undefined ? { title: canonical.title } : {}), - ...(canonical.workspaceRoot !== undefined - ? { workspaceRoot: canonical.workspaceRoot } - : {}), - ...(canonical.defaultModelSelection !== undefined - ? { defaultModelSelection: canonical.defaultModelSelection } - : {}), - ...(canonical.scripts !== undefined ? { scripts: canonical.scripts } : {}), - updatedAt: canonical.updatedAt, - } as ProjectMetaUpdatedPayloadSource), - } as any), - ), -); export const ProjectDeletedPayload = Schema.Struct({ projectId: ProjectId, deletedAt: IsoDateTime, }); -const ThreadCreatedPayloadCanonical = Schema.Struct({ +export const ThreadCreatedPayload = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, @@ -834,119 +657,20 @@ const ThreadCreatedPayloadCanonical = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, }); -const ThreadCreatedPayloadSource = Schema.Struct({ - threadId: ThreadId, - projectId: ProjectId, - title: TrimmedNonEmptyString, - modelSelection: Schema.optional(ModelSelection), - ...LegacyThreadModelSelectionFields, - runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), - interactionMode: ProviderInteractionMode.pipe( - Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), - ), - branch: Schema.NullOr(TrimmedNonEmptyString), - worktreePath: Schema.NullOr(TrimmedNonEmptyString), - createdAt: IsoDateTime, - updatedAt: IsoDateTime, -}); -type ThreadCreatedPayloadSource = typeof ThreadCreatedPayloadSource.Type; -export const ThreadCreatedPayload = ThreadCreatedPayloadSource.pipe( - Schema.decodeTo( - Schema.toType(ThreadCreatedPayloadCanonical), - SchemaTransformation.transformOrFail({ - decode: (input) => - normalizeLegacyThreadModelSelection(input).pipe( - Effect.flatMap((modelSelection) => - modelSelection === undefined - ? Effect.fail( - new SchemaIssue.InvalidValue(Option.some(input), { - title: "Legacy thread.created payload is missing a model selection", - }), - ) - : Effect.succeed({ - threadId: input.threadId, - projectId: input.projectId, - title: input.title, - modelSelection, - runtimeMode: input.runtimeMode, - interactionMode: input.interactionMode, - branch: input.branch, - worktreePath: input.worktreePath, - createdAt: input.createdAt, - updatedAt: input.updatedAt, - }), - ), - ), - encode: (canonical) => - Effect.succeed({ - threadId: canonical.threadId, - projectId: canonical.projectId, - title: canonical.title, - modelSelection: canonical.modelSelection, - runtimeMode: canonical.runtimeMode, - interactionMode: canonical.interactionMode, - branch: canonical.branch, - worktreePath: canonical.worktreePath, - createdAt: canonical.createdAt, - updatedAt: canonical.updatedAt, - } as ThreadCreatedPayloadSource), - }), - ), -); export const ThreadDeletedPayload = Schema.Struct({ threadId: ThreadId, deletedAt: IsoDateTime, }); -const ThreadMetaUpdatedPayloadCanonical = Schema.Struct({ - threadId: ThreadId, - title: Schema.optional(TrimmedNonEmptyString), - modelSelection: Schema.optional(ModelSelection), - branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), - worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), - updatedAt: IsoDateTime, -}); -type ThreadMetaUpdatedPayloadCanonical = typeof ThreadMetaUpdatedPayloadCanonical.Type; -const ThreadMetaUpdatedPayloadSource = Schema.Struct({ +export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), modelSelection: Schema.optional(ModelSelection), - ...LegacyThreadModelSelectionFields, branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), updatedAt: IsoDateTime, }); -type ThreadMetaUpdatedPayloadSource = typeof ThreadMetaUpdatedPayloadSource.Type; -export const ThreadMetaUpdatedPayload = ThreadMetaUpdatedPayloadSource.pipe( - Schema.decodeTo( - Schema.toType(ThreadMetaUpdatedPayloadCanonical), - SchemaTransformation.transformOrFail({ - decode: (input: ThreadMetaUpdatedPayloadSource) => - normalizeLegacyThreadModelSelection(input).pipe( - Effect.map((modelSelection) => ({ - threadId: input.threadId, - ...(input.title !== undefined ? { title: input.title } : {}), - ...(modelSelection !== undefined ? { modelSelection } : {}), - ...(input.branch !== undefined ? { branch: input.branch } : {}), - ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), - updatedAt: input.updatedAt, - })), - ), - encode: (canonical: ThreadMetaUpdatedPayloadCanonical) => - Effect.succeed({ - threadId: canonical.threadId, - ...(canonical.title !== undefined ? { title: canonical.title } : {}), - ...(canonical.modelSelection !== undefined - ? { modelSelection: canonical.modelSelection } - : {}), - ...(canonical.branch !== undefined ? { branch: canonical.branch } : {}), - ...(canonical.worktreePath !== undefined ? { worktreePath: canonical.worktreePath } : {}), - updatedAt: canonical.updatedAt, - } as ThreadMetaUpdatedPayloadSource), - } as any), - ), -); export const ThreadRuntimeModeSetPayload = Schema.Struct({ threadId: ThreadId, @@ -974,26 +698,10 @@ export const ThreadMessageSentPayload = Schema.Struct({ updatedAt: IsoDateTime, }); -const ThreadTurnStartRequestedPayloadCanonical = Schema.Struct({ - threadId: ThreadId, - messageId: MessageId, - modelSelection: Schema.optional(ModelSelection), - providerOptions: Schema.optional(ProviderStartOptions), - assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), - runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), - interactionMode: ProviderInteractionMode.pipe( - Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), - ), - sourceProposedPlan: Schema.optional(SourceProposedPlanReference), - createdAt: IsoDateTime, -}); -type ThreadTurnStartRequestedPayloadCanonical = - typeof ThreadTurnStartRequestedPayloadCanonical.Type; -const ThreadTurnStartRequestedPayloadSource = Schema.Struct({ +export const ThreadTurnStartRequestedPayload = Schema.Struct({ threadId: ThreadId, messageId: MessageId, modelSelection: Schema.optional(ModelSelection), - ...LegacyThreadModelSelectionFields, providerOptions: Schema.optional(ProviderStartOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), @@ -1003,54 +711,6 @@ const ThreadTurnStartRequestedPayloadSource = Schema.Struct({ sourceProposedPlan: Schema.optional(SourceProposedPlanReference), createdAt: IsoDateTime, }); -type ThreadTurnStartRequestedPayloadSource = typeof ThreadTurnStartRequestedPayloadSource.Type; -export const ThreadTurnStartRequestedPayload = ThreadTurnStartRequestedPayloadSource.pipe( - Schema.decodeTo( - Schema.toType(ThreadTurnStartRequestedPayloadCanonical), - SchemaTransformation.transformOrFail({ - decode: (input: ThreadTurnStartRequestedPayloadSource) => - normalizeLegacyThreadModelSelection(input).pipe( - Effect.map((modelSelection) => ({ - threadId: input.threadId, - messageId: input.messageId, - ...(modelSelection !== undefined ? { modelSelection } : {}), - ...(input.providerOptions !== undefined - ? { providerOptions: input.providerOptions } - : {}), - ...(input.assistantDeliveryMode !== undefined - ? { assistantDeliveryMode: input.assistantDeliveryMode } - : {}), - runtimeMode: input.runtimeMode, - interactionMode: input.interactionMode, - ...(input.sourceProposedPlan !== undefined - ? { sourceProposedPlan: input.sourceProposedPlan } - : {}), - createdAt: input.createdAt, - })), - ), - encode: (canonical: ThreadTurnStartRequestedPayloadCanonical) => - Effect.succeed({ - threadId: canonical.threadId, - messageId: canonical.messageId, - ...(canonical.modelSelection !== undefined - ? { modelSelection: canonical.modelSelection } - : {}), - ...(canonical.providerOptions !== undefined - ? { providerOptions: canonical.providerOptions } - : {}), - ...(canonical.assistantDeliveryMode !== undefined - ? { assistantDeliveryMode: canonical.assistantDeliveryMode } - : {}), - runtimeMode: canonical.runtimeMode, - interactionMode: canonical.interactionMode, - ...(canonical.sourceProposedPlan !== undefined - ? { sourceProposedPlan: canonical.sourceProposedPlan } - : {}), - createdAt: canonical.createdAt, - } as ThreadTurnStartRequestedPayloadSource), - } as any), - ), -); export const ThreadTurnInterruptRequestedPayload = Schema.Struct({ threadId: ThreadId, diff --git a/scripts/cursor-acp-models-probe.ts b/scripts/cursor-acp-models-probe.ts new file mode 100644 index 0000000000..82eb0c953f --- /dev/null +++ b/scripts/cursor-acp-models-probe.ts @@ -0,0 +1,206 @@ +/** + * Probe: connect to Cursor Agent via ACP (stdio NDJSON JSON-RPC 2.0) + * and retrieve the available model list. + * + * Usage: bun run scripts/cursor-acp-models-probe.ts + */ + +import { spawn, type Subprocess } from "bun" + +// ── JSON-RPC helpers ────────────────────────────────────────────── + +interface JsonRpcRequest { + jsonrpc: "2.0" + id: number + method: string + params: Record +} + +interface JsonRpcResponse { + jsonrpc: "2.0" + id?: number + method?: string + result?: unknown + error?: { code: number; message: string; data?: unknown } + params?: unknown +} + +// ── NDJSON reader ───────────────────────────────────────────────── + +async function* readNdjson( + stream: ReadableStream, +): AsyncGenerator { + const reader = stream.getReader() + const decoder = new TextDecoder() + let buf = "" + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + buf += decoder.decode(value, { stream: true }) + + let nl: number + while ((nl = buf.indexOf("\n")) !== -1) { + const line = buf.slice(0, nl).trim() + buf = buf.slice(nl + 1) + if (line.length === 0) continue + try { + yield JSON.parse(line) as JsonRpcResponse + } catch { + console.error("[probe] unparseable line:", line) + } + } + } + } finally { + reader.releaseLock() + } +} + +// ── Main ────────────────────────────────────────────────────────── + +const AGENT_BIN = process.env.CURSOR_AGENT ?? "cursor-agent" +const TIMEOUT_MS = 30_000 + +console.log(`[probe] spawning: ${AGENT_BIN} acp`) + +const proc: Subprocess = spawn([AGENT_BIN, "acp"], { + stdin: "pipe", + stdout: "pipe", + stderr: "inherit", +}) + +const writer = proc.stdin! +const messages = readNdjson(proc.stdout as ReadableStream) + +let nextId = 1 + +async function send(method: string, params: Record = {}) { + const msg: JsonRpcRequest = { + jsonrpc: "2.0", + id: nextId++, + method, + params, + } + const line = JSON.stringify(msg) + "\n" + writer.write(new TextEncoder().encode(line)) + writer.flush() + return msg.id +} + +async function waitForResponse( + id: number, + iter: AsyncGenerator, +): Promise { + for await (const msg of iter) { + // skip notifications (no id) + if (msg.id === id) return msg + // log interesting notifications + if (msg.method) { + console.log(`[probe] notification: ${msg.method}`) + } + } + throw new Error(`stream ended before response id=${id}`) +} + +// ── Run the probe ───────────────────────────────────────────────── + +const timeout = setTimeout(() => { + console.error("[probe] timed out after", TIMEOUT_MS, "ms") + proc.kill() + process.exit(1) +}, TIMEOUT_MS) + +try { + // 1. Initialize + console.log("[probe] → initialize") + const initId = await send("initialize", { + protocolVersion: 1, + clientInfo: { name: "t3-acp-probe", version: "0.1.0" }, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + }) + const initResp = await waitForResponse(initId, messages) + if (initResp.error) { + console.error("[probe] initialize error:", initResp.error) + process.exit(1) + } + console.log("[probe] ← initialize response:") + console.log(JSON.stringify(initResp.result, null, 2)) + + const initResult = initResp.result as Record + const agentInfo = (initResult.agentInfo ?? initResult.agent_info ?? initResult.serverInfo ?? initResult.server_info) as + | { name: string; version: string } + | undefined + if (agentInfo) { + console.log(`[probe] agent: ${agentInfo.name} v${agentInfo.version}`) + } + + // 2. Create session → models come back in the response + console.log("[probe] → session/new") + const sessionId = await send("session/new", { + cwd: process.cwd(), + mcpServers: [], + }) + const sessionResp = await waitForResponse(sessionId, messages) + if (sessionResp.error) { + console.error("[probe] session/new error:", sessionResp.error) + process.exit(1) + } + + const session = sessionResp.result as { + sessionId: string + models?: { + currentModelId: string + availableModels: Array<{ modelId: string; name: string }> + } + configOptions?: Array<{ + id: string + name: string + type: string + currentValue?: string + options?: Array<{ value: string; name: string }> + }> + } + + console.log(`[probe] ← session: ${session.sessionId}`) + + // 3. Print models from top-level models field + if (session.models) { + console.log( + `\n── models (top-level) ── current: ${session.models.currentModelId}`, + ) + for (const m of session.models.availableModels) { + console.log(` ${m.modelId.padEnd(40)} ${m.name}`) + } + } + + // 4. Print model config option (may have richer variant info) + const modelConfig = session.configOptions?.find((c) => c.id === "model") + if (modelConfig?.options) { + console.log( + `\n── models (config option) ── current: ${modelConfig.currentValue}`, + ) + for (const opt of modelConfig.options) { + console.log(` ${opt.value.padEnd(40)} ${opt.name}`) + } + } + + // 5. Print other config options for reference + const otherConfigs = session.configOptions?.filter((c) => c.id !== "model") + if (otherConfigs?.length) { + console.log("\n── other config options ──") + for (const c of otherConfigs) { + console.log(` ${c.id}: ${c.currentValue} (${c.type})`) + } + } + + // 6. Also dump raw JSON for inspection + console.log("\n── raw session response ──") + console.log(JSON.stringify(session, null, 2)) +} finally { + clearTimeout(timeout) + proc.kill() +} diff --git a/scripts/package.json b/scripts/package.json index d9db897ea2..c340743979 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -6,6 +6,7 @@ "prepare": "effect-language-service patch", "claude-fast-mode-probe": "bun run claude-fast-mode-probe.ts", "claude-haiku-thinking-probe": "bun run claude-haiku-thinking-probe.ts", + "cursor-acp-models-probe": "bun run cursor-acp-models-probe.ts", "typecheck": "tsc --noEmit", "test": "vitest run" }, From f03289e8ba9d6bdc48104ce33388ae5b0cc342c8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 16:33:00 -0700 Subject: [PATCH 20/38] rm cursor probe --- scripts/cursor-acp-models-probe.ts | 206 ----------------------------- scripts/package.json | 1 - 2 files changed, 207 deletions(-) delete mode 100644 scripts/cursor-acp-models-probe.ts diff --git a/scripts/cursor-acp-models-probe.ts b/scripts/cursor-acp-models-probe.ts deleted file mode 100644 index 82eb0c953f..0000000000 --- a/scripts/cursor-acp-models-probe.ts +++ /dev/null @@ -1,206 +0,0 @@ -/** - * Probe: connect to Cursor Agent via ACP (stdio NDJSON JSON-RPC 2.0) - * and retrieve the available model list. - * - * Usage: bun run scripts/cursor-acp-models-probe.ts - */ - -import { spawn, type Subprocess } from "bun" - -// ── JSON-RPC helpers ────────────────────────────────────────────── - -interface JsonRpcRequest { - jsonrpc: "2.0" - id: number - method: string - params: Record -} - -interface JsonRpcResponse { - jsonrpc: "2.0" - id?: number - method?: string - result?: unknown - error?: { code: number; message: string; data?: unknown } - params?: unknown -} - -// ── NDJSON reader ───────────────────────────────────────────────── - -async function* readNdjson( - stream: ReadableStream, -): AsyncGenerator { - const reader = stream.getReader() - const decoder = new TextDecoder() - let buf = "" - - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - buf += decoder.decode(value, { stream: true }) - - let nl: number - while ((nl = buf.indexOf("\n")) !== -1) { - const line = buf.slice(0, nl).trim() - buf = buf.slice(nl + 1) - if (line.length === 0) continue - try { - yield JSON.parse(line) as JsonRpcResponse - } catch { - console.error("[probe] unparseable line:", line) - } - } - } - } finally { - reader.releaseLock() - } -} - -// ── Main ────────────────────────────────────────────────────────── - -const AGENT_BIN = process.env.CURSOR_AGENT ?? "cursor-agent" -const TIMEOUT_MS = 30_000 - -console.log(`[probe] spawning: ${AGENT_BIN} acp`) - -const proc: Subprocess = spawn([AGENT_BIN, "acp"], { - stdin: "pipe", - stdout: "pipe", - stderr: "inherit", -}) - -const writer = proc.stdin! -const messages = readNdjson(proc.stdout as ReadableStream) - -let nextId = 1 - -async function send(method: string, params: Record = {}) { - const msg: JsonRpcRequest = { - jsonrpc: "2.0", - id: nextId++, - method, - params, - } - const line = JSON.stringify(msg) + "\n" - writer.write(new TextEncoder().encode(line)) - writer.flush() - return msg.id -} - -async function waitForResponse( - id: number, - iter: AsyncGenerator, -): Promise { - for await (const msg of iter) { - // skip notifications (no id) - if (msg.id === id) return msg - // log interesting notifications - if (msg.method) { - console.log(`[probe] notification: ${msg.method}`) - } - } - throw new Error(`stream ended before response id=${id}`) -} - -// ── Run the probe ───────────────────────────────────────────────── - -const timeout = setTimeout(() => { - console.error("[probe] timed out after", TIMEOUT_MS, "ms") - proc.kill() - process.exit(1) -}, TIMEOUT_MS) - -try { - // 1. Initialize - console.log("[probe] → initialize") - const initId = await send("initialize", { - protocolVersion: 1, - clientInfo: { name: "t3-acp-probe", version: "0.1.0" }, - clientCapabilities: { - fs: { readTextFile: false, writeTextFile: false }, - terminal: false, - }, - }) - const initResp = await waitForResponse(initId, messages) - if (initResp.error) { - console.error("[probe] initialize error:", initResp.error) - process.exit(1) - } - console.log("[probe] ← initialize response:") - console.log(JSON.stringify(initResp.result, null, 2)) - - const initResult = initResp.result as Record - const agentInfo = (initResult.agentInfo ?? initResult.agent_info ?? initResult.serverInfo ?? initResult.server_info) as - | { name: string; version: string } - | undefined - if (agentInfo) { - console.log(`[probe] agent: ${agentInfo.name} v${agentInfo.version}`) - } - - // 2. Create session → models come back in the response - console.log("[probe] → session/new") - const sessionId = await send("session/new", { - cwd: process.cwd(), - mcpServers: [], - }) - const sessionResp = await waitForResponse(sessionId, messages) - if (sessionResp.error) { - console.error("[probe] session/new error:", sessionResp.error) - process.exit(1) - } - - const session = sessionResp.result as { - sessionId: string - models?: { - currentModelId: string - availableModels: Array<{ modelId: string; name: string }> - } - configOptions?: Array<{ - id: string - name: string - type: string - currentValue?: string - options?: Array<{ value: string; name: string }> - }> - } - - console.log(`[probe] ← session: ${session.sessionId}`) - - // 3. Print models from top-level models field - if (session.models) { - console.log( - `\n── models (top-level) ── current: ${session.models.currentModelId}`, - ) - for (const m of session.models.availableModels) { - console.log(` ${m.modelId.padEnd(40)} ${m.name}`) - } - } - - // 4. Print model config option (may have richer variant info) - const modelConfig = session.configOptions?.find((c) => c.id === "model") - if (modelConfig?.options) { - console.log( - `\n── models (config option) ── current: ${modelConfig.currentValue}`, - ) - for (const opt of modelConfig.options) { - console.log(` ${opt.value.padEnd(40)} ${opt.name}`) - } - } - - // 5. Print other config options for reference - const otherConfigs = session.configOptions?.filter((c) => c.id !== "model") - if (otherConfigs?.length) { - console.log("\n── other config options ──") - for (const c of otherConfigs) { - console.log(` ${c.id}: ${c.currentValue} (${c.type})`) - } - } - - // 6. Also dump raw JSON for inspection - console.log("\n── raw session response ──") - console.log(JSON.stringify(session, null, 2)) -} finally { - clearTimeout(timeout) - proc.kill() -} diff --git a/scripts/package.json b/scripts/package.json index c340743979..d9db897ea2 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -6,7 +6,6 @@ "prepare": "effect-language-service patch", "claude-fast-mode-probe": "bun run claude-fast-mode-probe.ts", "claude-haiku-thinking-probe": "bun run claude-haiku-thinking-probe.ts", - "cursor-acp-models-probe": "bun run cursor-acp-models-probe.ts", "typecheck": "tsc --noEmit", "test": "vitest run" }, From ce0315c0d742324e56b7a8faa367906542afb4d4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 16:44:34 -0700 Subject: [PATCH 21/38] rm unnecessary map --- .../src/orchestration/Layers/ProviderCommandReactor.ts | 10 ++++++---- .../server/src/persistence/Layers/ProjectionThreads.ts | 2 -- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index f7f7447a05..9399bcc280 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -383,14 +383,16 @@ const make = Effect.gen(function* () { activeSession === undefined ? "in-session" : (yield* providerService.getCapabilities(activeSession.provider)).sessionModelSwitch; + const requestedModelSelection = + input.modelSelection ?? threadModelSelections.get(input.threadId) ?? thread.modelSelection; const modelForTurn = sessionModelSwitch === "unsupported" - ? input.modelSelection + ? activeSession?.model !== undefined ? { - ...input.modelSelection, - model: activeSession?.model ?? input.modelSelection.model, + ...requestedModelSelection, + model: activeSession.model, } - : undefined + : requestedModelSelection : input.modelSelection; yield* providerService.sendTurn({ diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 16270ecc7c..8868452779 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -135,13 +135,11 @@ const makeProjectionThreadRepository = Effect.gen(function* () { const getById: ProjectionThreadRepositoryShape["getById"] = (input) => getProjectionThreadRow(input).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.getById:query")), - Effect.map(Option.map((row) => row)), ); const listByProjectId: ProjectionThreadRepositoryShape["listByProjectId"] = (input) => listProjectionThreadRows(input).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.listByProjectId:query")), - Effect.map((rows) => rows), ); const deleteById: ProjectionThreadRepositoryShape["deleteById"] = (input) => From 0a812154ccc563de4813e96ec1d12a6a703f25dd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 16:45:44 -0700 Subject: [PATCH 22/38] Preserve session model when switching is unsupported - keep the active Codex model across turns when in-session switching is unavailable - adjust reactor coverage for unsupported session model switching --- .../Layers/ProviderCommandReactor.test.ts | 56 ++++++++++++++++++- .../persistence/Layers/ProjectionThreads.ts | 2 +- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 20515a4eda..b58c2522cb 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -94,6 +94,7 @@ describe("ProviderCommandReactor", () => { async function createHarness(input?: { readonly baseDir?: string; readonly threadModelSelection?: ModelSelection; + readonly sessionModelSwitch?: "unsupported" | "in-session"; }) { const now = new Date().toISOString(); const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); @@ -192,9 +193,9 @@ describe("ProviderCommandReactor", () => { respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"], stopSession: stopSession as ProviderServiceShape["stopSession"], listSessions: () => Effect.succeed(runtimeSessions), - getCapabilities: (provider) => + getCapabilities: (_provider) => Effect.succeed({ - sessionModelSwitch: provider === "codex" ? "in-session" : "in-session", + sessionModelSwitch: input?.sessionModelSwitch ?? "in-session", }), rollbackConversation: () => unsupported(), streamEvents: Stream.fromPubSub(runtimeEventPubSub), @@ -557,6 +558,57 @@ describe("ProviderCommandReactor", () => { }); }); + it("preserves the active session model when in-session model switching is unsupported", async () => { + const harness = await createHarness({ sessionModelSwitch: "unsupported" }); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-unsupported-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-unsupported-1"), + role: "user", + text: "first", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-unsupported-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-unsupported-2"), + role: "user", + text: "second", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + + expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ + threadId: ThreadId.makeUnsafe("thread-1"), + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + }); + }); + it("reuses the same provider session when runtime mode is unchanged", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 8868452779..344f199092 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -1,6 +1,6 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Option, Schema, Struct } from "effect"; +import { Effect, Layer, Schema, Struct } from "effect"; import { toPersistenceSqlError } from "../Errors.ts"; import { From 4fe00a57af787001ddd34849bc75339b42e1da97 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 17:16:24 -0700 Subject: [PATCH 23/38] Persist provider-specific composer model options - 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 --- apps/web/src/components/ChatView.browser.tsx | 19 ++ apps/web/src/components/ChatView.tsx | 10 +- .../chat/ClaudeTraitsPicker.browser.tsx | 8 + .../components/chat/ClaudeTraitsPicker.tsx | 6 +- .../chat/CodexTraitsPicker.browser.tsx | 7 + .../src/components/chat/CodexTraitsPicker.tsx | 6 +- .../CompactComposerControlsMenu.browser.tsx | 2 + .../chat/composerProviderRegistry.test.tsx | 58 +++- .../chat/composerProviderRegistry.tsx | 32 +-- apps/web/src/composerDraftStore.test.ts | 68 ++++- apps/web/src/composerDraftStore.ts | 264 +++++++++++++++--- apps/web/src/hooks/useHandleNewThread.ts | 7 +- 12 files changed, 394 insertions(+), 93 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 4f6933a0f6..13671016c5 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -827,6 +827,7 @@ describe("ChatView timeline estimator parity (full app)", () => { draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, stickyModelSelection: null, + stickyModelOptions: {}, }); useStore.setState({ projects: [], @@ -1499,6 +1500,12 @@ describe("ChatView timeline estimator parity (full app)", () => { fastMode: true, }, }, + stickyModelOptions: { + codex: { + reasoningEffort: "medium", + fastMode: true, + }, + }, }); const mounted = await mountChatView({ @@ -1546,6 +1553,12 @@ describe("ChatView timeline estimator parity (full app)", () => { fastMode: true, }, }, + stickyModelOptions: { + claudeAgent: { + effort: "max", + fastMode: true, + }, + }, }); const mounted = await mountChatView({ @@ -1623,6 +1636,12 @@ describe("ChatView timeline estimator parity (full app)", () => { fastMode: true, }, }, + stickyModelOptions: { + codex: { + reasoningEffort: "medium", + fastMode: true, + }, + }, }); const mounted = await mountChatView({ diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2240e74cee..9387b2b810 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -634,9 +634,9 @@ export default function ChatView({ threadId }: ChatViewProps) { provider: selectedProvider, model: selectedModel, prompt, - modelOptions: composerDraft.modelSelection?.options, + modelOptions: composerDraft.modelOptions, }), - [composerDraft.modelSelection?.options, prompt, selectedModel, selectedProvider], + [composerDraft.modelOptions, prompt, selectedModel, selectedProvider], ); const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; @@ -3116,14 +3116,9 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model); - const existingModelSelection = composerDraft.modelSelection; - const preserveOptions = - existingModelSelection?.provider === provider && - existingModelSelection.options !== undefined; const nextModelSelection: ModelSelection = { provider, model: resolvedModel, - ...(preserveOptions ? { options: existingModelSelection.options } : {}), }; setComposerDraftModelSelection(activeThread.id, nextModelSelection); setStickyComposerModelSelection(nextModelSelection); @@ -3131,7 +3126,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [ activeThread, - composerDraft.modelSelection, lockedProvider, scheduleComposerFocus, setComposerDraftModelSelection, diff --git a/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx b/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx index 8f043759f4..ebb4b32945 100644 --- a/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx @@ -34,6 +34,13 @@ async function mountPicker(props?: { ...(props?.fastModeEnabled ? { fastMode: true } : {}), }, }, + modelOptions: { + claudeAgent: { + ...(props?.effort ? { effort: props.effort } : {}), + ...(props?.thinkingEnabled === false ? { thinking: false } : {}), + ...(props?.fastModeEnabled ? { fastMode: true } : {}), + }, + }, runtimeMode: null, interactionMode: null, }; @@ -70,6 +77,7 @@ describe("ClaudeTraitsPicker", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + stickyModelOptions: {}, }); }); diff --git a/apps/web/src/components/chat/ClaudeTraitsPicker.tsx b/apps/web/src/components/chat/ClaudeTraitsPicker.tsx index 994e65012f..0b809e5aea 100644 --- a/apps/web/src/components/chat/ClaudeTraitsPicker.tsx +++ b/apps/web/src/components/chat/ClaudeTraitsPicker.tsx @@ -93,8 +93,7 @@ export const ClaudeTraitsMenuContent = memo(function ClaudeTraitsMenuContentImpl }: ClaudeTraitsMenuContentProps) { const draft = useComposerThreadDraft(threadId); const prompt = draft.prompt; - const modelOptions = - draft.modelSelection?.provider === PROVIDER ? draft.modelSelection.options : null; + const modelOptions = draft.modelOptions?.claudeAgent; const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); const { effort, @@ -226,8 +225,7 @@ export const ClaudeTraitsPicker = memo(function ClaudeTraitsPicker({ const [isMenuOpen, setIsMenuOpen] = useState(false); const draft = useComposerThreadDraft(threadId); const prompt = draft.prompt; - const modelOptions = - draft.modelSelection?.provider === PROVIDER ? draft.modelSelection.options : null; + const modelOptions = draft.modelOptions?.claudeAgent; const { effort, thinkingEnabled, fastModeEnabled, ultrathinkPromptControlled, supportsFastMode } = getSelectedClaudeTraits(model, prompt, modelOptions); const triggerLabel = [ diff --git a/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx b/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx index c494bb04b5..a88f465481 100644 --- a/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx @@ -30,6 +30,12 @@ async function mountPicker(props: { ...(props.fastModeEnabled ? { fastMode: true } : {}), }, }, + modelOptions: { + codex: { + ...(props.reasoningEffort ? { reasoningEffort: props.reasoningEffort } : {}), + ...(props.fastModeEnabled ? { fastMode: true } : {}), + }, + }, runtimeMode: null, interactionMode: null, }; @@ -60,6 +66,7 @@ describe("CodexTraitsPicker", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + stickyModelOptions: {}, }); }); diff --git a/apps/web/src/components/chat/CodexTraitsPicker.tsx b/apps/web/src/components/chat/CodexTraitsPicker.tsx index e985e76901..8ce6709f2a 100644 --- a/apps/web/src/components/chat/CodexTraitsPicker.tsx +++ b/apps/web/src/components/chat/CodexTraitsPicker.tsx @@ -48,8 +48,7 @@ function getSelectedCodexTraits(modelOptions: CodexModelOptions | null | undefin function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) { const draft = useComposerThreadDraft(props.threadId); - const modelOptions = - draft.modelSelection?.provider === PROVIDER ? draft.modelSelection.options : null; + const modelOptions = draft.modelOptions?.codex; const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); const options = getReasoningEffortOptions(PROVIDER); const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER); @@ -114,8 +113,7 @@ export const CodexTraitsMenuContent = memo(CodexTraitsMenuContentImpl); export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { threadId: ThreadId }) { const [isMenuOpen, setIsMenuOpen] = useState(false); const draft = useComposerThreadDraft(props.threadId); - const modelOptions = - draft.modelSelection?.provider === PROVIDER ? draft.modelSelection.options : null; + const modelOptions = draft.modelOptions?.codex; const { effort, fastModeEnabled } = getSelectedCodexTraits(modelOptions); const triggerLabel = [CODEX_REASONING_LABELS[effort], ...(fastModeEnabled ? ["Fast"] : [])] .filter(Boolean) diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 3d98760bbd..17dc2a5ca4 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -37,6 +37,7 @@ async function mountMenu(props?: { } : {}), }, + modelOptions: props?.modelOptions ?? null, runtimeMode: null, interactionMode: null, }; @@ -87,6 +88,7 @@ describe("CompactComposerControlsMenu", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + stickyModelOptions: {}, }); }); diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index 1a58164c93..43a146a9ae 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -23,8 +23,10 @@ describe("getComposerProviderState", () => { model: "gpt-5.4", prompt: "", modelOptions: { - reasoningEffort: "low", - fastMode: true, + codex: { + reasoningEffort: "low", + fastMode: true, + }, }, }); @@ -38,6 +40,27 @@ describe("getComposerProviderState", () => { }); }); + it("preserves codex fast mode when it is the only active option", () => { + const state = getComposerProviderState({ + provider: "codex", + model: "gpt-5.4", + prompt: "", + modelOptions: { + codex: { + fastMode: true, + }, + }, + }); + + expect(state).toEqual({ + provider: "codex", + promptEffort: "high", + modelOptionsForDispatch: { + fastMode: true, + }, + }); + }); + it("returns Claude defaults for effort-capable models", () => { const state = getComposerProviderState({ provider: "claudeAgent", @@ -59,7 +82,9 @@ describe("getComposerProviderState", () => { model: "claude-sonnet-4-6", prompt: "Ultrathink:\nInvestigate this failure", modelOptions: { - effort: "medium", + claudeAgent: { + effort: "medium", + }, }, }); @@ -81,8 +106,10 @@ describe("getComposerProviderState", () => { model: "claude-haiku-4-5", prompt: "", modelOptions: { - effort: "max", - thinking: false, + claudeAgent: { + effort: "max", + thinking: false, + }, }, }); @@ -94,4 +121,25 @@ describe("getComposerProviderState", () => { }, }); }); + + it("preserves Claude fast mode when it is the only active option", () => { + const state = getComposerProviderState({ + provider: "claudeAgent", + model: "claude-opus-4-6", + prompt: "", + modelOptions: { + claudeAgent: { + fastMode: true, + }, + }, + }); + + expect(state).toEqual({ + provider: "claudeAgent", + promptEffort: "high", + modelOptionsForDispatch: { + fastMode: true, + }, + }); + }); }); diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index d211c21d22..35a21c9af2 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -1,9 +1,7 @@ import { - type ClaudeModelOptions, - type CodexModelOptions, type ModelSlug, - type ModelSelection, type ProviderKind, + type ProviderModelOptions, type ThreadId, } from "@t3tools/contracts"; import { @@ -23,13 +21,13 @@ export type ComposerProviderStateInput = { provider: ProviderKind; model: ModelSlug; prompt: string; - modelOptions: ModelSelection["options"] | null | undefined; + modelOptions: ProviderModelOptions | null | undefined; }; export type ComposerProviderState = { provider: ProviderKind; promptEffort: string | null; - modelOptionsForDispatch: ModelSelection["options"] | undefined; + modelOptionsForDispatch: ProviderModelOptions[ProviderKind] | undefined; composerFrameClassName?: string; composerSurfaceClassName?: string; modelPickerIconClassName?: string; @@ -49,28 +47,13 @@ type ProviderRegistryEntry = { }) => ReactNode; }; -function asCodexModelOptions( - modelOptions: ModelSelection["options"] | null | undefined, -): CodexModelOptions | undefined { - return modelOptions && "reasoningEffort" in modelOptions ? modelOptions : undefined; -} - -function asClaudeModelOptions( - modelOptions: ModelSelection["options"] | null | undefined, -): ClaudeModelOptions | undefined { - return modelOptions && ("effort" in modelOptions || "thinking" in modelOptions) - ? modelOptions - : undefined; -} - const composerProviderRegistry: Record = { codex: { getState: ({ modelOptions }) => { - const codexModelOptions = asCodexModelOptions(modelOptions); const promptEffort = - resolveReasoningEffortForProvider("codex", codexModelOptions?.reasoningEffort) ?? + resolveReasoningEffortForProvider("codex", modelOptions?.codex?.reasoningEffort) ?? getDefaultReasoningEffort("codex"); - const normalizedCodexOptions = normalizeCodexModelOptions(codexModelOptions); + const normalizedCodexOptions = normalizeCodexModelOptions(modelOptions?.codex); return { provider: "codex", @@ -83,11 +66,10 @@ const composerProviderRegistry: Record = { }, claudeAgent: { getState: ({ model, prompt, modelOptions }) => { - const claudeModelOptions = asClaudeModelOptions(modelOptions); const reasoningOptions = getReasoningEffortOptions("claudeAgent", model); const draftEffort = resolveReasoningEffortForProvider( "claudeAgent", - claudeModelOptions?.effort, + modelOptions?.claudeAgent?.effort, ); const defaultEffort = getDefaultReasoningEffort("claudeAgent"); const promptEffort = @@ -96,7 +78,7 @@ const composerProviderRegistry: Record = { : reasoningOptions.includes(defaultEffort) ? defaultEffort : null; - const normalizedClaudeOptions = normalizeClaudeModelOptions(model, claudeModelOptions); + const normalizedClaudeOptions = normalizeClaudeModelOptions(model, modelOptions?.claudeAgent); const ultrathinkActive = supportsClaudeUltrathinkKeyword(model) && isClaudeUltrathinkPrompt(prompt); diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 68925a8988..2627b387c5 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1,5 +1,10 @@ import * as Schema from "effect/Schema"; -import { ProjectId, ThreadId, type ModelSelection } from "@t3tools/contracts"; +import { + ProjectId, + ThreadId, + type ModelSelection, + type ProviderModelOptions, +} from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -68,6 +73,7 @@ function resetComposerDraftStore() { draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, stickyModelSelection: null, + stickyModelOptions: {}, }); } @@ -83,6 +89,10 @@ function modelSelection( } as ModelSelection; } +function providerModelOptions(options: ProviderModelOptions): ProviderModelOptions { + return options; +} + describe("composerDraftStore addImages", () => { const threadId = ThreadId.makeUnsafe("thread-dedupe"); let originalRevokeObjectUrl: typeof URL.revokeObjectURL; @@ -208,6 +218,7 @@ describe("composerDraftStore syncPersistedAttachments", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + stickyModelOptions: {}, }); }); @@ -264,6 +275,7 @@ describe("composerDraftStore terminal contexts", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + stickyModelOptions: {}, }); }); @@ -676,6 +688,13 @@ describe("composerDraftStore modelSelection", () => { thinking: false, }), ); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual( + providerModelOptions({ + claudeAgent: { + thinking: false, + }, + }), + ); }); it("removes selection options when the patched provider options normalize empty", () => { @@ -695,6 +714,7 @@ describe("composerDraftStore modelSelection", () => { expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( modelSelection("claudeAgent", "claude-opus-4-6"), ); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toBeNull(); expect(useComposerDraftStore.getState().stickyModelSelection).toBeNull(); }); @@ -711,6 +731,7 @@ describe("composerDraftStore modelSelection", () => { expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( modelSelection("codex", "gpt-5.4"), ); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toBeNull(); }); it("updates only the draft when sticky persistence is omitted", () => { @@ -738,6 +759,28 @@ describe("composerDraftStore modelSelection", () => { ); }); + it("preserves other provider options when switching the active model selection", () => { + const store = useComposerDraftStore.getState(); + + store.setModelOptions( + threadId, + providerModelOptions({ + codex: { fastMode: true }, + claudeAgent: { effort: "max" }, + }), + ); + + store.setModelSelection(threadId, modelSelection("claudeAgent", "claude-opus-4-6")); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + modelSelection: modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), + modelOptions: providerModelOptions({ + codex: { fastMode: true }, + claudeAgent: { effort: "max" }, + }), + }); + }); + it("creates the first sticky snapshot from provider option changes", () => { const store = useComposerDraftStore.getState(); @@ -832,6 +875,12 @@ describe("composerDraftStore sticky composer settings", () => { fastMode: true, }, }, + stickyModelOptions: { + codex: { + reasoningEffort: "medium", + fastMode: true, + }, + }, }); }); @@ -843,6 +892,7 @@ describe("composerDraftStore sticky composer settings", () => { expect(useComposerDraftStore.getState().stickyModelSelection).toEqual( modelSelection("codex", "gpt-5.4"), ); + expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({}); }); }); @@ -853,7 +903,7 @@ describe("composerDraftStore provider-scoped option updates", () => { resetComposerDraftStore(); }); - it("does not patch provider options when the draft selection is on another provider", () => { + it("retains off-provider option memory without changing the active selection", () => { const store = useComposerDraftStore.getState(); store.setModelSelection( threadId, @@ -862,11 +912,19 @@ describe("composerDraftStore provider-scoped option updates", () => { }), ); store.setProviderModelOptions(threadId, "claudeAgent", { effort: "max" }); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( - modelSelection("codex", "gpt-5.3-codex", { + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + modelSelection: modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium", }), - ); + modelOptions: providerModelOptions({ + codex: { + reasoningEffort: "medium", + }, + claudeAgent: { + effort: "max", + }, + }), + }); }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 587129fa08..1f0a3b581b 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -3,7 +3,7 @@ import { type ClaudeCodeEffort, type CodexReasoningEffort, DEFAULT_REASONING_EFFORT_BY_PROVIDER, - type ModelSelection, + ModelSelection, ProjectId, ProviderInteractionMode, ProviderKind, @@ -74,7 +74,8 @@ const PersistedComposerThreadDraftState = Schema.Struct({ prompt: Schema.String, attachments: Schema.Array(PersistedComposerImageAttachment), terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)), - modelSelection: Schema.optionalKey(Schema.NullOr(Schema.Unknown)), + modelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), + modelOptions: Schema.optionalKey(Schema.NullOr(ProviderModelOptions)), runtimeMode: Schema.optionalKey(RuntimeMode), interactionMode: Schema.optionalKey(ProviderInteractionMode), }); @@ -109,7 +110,8 @@ const PersistedComposerDraftStoreState = Schema.Struct({ draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState), draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState), projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId), - stickyModelSelection: Schema.NullOr(Schema.Unknown), + stickyModelSelection: Schema.NullOr(ModelSelection), + stickyModelOptions: Schema.optionalKey(Schema.NullOr(ProviderModelOptions)), }); type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type; @@ -125,6 +127,7 @@ interface ComposerThreadDraftState { persistedAttachments: PersistedComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; modelSelection: ModelSelection | null; + modelOptions: ProviderModelOptions | null; runtimeMode: RuntimeMode | null; interactionMode: ProviderInteractionMode | null; } @@ -148,6 +151,7 @@ interface ComposerDraftStoreState { draftThreadsByThreadId: Record; projectDraftThreadIdByProjectId: Record; stickyModelSelection: ModelSelection | null; + stickyModelOptions: ProviderModelOptions; getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null; getDraftThread: (threadId: ThreadId) => DraftThreadState | null; setProjectDraftThreadId: ( @@ -178,12 +182,17 @@ interface ComposerDraftStoreState { clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; clearDraftThread: (threadId: ThreadId) => void; setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void; + setStickyModelOptions: (modelOptions: ProviderModelOptions | null | undefined) => void; setPrompt: (threadId: ThreadId, prompt: string) => void; setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void; setModelSelection: ( threadId: ThreadId, modelSelection: ModelSelection | null | undefined, ) => void; + setModelOptions: ( + threadId: ThreadId, + modelOptions: ProviderModelOptions | null | undefined, + ) => void; setProviderModelOptions: ( threadId: ThreadId, provider: ProviderKind, @@ -223,6 +232,7 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze({ persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, terminalContexts: EMPTY_TERMINAL_CONTEXTS, modelSelection: null, + modelOptions: null, runtimeMode: null, interactionMode: null, }); @@ -251,6 +262,7 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { persistedAttachments: [], terminalContexts: [], modelSelection: null, + modelOptions: null, runtimeMode: null, interactionMode: null, }; @@ -320,6 +332,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && draft.modelSelection === null && + draft.modelOptions === null && draft.runtimeMode === null && draft.interactionMode === null ); @@ -438,6 +451,35 @@ function normalizeModelSelection( }; } +function syncModelSelectionOptions( + modelSelection: ModelSelection | null, + modelOptions: ProviderModelOptions | null | undefined, +): ModelSelection | null { + if (modelSelection === null) { + return null; + } + const options = modelOptions?.[modelSelection.provider]; + return { + provider: modelSelection.provider, + model: modelSelection.model, + ...(options ? { options } : {}), + }; +} + +function mergeModelSelectionIntoProviderModelOptions( + modelSelection: ModelSelection | null, + currentModelOptions: ProviderModelOptions | null | undefined, +): ProviderModelOptions | null { + if (modelSelection?.options === undefined) { + return normalizeProviderModelOptions(currentModelOptions); + } + return replaceProviderModelOptions( + normalizeProviderModelOptions(currentModelOptions), + modelSelection.provider, + modelSelection.options, + ); +} + function replaceProviderModelOptions( currentModelOptions: ProviderModelOptions | null | undefined, provider: ProviderKind, @@ -686,17 +728,29 @@ function normalizePersistedDraftsByThreadId( terminalContexts.length, ); const legacyDraftCandidate = draftCandidate as LegacyPersistedComposerThreadDraftState; - const modelSelection = normalizeModelSelection(draftCandidate.modelSelection, { + const normalizedModelOptions = + normalizeProviderModelOptions( + draftCandidate.modelOptions ?? legacyDraftCandidate.modelOptions, + undefined, + legacyDraftCandidate, + ) ?? null; + const normalizedModelSelection = normalizeModelSelection(draftCandidate.modelSelection, { provider: legacyDraftCandidate.provider, model: legacyDraftCandidate.model, - modelOptions: legacyDraftCandidate.modelOptions, + modelOptions: normalizedModelOptions ?? legacyDraftCandidate.modelOptions, legacyCodex: legacyDraftCandidate, }); + const modelOptions = mergeModelSelectionIntoProviderModelOptions( + normalizedModelSelection, + normalizedModelOptions, + ); + const modelSelection = syncModelSelectionOptions(normalizedModelSelection, modelOptions); if ( promptCandidate.length === 0 && attachments.length === 0 && terminalContexts.length === 0 && modelSelection === null && + modelOptions === null && !runtimeMode && !interactionMode ) { @@ -707,6 +761,7 @@ function normalizePersistedDraftsByThreadId( attachments, ...(terminalContexts.length > 0 ? { terminalContexts } : {}), ...(modelSelection ? { modelSelection } : {}), + ...(modelOptions ? { modelOptions } : {}), ...(runtimeMode ? { runtimeMode } : {}), ...(interactionMode ? { interactionMode } : {}), }; @@ -725,11 +780,20 @@ function migratePersistedComposerDraftStoreState( const rawDraftMap = candidate.draftsByThreadId; const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId; const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId; - const stickyModelSelection = normalizeModelSelection(candidate.stickyModelSelection, { + const stickyModelOptions = normalizeProviderModelOptions(candidate.stickyModelOptions) ?? {}; + const normalizedStickyModelSelection = normalizeModelSelection(candidate.stickyModelSelection, { provider: candidate.stickyProvider ?? "codex", model: candidate.stickyModel, - modelOptions: candidate.stickyModelOptions, + modelOptions: stickyModelOptions, }); + const nextStickyModelOptions = mergeModelSelectionIntoProviderModelOptions( + normalizedStickyModelSelection, + stickyModelOptions, + ); + const stickyModelSelection = syncModelSelectionOptions( + normalizedStickyModelSelection, + nextStickyModelOptions, + ); const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } = normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectId); const draftsByThreadId = normalizePersistedDraftsByThreadId(rawDraftMap); @@ -738,6 +802,7 @@ function migratePersistedComposerDraftStoreState( draftThreadsByThreadId, projectDraftThreadIdByProjectId, stickyModelSelection, + stickyModelOptions: nextStickyModelOptions ?? {}, }; } @@ -756,6 +821,7 @@ function partializeComposerDraftStoreState( draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && draft.modelSelection === null && + draft.modelOptions === null && draft.runtimeMode === null && draft.interactionMode === null ) { @@ -778,6 +844,7 @@ function partializeComposerDraftStoreState( } : {}), ...(draft.modelSelection ? { modelSelection: draft.modelSelection } : {}), + ...(draft.modelOptions ? { modelOptions: draft.modelOptions } : {}), ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}), ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}), }; @@ -788,6 +855,7 @@ function partializeComposerDraftStoreState( draftThreadsByThreadId: state.draftThreadsByThreadId, projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId, stickyModelSelection: state.stickyModelSelection, + stickyModelOptions: state.stickyModelOptions, }; } @@ -803,19 +871,30 @@ function normalizeCurrentPersistedComposerDraftStoreState( normalizedPersistedState.draftThreadsByThreadId, normalizedPersistedState.projectDraftThreadIdByProjectId, ); - const stickyModelSelection = normalizeModelSelection( + const stickyModelOptions = + normalizeProviderModelOptions(normalizedPersistedState.stickyModelOptions) ?? {}; + const normalizedStickyModelSelection = normalizeModelSelection( normalizedPersistedState.stickyModelSelection, { provider: normalizedPersistedState.stickyProvider, model: normalizedPersistedState.stickyModel, - modelOptions: normalizedPersistedState.stickyModelOptions, + modelOptions: stickyModelOptions, }, ); + const nextStickyModelOptions = mergeModelSelectionIntoProviderModelOptions( + normalizedStickyModelSelection, + stickyModelOptions, + ); + const stickyModelSelection = syncModelSelectionOptions( + normalizedStickyModelSelection, + nextStickyModelOptions, + ); return { draftsByThreadId: normalizePersistedDraftsByThreadId(normalizedPersistedState.draftsByThreadId), draftThreadsByThreadId, projectDraftThreadIdByProjectId, stickyModelSelection, + stickyModelOptions: nextStickyModelOptions ?? {}, }; } @@ -942,6 +1021,19 @@ function hydrateImagesFromPersisted( function toHydratedThreadDraft( persistedDraft: PersistedComposerThreadDraftState, ): ComposerThreadDraftState { + const legacyPersistedDraft = persistedDraft as LegacyPersistedComposerThreadDraftState; + const normalizedModelSelection = normalizeModelSelection(persistedDraft.modelSelection, { + provider: legacyPersistedDraft.provider, + model: legacyPersistedDraft.model, + modelOptions: legacyPersistedDraft.modelOptions, + legacyCodex: legacyPersistedDraft, + }); + const normalizedModelOptions = normalizeProviderModelOptions( + persistedDraft.modelOptions ?? legacyPersistedDraft.modelOptions, + ); + const modelOptions = + mergeModelSelectionIntoProviderModelOptions(normalizedModelSelection, normalizedModelOptions) ?? + null; return { prompt: persistedDraft.prompt, images: hydrateImagesFromPersisted(persistedDraft.attachments), @@ -952,12 +1044,8 @@ function toHydratedThreadDraft( ...context, text: "", })) ?? [], - modelSelection: normalizeModelSelection(persistedDraft.modelSelection, { - provider: (persistedDraft as LegacyPersistedComposerThreadDraftState).provider, - model: (persistedDraft as LegacyPersistedComposerThreadDraftState).model, - modelOptions: (persistedDraft as LegacyPersistedComposerThreadDraftState).modelOptions, - legacyCodex: persistedDraft as LegacyPersistedComposerThreadDraftState, - }), + modelSelection: syncModelSelectionOptions(normalizedModelSelection, modelOptions), + modelOptions, runtimeMode: persistedDraft.runtimeMode ?? null, interactionMode: persistedDraft.interactionMode ?? null, }; @@ -970,6 +1058,7 @@ export const useComposerDraftStore = create()( draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, stickyModelSelection: null, + stickyModelOptions: {}, getDraftThreadByProjectId: (projectId) => { if (projectId.length === 0) { return null; @@ -1218,11 +1307,52 @@ export const useComposerDraftStore = create()( setStickyModelSelection: (modelSelection) => { const normalizedModelSelection = normalizeModelSelection(modelSelection); set((state) => - Equal.equals(state.stickyModelSelection, normalizedModelSelection) + Equal.equals( + state.stickyModelSelection, + syncModelSelectionOptions( + normalizedModelSelection, + mergeModelSelectionIntoProviderModelOptions( + normalizedModelSelection, + state.stickyModelOptions, + ), + ), + ) ? state - : { stickyModelSelection: normalizedModelSelection }, + : { + stickyModelSelection: syncModelSelectionOptions( + normalizedModelSelection, + mergeModelSelectionIntoProviderModelOptions( + normalizedModelSelection, + state.stickyModelOptions, + ), + ), + stickyModelOptions: + mergeModelSelectionIntoProviderModelOptions( + normalizedModelSelection, + state.stickyModelOptions, + ) ?? {}, + }, ); }, + setStickyModelOptions: (modelOptions) => { + const normalizedModelOptions = normalizeProviderModelOptions(modelOptions) ?? {}; + set((state) => { + const nextStickyModelSelection = syncModelSelectionOptions( + state.stickyModelSelection, + normalizedModelOptions, + ); + if ( + Equal.equals(state.stickyModelOptions, normalizedModelOptions) && + Equal.equals(state.stickyModelSelection, nextStickyModelSelection) + ) { + return state; + } + return { + stickyModelSelection: nextStickyModelSelection, + stickyModelOptions: normalizedModelOptions, + }; + }); + }, setPrompt: (threadId, prompt) => { if (threadId.length === 0) { return; @@ -1277,12 +1407,59 @@ export const useComposerDraftStore = create()( return state; } const base = existing ?? createEmptyThreadDraft(); - if (Equal.equals(base.modelSelection, normalizedModelSelection)) { + const nextModelOptions = mergeModelSelectionIntoProviderModelOptions( + normalizedModelSelection, + base.modelOptions, + ); + const nextModelSelection = syncModelSelectionOptions( + normalizedModelSelection, + nextModelOptions, + ); + if ( + Equal.equals(base.modelSelection, nextModelSelection) && + Equal.equals(base.modelOptions, nextModelOptions) + ) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...base, + modelSelection: nextModelSelection, + modelOptions: nextModelOptions, + }; + const nextDraftsByThreadId = { ...state.draftsByThreadId }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadId[threadId]; + } else { + nextDraftsByThreadId[threadId] = nextDraft; + } + return { draftsByThreadId: nextDraftsByThreadId }; + }); + }, + setModelOptions: (threadId, modelOptions) => { + if (threadId.length === 0) { + return; + } + set((state) => { + const existing = state.draftsByThreadId[threadId]; + const nextModelOptions = normalizeProviderModelOptions(modelOptions); + if (!existing && nextModelOptions === null) { + return state; + } + const base = existing ?? createEmptyThreadDraft(); + const nextModelSelection = syncModelSelectionOptions( + base.modelSelection, + nextModelOptions, + ); + if ( + Equal.equals(base.modelOptions, nextModelOptions) && + Equal.equals(base.modelSelection, nextModelSelection) + ) { return state; } const nextDraft: ComposerThreadDraftState = { ...base, - modelSelection: normalizedModelSelection, + modelSelection: nextModelSelection, + modelOptions: nextModelOptions, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; if (shouldRemoveDraft(nextDraft)) { @@ -1304,35 +1481,35 @@ export const useComposerDraftStore = create()( set((state) => { const existing = state.draftsByThreadId[threadId]; const base = existing ?? createEmptyThreadDraft(); - if (base.modelSelection?.provider !== normalizedProvider) { - return state; - } - const nextProviderModelOptions = replaceProviderModelOptions( - base.modelSelection?.options - ? { [normalizedProvider]: base.modelSelection.options } - : null, + const nextModelOptions = replaceProviderModelOptions( + base.modelOptions, normalizedProvider, nextProviderOptions, ); - const nextOptions = - normalizedProvider === "codex" - ? nextProviderModelOptions?.codex - : nextProviderModelOptions?.claudeAgent; - const nextModelSelection = - base.modelSelection === null - ? null - : { - provider: base.modelSelection.provider, - model: base.modelSelection.model, - ...(nextOptions ? { options: nextOptions } : {}), - }; + const nextModelSelection = syncModelSelectionOptions( + base.modelSelection, + nextModelOptions, + ); + const nextStickyModelOptions = + options?.persistSticky === true + ? (replaceProviderModelOptions( + state.stickyModelOptions, + normalizedProvider, + nextProviderOptions, + ) ?? {}) + : state.stickyModelOptions; + const stickySelectionBase = + state.stickyModelSelection ?? + (options?.persistSticky === true ? base.modelSelection : null); const nextStickyModelSelection = - options?.persistSticky === true && nextModelSelection !== null - ? nextModelSelection + options?.persistSticky === true + ? syncModelSelectionOptions(stickySelectionBase, nextStickyModelOptions) : state.stickyModelSelection; if ( Equal.equals(base.modelSelection, nextModelSelection) && + Equal.equals(base.modelOptions, nextModelOptions) && + Equal.equals(state.stickyModelOptions, nextStickyModelOptions) && Equal.equals(state.stickyModelSelection, nextStickyModelSelection) ) { return state; @@ -1341,6 +1518,7 @@ export const useComposerDraftStore = create()( const nextDraft: ComposerThreadDraftState = { ...base, modelSelection: nextModelSelection, + modelOptions: nextModelOptions, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; if (shouldRemoveDraft(nextDraft)) { @@ -1352,7 +1530,10 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId, ...(options?.persistSticky === true - ? { stickyModelSelection: nextStickyModelSelection } + ? { + stickyModelSelection: nextStickyModelSelection, + stickyModelOptions: nextStickyModelOptions, + } : {}), }; }); @@ -1712,6 +1893,7 @@ export const useComposerDraftStore = create()( draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId, projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId, stickyModelSelection: normalizedPersisted.stickyModelSelection as ModelSelection | null, + stickyModelOptions: normalizedPersisted.stickyModelOptions ?? {}, }; }, }, diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 40d8c68f22..69010dfe94 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -13,6 +13,7 @@ export function useHandleNewThread() { const projects = useStore((store) => store.projects); const threads = useStore((store) => store.threads); const stickyModelSelection = useComposerDraftStore((store) => store.stickyModelSelection); + const stickyModelOptions = useComposerDraftStore((store) => store.stickyModelOptions); const navigate = useNavigate(); const routeThreadId = useParams({ strict: false, @@ -40,6 +41,7 @@ export function useHandleNewThread() { getDraftThread, getDraftThreadByProjectId, setModelSelection, + setModelOptions, setDraftThreadContext, setProjectDraftThreadId, } = useComposerDraftStore.getState(); @@ -101,6 +103,9 @@ export function useHandleNewThread() { if (stickyModelSelection) { setModelSelection(threadId, stickyModelSelection); } + if (Object.keys(stickyModelOptions).length > 0) { + setModelOptions(threadId, stickyModelOptions); + } await navigate({ to: "/$threadId", @@ -108,7 +113,7 @@ export function useHandleNewThread() { }); })(); }, - [navigate, routeThreadId, stickyModelSelection], + [navigate, routeThreadId, stickyModelOptions, stickyModelSelection], ); return { From 3c56663abcd5c3a3775ea6d747c3ef6a7a71a0ac Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 17:24:28 -0700 Subject: [PATCH 24/38] kewl --- apps/web/src/composerDraftStore.ts | 36 ++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 1f0a3b581b..2171655519 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -88,12 +88,26 @@ const LegacyCodexFields = Schema.Struct({ }); type LegacyCodexFields = typeof LegacyCodexFields.Type; -type LegacyPersistedCodexThreadDraftState = PersistedComposerThreadDraftState & LegacyCodexFields; -type LegacyPersistedComposerThreadDraftState = LegacyPersistedCodexThreadDraftState & { - provider?: ProviderKind; - model?: string; - modelOptions?: ProviderModelOptions | null; -}; +const LegacyThreadModelFields = Schema.Struct({ + provider: Schema.optionalKey(ProviderKind), + model: Schema.optionalKey(Schema.String), + modelOptions: Schema.optionalKey(Schema.NullOr(ProviderModelOptions)), +}); +type LegacyThreadModelFields = typeof LegacyThreadModelFields.Type; + +type LegacyPersistedComposerThreadDraftState = PersistedComposerThreadDraftState & + LegacyCodexFields & + LegacyThreadModelFields; + +const LegacyStickyModelFields = Schema.Struct({ + stickyProvider: Schema.optionalKey(ProviderKind), + stickyModel: Schema.optionalKey(Schema.String), + stickyModelOptions: Schema.optionalKey(Schema.NullOr(ProviderModelOptions)), +}); +type LegacyStickyModelFields = typeof LegacyStickyModelFields.Type; + +type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState & + LegacyStickyModelFields; const PersistedDraftThreadState = Schema.Struct({ projectId: ProjectId, @@ -698,9 +712,7 @@ function normalizePersistedDraftsByThreadId( if (!draftValue || typeof draftValue !== "object") { continue; } - const draftCandidate = draftValue as - | PersistedComposerThreadDraftState - | LegacyPersistedCodexThreadDraftState; + const draftCandidate = draftValue as PersistedComposerThreadDraftState; const promptCandidate = typeof draftCandidate.prompt === "string" ? draftCandidate.prompt : ""; const attachments = Array.isArray(draftCandidate.attachments) ? draftCandidate.attachments.flatMap((entry) => { @@ -727,7 +739,7 @@ function normalizePersistedDraftsByThreadId( promptCandidate, terminalContexts.length, ); - const legacyDraftCandidate = draftCandidate as LegacyPersistedComposerThreadDraftState; + const legacyDraftCandidate = draftValue as LegacyPersistedComposerThreadDraftState; const normalizedModelOptions = normalizeProviderModelOptions( draftCandidate.modelOptions ?? legacyDraftCandidate.modelOptions, @@ -776,7 +788,7 @@ function migratePersistedComposerDraftStoreState( if (!persistedState || typeof persistedState !== "object") { return EMPTY_PERSISTED_DRAFT_STORE_STATE; } - const candidate = persistedState as Record; + const candidate = persistedState as LegacyPersistedComposerDraftStoreState; const rawDraftMap = candidate.draftsByThreadId; const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId; const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId; @@ -865,7 +877,7 @@ function normalizeCurrentPersistedComposerDraftStoreState( if (!persistedState || typeof persistedState !== "object") { return EMPTY_PERSISTED_DRAFT_STORE_STATE; } - const normalizedPersistedState = persistedState as Record; + const normalizedPersistedState = persistedState as LegacyPersistedComposerDraftStoreState; const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } = normalizePersistedDraftThreads( normalizedPersistedState.draftThreadsByThreadId, From 5635f3d6e955b12e34791ba1f688efd7ec20763a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 17:26:00 -0700 Subject: [PATCH 25/38] rev --- apps/web/src/components/chat/ClaudeTraitsPicker.tsx | 4 ++-- apps/web/src/components/chat/CodexTraitsPicker.tsx | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/ClaudeTraitsPicker.tsx b/apps/web/src/components/chat/ClaudeTraitsPicker.tsx index 0b809e5aea..d6585d43d8 100644 --- a/apps/web/src/components/chat/ClaudeTraitsPicker.tsx +++ b/apps/web/src/components/chat/ClaudeTraitsPicker.tsx @@ -93,7 +93,7 @@ export const ClaudeTraitsMenuContent = memo(function ClaudeTraitsMenuContentImpl }: ClaudeTraitsMenuContentProps) { const draft = useComposerThreadDraft(threadId); const prompt = draft.prompt; - const modelOptions = draft.modelOptions?.claudeAgent; + const modelOptions = draft.modelOptions?.[PROVIDER]; const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); const { effort, @@ -225,7 +225,7 @@ export const ClaudeTraitsPicker = memo(function ClaudeTraitsPicker({ const [isMenuOpen, setIsMenuOpen] = useState(false); const draft = useComposerThreadDraft(threadId); const prompt = draft.prompt; - const modelOptions = draft.modelOptions?.claudeAgent; + const modelOptions = draft.modelOptions?.[PROVIDER]; const { effort, thinkingEnabled, fastModeEnabled, ultrathinkPromptControlled, supportsFastMode } = getSelectedClaudeTraits(model, prompt, modelOptions); const triggerLabel = [ diff --git a/apps/web/src/components/chat/CodexTraitsPicker.tsx b/apps/web/src/components/chat/CodexTraitsPicker.tsx index 8ce6709f2a..73ca040f30 100644 --- a/apps/web/src/components/chat/CodexTraitsPicker.tsx +++ b/apps/web/src/components/chat/CodexTraitsPicker.tsx @@ -48,7 +48,7 @@ function getSelectedCodexTraits(modelOptions: CodexModelOptions | null | undefin function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) { const draft = useComposerThreadDraft(props.threadId); - const modelOptions = draft.modelOptions?.codex; + const modelOptions = draft.modelOptions?.[PROVIDER]; const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); const options = getReasoningEffortOptions(PROVIDER); const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER); @@ -112,8 +112,7 @@ export const CodexTraitsMenuContent = memo(CodexTraitsMenuContentImpl); export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { threadId: ThreadId }) { const [isMenuOpen, setIsMenuOpen] = useState(false); - const draft = useComposerThreadDraft(props.threadId); - const modelOptions = draft.modelOptions?.codex; + const modelOptions = useComposerThreadDraft(props.threadId).modelOptions?.[PROVIDER]; const { effort, fastModeEnabled } = getSelectedCodexTraits(modelOptions); const triggerLabel = [CODEX_REASONING_LABELS[effort], ...(fastModeEnabled ? ["Fast"] : [])] .filter(Boolean) From 2767215e4e71aac09bfa3865e674464079a08316 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 17:28:02 -0700 Subject: [PATCH 26/38] kewl --- apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx | 6 +++--- apps/web/src/components/chat/CodexTraitsPicker.browser.tsx | 4 ++-- .../components/chat/CompactComposerControlsMenu.browser.tsx | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx b/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx index ebb4b32945..f861ee565e 100644 --- a/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx @@ -1,6 +1,6 @@ import "../../index.css"; -import { ThreadId } from "@t3tools/contracts"; +import { DEFAULT_MODEL_BY_PROVIDER, ThreadId } from "@t3tools/contracts"; import { page } from "vitest/browser"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -27,7 +27,7 @@ async function mountPicker(props?: { terminalContexts: [], modelSelection: { provider: "claudeAgent", - model: props?.model ?? "claude-opus-4-6", + model: DEFAULT_MODEL_BY_PROVIDER["claudeAgent"], options: { ...(props?.effort ? { effort: props.effort } : {}), ...(props?.thinkingEnabled === false ? { thinking: false } : {}), @@ -55,7 +55,7 @@ async function mountPicker(props?: { const screen = await render( , { container: host }, diff --git a/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx b/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx index a88f465481..72e3e83dee 100644 --- a/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx @@ -1,6 +1,6 @@ import "../../index.css"; -import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { DEFAULT_MODEL_BY_PROVIDER, ProjectId, ThreadId } from "@t3tools/contracts"; import { page } from "vitest/browser"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -24,7 +24,7 @@ async function mountPicker(props: { terminalContexts: [], modelSelection: { provider: "codex", - model: "gpt-5.4", + model: DEFAULT_MODEL_BY_PROVIDER["codex"], options: { ...(props.reasoningEffort ? { reasoningEffort: props.reasoningEffort } : {}), ...(props.fastModeEnabled ? { fastMode: true } : {}), diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 17dc2a5ca4..1f97ab46b5 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -1,4 +1,4 @@ -import { type ProviderModelOptions, ThreadId } from "@t3tools/contracts"; +import { DEFAULT_MODEL_BY_PROVIDER, type ProviderModelOptions, ThreadId } from "@t3tools/contracts"; import "../../index.css"; import { page } from "vitest/browser"; @@ -29,7 +29,7 @@ async function mountMenu(props?: { terminalContexts: [], modelSelection: { provider, - model: props?.model ?? "claude-opus-4-6", + model: DEFAULT_MODEL_BY_PROVIDER["claudeAgent"], ...(props?.modelOptions ? { options: From 2eb166537b8352796e6e2156b3e1017bbd8a4e92 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 17:29:34 -0700 Subject: [PATCH 27/38] rm unused --- apps/web/src/store.ts | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 088e9657fc..4590b2886d 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,7 +1,5 @@ import { Fragment, type ReactNode, createElement, useEffect } from "react"; import { - DEFAULT_MODEL_BY_PROVIDER, - type ModelSelection, type ProviderKind, ThreadId, type OrchestrationReadModel, @@ -326,26 +324,6 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea }; } -export function resolveModelSelection( - modelSelection: ModelSelection | null | undefined, - fallbackProvider: ProviderKind, - fallbackModel?: string | null, -): ModelSelection { - if (modelSelection) { - return { - ...modelSelection, - model: resolveModelSlugForProvider(modelSelection.provider, modelSelection.model), - }; - } - return { - provider: fallbackProvider, - model: resolveModelSlugForProvider( - fallbackProvider, - fallbackModel ?? DEFAULT_MODEL_BY_PROVIDER[fallbackProvider], - ), - }; -} - export function markThreadVisited( state: AppState, threadId: ThreadId, From cfe745c2838f682cb0dcda66ca140c69c441887e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 17:30:30 -0700 Subject: [PATCH 28/38] whops --- apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx b/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx index f861ee565e..df249bf4e2 100644 --- a/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx @@ -27,7 +27,7 @@ async function mountPicker(props?: { terminalContexts: [], modelSelection: { provider: "claudeAgent", - model: DEFAULT_MODEL_BY_PROVIDER["claudeAgent"], + model: props?.model ?? DEFAULT_MODEL_BY_PROVIDER["claudeAgent"], options: { ...(props?.effort ? { effort: props.effort } : {}), ...(props?.thinkingEnabled === false ? { thinking: false } : {}), From bedce2110ca4d2fbeea167b49028f086ffe2a14c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 17:37:26 -0700 Subject: [PATCH 29/38] fix test --- .../components/chat/ClaudeTraitsPicker.browser.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx b/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx index df249bf4e2..9d3822f503 100644 --- a/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx @@ -1,6 +1,6 @@ import "../../index.css"; -import { DEFAULT_MODEL_BY_PROVIDER, ThreadId } from "@t3tools/contracts"; +import { ThreadId } from "@t3tools/contracts"; import { page } from "vitest/browser"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -19,6 +19,7 @@ async function mountPicker(props?: { const draftsByThreadId = {} as ReturnType< typeof useComposerDraftStore.getState >["draftsByThreadId"]; + const model = props?.model ?? "claude-opus-4-6"; draftsByThreadId[threadId] = { prompt: props?.prompt ?? "", images: [], @@ -27,7 +28,7 @@ async function mountPicker(props?: { terminalContexts: [], modelSelection: { provider: "claudeAgent", - model: props?.model ?? DEFAULT_MODEL_BY_PROVIDER["claudeAgent"], + model, options: { ...(props?.effort ? { effort: props.effort } : {}), ...(props?.thinkingEnabled === false ? { thinking: false } : {}), @@ -53,11 +54,7 @@ async function mountPicker(props?: { document.body.append(host); const onPromptChange = vi.fn(); const screen = await render( - , + , { container: host }, ); @@ -77,6 +74,7 @@ describe("ClaudeTraitsPicker", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + stickyModelSelection: null, stickyModelOptions: {}, }); }); From fc938c83e261906078f38512f070d4f83168124c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 18:08:41 -0700 Subject: [PATCH 30/38] simplify --- apps/web/src/components/ChatView.tsx | 39 ++--- .../chat/ClaudeTraitsPicker.browser.tsx | 125 +++++++++++++--- .../components/chat/ClaudeTraitsPicker.tsx | 30 ++-- .../chat/CodexTraitsPicker.browser.tsx | 67 ++------- .../src/components/chat/CodexTraitsPicker.tsx | 28 ++-- .../CompactComposerControlsMenu.browser.tsx | 4 +- .../chat/composerProviderRegistry.test.tsx | 40 ++++++ .../chat/composerProviderRegistry.tsx | 48 ++++++- apps/web/src/composerDraftStore.ts | 134 +++++++++++++++--- 9 files changed, 359 insertions(+), 156 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9387b2b810..f045a8c6ff 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -22,12 +22,7 @@ import { ProviderInteractionMode, RuntimeMode, } from "@t3tools/contracts"; -import { - applyClaudePromptEffortPrefix, - getDefaultModel, - normalizeModelSlug, - resolveModelSlugForProvider, -} from "@t3tools/shared/model"; +import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; @@ -134,6 +129,7 @@ import { type DraftThreadEnvMode, type PersistedComposerImageAttachment, useComposerDraftStore, + useEffectiveComposerModelState, useComposerThreadDraft, } from "../composerDraftStore"; import { @@ -609,34 +605,23 @@ export default function ChatView({ threadId }: ChatViewProps) { : null; const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? threadProvider ?? "codex"; - const baseThreadModel = resolveModelSlugForProvider( - selectedProvider, - activeThread?.modelSelection.model ?? - activeProject?.defaultModelSelection?.model ?? - getDefaultModel(selectedProvider), - ); const customModelsByProvider = useMemo(() => getCustomModelsByProvider(settings), [settings]); - const selectedModel = useMemo(() => { - const draftModel = composerDraft.modelSelection?.model; - if (!draftModel) { - return baseThreadModel; - } - return resolveAppModelSelection(selectedProvider, customModelsByProvider, draftModel); - }, [ - baseThreadModel, - composerDraft.modelSelection?.model, - customModelsByProvider, + const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ + threadId, selectedProvider, - ]); + threadModelSelection: activeThread?.modelSelection, + projectModelSelection: activeProject?.defaultModelSelection, + customModelsByProvider, + }); const composerProviderState = useMemo( () => getComposerProviderState({ provider: selectedProvider, model: selectedModel, prompt, - modelOptions: composerDraft.modelOptions, + modelOptions: composerModelOptions, }), - [composerDraft.modelOptions, prompt, selectedModel, selectedProvider], + [composerModelOptions, prompt, selectedModel, selectedProvider], ); const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; @@ -3153,12 +3138,16 @@ export default function ChatView({ threadId }: ChatViewProps) { provider: selectedProvider, threadId, model: selectedModel, + modelOptions: composerModelOptions?.[selectedProvider], + prompt, onPromptChange: setPromptFromTraits, }); const providerTraitsPicker = renderProviderTraitsPicker({ provider: selectedProvider, threadId, model: selectedModel, + modelOptions: composerModelOptions?.[selectedProvider], + prompt, onPromptChange: setPromptFromTraits, }); const onEnvModeChange = useCallback( diff --git a/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx b/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx index 9d3822f503..934dfbb2b8 100644 --- a/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx @@ -1,12 +1,50 @@ import "../../index.css"; -import { ThreadId } from "@t3tools/contracts"; +import { type ModelSelection, ThreadId } from "@t3tools/contracts"; import { page } from "vitest/browser"; +import { useCallback } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; import { ClaudeTraitsPicker } from "./ClaudeTraitsPicker"; -import { useComposerDraftStore } from "../../composerDraftStore"; +import { + useComposerDraftStore, + useComposerThreadDraft, + useEffectiveComposerModelState, +} from "../../composerDraftStore"; + +const THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits"); + +function ClaudeTraitsPickerHarness(props: { + model: string; + fallbackModelSelection: ModelSelection | null; +}) { + const prompt = useComposerThreadDraft(THREAD_ID).prompt; + const setPrompt = useComposerDraftStore((store) => store.setPrompt); + const { modelOptions, selectedModel } = useEffectiveComposerModelState({ + threadId: THREAD_ID, + selectedProvider: "claudeAgent", + threadModelSelection: props.fallbackModelSelection, + projectModelSelection: null, + customModelsByProvider: { codex: [], claudeAgent: [] }, + }); + const handlePromptChange = useCallback( + (nextPrompt: string) => { + setPrompt(THREAD_ID, nextPrompt); + }, + [setPrompt], + ); + + return ( + + ); +} async function mountPicker(props?: { model?: string; @@ -14,13 +52,18 @@ async function mountPicker(props?: { effort?: "low" | "medium" | "high" | "max" | "ultrathink" | null; thinkingEnabled?: boolean | null; fastModeEnabled?: boolean; + fallbackModelOptions?: { + effort?: "low" | "medium" | "high" | "max" | "ultrathink"; + thinking?: boolean; + fastMode?: boolean; + } | null; + skipDraftModelOptions?: boolean; }) { - const threadId = ThreadId.makeUnsafe("thread-claude-traits"); const draftsByThreadId = {} as ReturnType< typeof useComposerDraftStore.getState >["draftsByThreadId"]; const model = props?.model ?? "claude-opus-4-6"; - draftsByThreadId[threadId] = { + draftsByThreadId[THREAD_ID] = { prompt: props?.prompt ?? "", images: [], nonPersistedImageIds: [], @@ -29,19 +72,25 @@ async function mountPicker(props?: { modelSelection: { provider: "claudeAgent", model, - options: { - ...(props?.effort ? { effort: props.effort } : {}), - ...(props?.thinkingEnabled === false ? { thinking: false } : {}), - ...(props?.fastModeEnabled ? { fastMode: true } : {}), - }, - }, - modelOptions: { - claudeAgent: { - ...(props?.effort ? { effort: props.effort } : {}), - ...(props?.thinkingEnabled === false ? { thinking: false } : {}), - ...(props?.fastModeEnabled ? { fastMode: true } : {}), - }, + ...(!props?.skipDraftModelOptions + ? { + options: { + ...(props?.effort ? { effort: props.effort } : {}), + ...(props?.thinkingEnabled === false ? { thinking: false } : {}), + ...(props?.fastModeEnabled ? { fastMode: true } : {}), + }, + } + : {}), }, + modelOptions: props?.skipDraftModelOptions + ? null + : { + claudeAgent: { + ...(props?.effort ? { effort: props.effort } : {}), + ...(props?.thinkingEnabled === false ? { thinking: false } : {}), + ...(props?.fastModeEnabled ? { fastMode: true } : {}), + }, + }, runtimeMode: null, interactionMode: null, }; @@ -52,14 +101,20 @@ async function mountPicker(props?: { }); const host = document.createElement("div"); document.body.append(host); - const onPromptChange = vi.fn(); + const fallbackModelSelection = + props?.fallbackModelOptions !== undefined + ? ({ + provider: "claudeAgent", + model, + options: props.fallbackModelOptions ?? undefined, + } satisfies ModelSelection) + : null; const screen = await render( - , + , { container: host }, ); return { - onPromptChange, cleanup: async () => { await screen.unmount(); host.remove(); @@ -201,4 +256,36 @@ describe("ClaudeTraitsPicker", () => { await mounted.cleanup(); } }); + + it("can turn inherited fast mode off without snapping back", async () => { + const mounted = await mountPicker({ + model: "claude-opus-4-6", + skipDraftModelOptions: true, + fallbackModelOptions: { + effort: "high", + fastMode: true, + }, + }); + + try { + const trigger = page.getByRole("button"); + + await expect.element(trigger).toHaveTextContent("High · Fast"); + await trigger.click(); + await page.getByRole("menuitemradio", { name: "off" }).click(); + + await vi.waitFor(() => { + expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]?.modelOptions).toEqual({ + claudeAgent: { + effort: "high", + fastMode: false, + }, + }); + }); + await expect.element(trigger).toHaveTextContent("High"); + await expect.element(trigger).not.toHaveTextContent("High · Fast"); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/chat/ClaudeTraitsPicker.tsx b/apps/web/src/components/chat/ClaudeTraitsPicker.tsx index d6585d43d8..55566e4fb0 100644 --- a/apps/web/src/components/chat/ClaudeTraitsPicker.tsx +++ b/apps/web/src/components/chat/ClaudeTraitsPicker.tsx @@ -8,7 +8,6 @@ import { applyClaudePromptEffortPrefix, getDefaultReasoningEffort, getReasoningEffortOptions, - normalizeClaudeModelOptions, resolveReasoningEffortForProvider, supportsClaudeFastMode, supportsClaudeThinkingToggle, @@ -27,7 +26,7 @@ import { MenuSeparator as MenuDivider, MenuTrigger, } from "../ui/menu"; -import { useComposerDraftStore, useComposerThreadDraft } from "../../composerDraftStore"; +import { useComposerDraftStore } from "../../composerDraftStore"; const PROVIDER = "claudeAgent" as const satisfies ProviderKind; @@ -83,17 +82,18 @@ function getSelectedClaudeTraits( interface ClaudeTraitsMenuContentProps { threadId: ThreadId; model: string | null | undefined; + prompt: string; onPromptChange: (prompt: string) => void; + modelOptions?: ClaudeModelOptions | null | undefined; } export const ClaudeTraitsMenuContent = memo(function ClaudeTraitsMenuContentImpl({ threadId, model, + prompt, onPromptChange, + modelOptions, }: ClaudeTraitsMenuContentProps) { - const draft = useComposerThreadDraft(threadId); - const prompt = draft.prompt; - const modelOptions = draft.modelOptions?.[PROVIDER]; const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); const { effort, @@ -122,16 +122,15 @@ export const ClaudeTraitsMenuContent = memo(function ClaudeTraitsMenuContentImpl setProviderModelOptions( threadId, PROVIDER, - normalizeClaudeModelOptions(model, { + { ...modelOptions, effort: nextEffort, - }), + }, { persistSticky: true }, ); }, [ ultrathinkPromptControlled, - model, modelOptions, onPromptChange, threadId, @@ -175,10 +174,10 @@ export const ClaudeTraitsMenuContent = memo(function ClaudeTraitsMenuContentImpl setProviderModelOptions( threadId, PROVIDER, - normalizeClaudeModelOptions(model, { + { ...modelOptions, thinking: value === "on", - }), + }, { persistSticky: true }, ); }} @@ -199,10 +198,10 @@ export const ClaudeTraitsMenuContent = memo(function ClaudeTraitsMenuContentImpl setProviderModelOptions( threadId, PROVIDER, - normalizeClaudeModelOptions(model, { + { ...modelOptions, fastMode: value === "on", - }), + }, { persistSticky: true }, ); }} @@ -220,12 +219,11 @@ export const ClaudeTraitsMenuContent = memo(function ClaudeTraitsMenuContentImpl export const ClaudeTraitsPicker = memo(function ClaudeTraitsPicker({ threadId, model, + prompt, onPromptChange, + modelOptions, }: ClaudeTraitsMenuContentProps) { const [isMenuOpen, setIsMenuOpen] = useState(false); - const draft = useComposerThreadDraft(threadId); - const prompt = draft.prompt; - const modelOptions = draft.modelOptions?.[PROVIDER]; const { effort, thinkingEnabled, fastModeEnabled, ultrathinkPromptControlled, supportsFastMode } = getSelectedClaudeTraits(model, prompt, modelOptions); const triggerLabel = [ @@ -264,7 +262,9 @@ export const ClaudeTraitsPicker = memo(function ClaudeTraitsPicker({ diff --git a/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx b/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx index 72e3e83dee..fc786b0f58 100644 --- a/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx @@ -48,7 +48,16 @@ async function mountPicker(props: { }); const host = document.createElement("div"); document.body.append(host); - const screen = await render(, { container: host }); + const screen = await render( + , + { container: host }, + ); return { cleanup: async () => { @@ -142,60 +151,4 @@ describe("CodexTraitsPicker", () => { await mounted.cleanup(); } }); - - it("hydrates legacy codex persisted state into modelOptions through the picker", async () => { - const threadId = ThreadId.makeUnsafe("thread-codex-legacy"); - localStorage.setItem( - COMPOSER_DRAFT_STORAGE_KEY, - JSON.stringify({ - state: { - draftsByThreadId: { - [threadId]: { - prompt: "", - attachments: [], - modelSelection: null, - provider: "codex", - model: "gpt-5.3-codex", - effort: "xhigh", - codexFastMode: true, - serviceTier: "fast", - }, - }, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - }, - version: 1, - }), - ); - useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - }); - - const host = document.createElement("div"); - document.body.append(host); - const screen = await render(, { container: host }); - - try { - await useComposerDraftStore.persist.rehydrate(); - - await vi.waitFor(() => { - expect(document.body.textContent ?? "").toContain("Extra High · Fast"); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( - { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "xhigh", - fastMode: true, - }, - }, - ); - }); - } finally { - await screen.unmount(); - host.remove(); - } - }); }); diff --git a/apps/web/src/components/chat/CodexTraitsPicker.tsx b/apps/web/src/components/chat/CodexTraitsPicker.tsx index 73ca040f30..e9dfbf07ea 100644 --- a/apps/web/src/components/chat/CodexTraitsPicker.tsx +++ b/apps/web/src/components/chat/CodexTraitsPicker.tsx @@ -7,12 +7,11 @@ import type { import { getDefaultReasoningEffort, getReasoningEffortOptions, - normalizeCodexModelOptions, resolveReasoningEffortForProvider, } from "@t3tools/shared/model"; import { memo, useState } from "react"; import { ChevronDownIcon } from "lucide-react"; -import { useComposerDraftStore, useComposerThreadDraft } from "../../composerDraftStore"; +import { useComposerDraftStore } from "../../composerDraftStore"; import { Button } from "../ui/button"; import { Menu, @@ -46,9 +45,11 @@ function getSelectedCodexTraits(modelOptions: CodexModelOptions | null | undefin }; } -function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) { - const draft = useComposerThreadDraft(props.threadId); - const modelOptions = draft.modelOptions?.[PROVIDER]; +function CodexTraitsMenuContentImpl(props: { + threadId: ThreadId; + modelOptions?: CodexModelOptions | null | undefined; +}) { + const modelOptions = props.modelOptions; const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); const options = getReasoningEffortOptions(PROVIDER); const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER); @@ -67,10 +68,10 @@ function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) { setProviderModelOptions( props.threadId, PROVIDER, - normalizeCodexModelOptions({ + { ...modelOptions, reasoningEffort: nextEffort, - }), + }, { persistSticky: true }, ); }} @@ -92,10 +93,10 @@ function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) { setProviderModelOptions( props.threadId, PROVIDER, - normalizeCodexModelOptions({ + { ...modelOptions, fastMode: value === "on", - }), + }, { persistSticky: true }, ); }} @@ -110,9 +111,12 @@ function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) { export const CodexTraitsMenuContent = memo(CodexTraitsMenuContentImpl); -export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { threadId: ThreadId }) { +export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { + threadId: ThreadId; + modelOptions?: CodexModelOptions | null | undefined; +}) { const [isMenuOpen, setIsMenuOpen] = useState(false); - const modelOptions = useComposerThreadDraft(props.threadId).modelOptions?.[PROVIDER]; + const modelOptions = props.modelOptions; const { effort, fastModeEnabled } = getSelectedCodexTraits(modelOptions); const triggerLabel = [CODEX_REASONING_LABELS[effort], ...(fastModeEnabled ? ["Fast"] : [])] .filter(Boolean) @@ -140,7 +144,7 @@ export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { thread - + ); diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 1f97ab46b5..eb961b5c4d 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -57,11 +57,13 @@ async function mountMenu(props?: { runtimeMode="approval-required" traitsMenuContent={ provider === "codex" ? ( - + ) : ( ) diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index 43a146a9ae..5912d32e6c 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -61,6 +61,26 @@ describe("getComposerProviderState", () => { }); }); + it("drops explicit codex default/off overrides from dispatch while keeping the selected effort label", () => { + const state = getComposerProviderState({ + provider: "codex", + model: "gpt-5.4", + prompt: "", + modelOptions: { + codex: { + reasoningEffort: "high", + fastMode: false, + }, + }, + }); + + expect(state).toEqual({ + provider: "codex", + promptEffort: "high", + modelOptionsForDispatch: undefined, + }); + }); + it("returns Claude defaults for effort-capable models", () => { const state = getComposerProviderState({ provider: "claudeAgent", @@ -142,4 +162,24 @@ describe("getComposerProviderState", () => { }, }); }); + + it("drops explicit Claude default/off overrides from dispatch while keeping the selected effort label", () => { + const state = getComposerProviderState({ + provider: "claudeAgent", + model: "claude-opus-4-6", + prompt: "", + modelOptions: { + claudeAgent: { + effort: "high", + fastMode: false, + }, + }, + }); + + expect(state).toEqual({ + provider: "claudeAgent", + promptEffort: "high", + modelOptionsForDispatch: undefined, + }); + }); }); diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index 35a21c9af2..1d6e5439ea 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -1,5 +1,7 @@ import { type ModelSlug, + type ClaudeModelOptions, + type CodexModelOptions, type ProviderKind, type ProviderModelOptions, type ThreadId, @@ -38,11 +40,15 @@ type ProviderRegistryEntry = { renderTraitsMenuContent: (input: { threadId: ThreadId; model: ModelSlug; + modelOptions: ProviderModelOptions[ProviderKind] | undefined; + prompt: string; onPromptChange: (prompt: string) => void; }) => ReactNode; renderTraitsPicker: (input: { threadId: ThreadId; model: ModelSlug; + modelOptions: ProviderModelOptions[ProviderKind] | undefined; + prompt: string; onPromptChange: (prompt: string) => void; }) => ReactNode; }; @@ -61,8 +67,18 @@ const composerProviderRegistry: Record = { modelOptionsForDispatch: normalizedCodexOptions, }; }, - renderTraitsMenuContent: ({ threadId }) => , - renderTraitsPicker: ({ threadId }) => , + renderTraitsMenuContent: ({ threadId, modelOptions }) => ( + + ), + renderTraitsPicker: ({ threadId, modelOptions }) => ( + + ), }, claudeAgent: { getState: ({ model, prompt, modelOptions }) => { @@ -93,11 +109,23 @@ const composerProviderRegistry: Record = { ...(ultrathinkActive ? { modelPickerIconClassName: "ultrathink-chroma" } : {}), }; }, - renderTraitsMenuContent: ({ threadId, model, onPromptChange }) => ( - + renderTraitsMenuContent: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( + ), - renderTraitsPicker: ({ threadId, model, onPromptChange }) => ( - + renderTraitsPicker: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( + ), }, }; @@ -110,11 +138,15 @@ export function renderProviderTraitsMenuContent(input: { provider: ProviderKind; threadId: ThreadId; model: ModelSlug; + modelOptions: ProviderModelOptions[ProviderKind] | undefined; + prompt: string; onPromptChange: (prompt: string) => void; }): ReactNode { return composerProviderRegistry[input.provider].renderTraitsMenuContent({ threadId: input.threadId, model: input.model, + modelOptions: input.modelOptions, + prompt: input.prompt, onPromptChange: input.onPromptChange, }); } @@ -123,11 +155,15 @@ export function renderProviderTraitsPicker(input: { provider: ProviderKind; threadId: ThreadId; model: ModelSlug; + modelOptions: ProviderModelOptions[ProviderKind] | undefined; + prompt: string; onPromptChange: (prompt: string) => void; }): ReactNode { return composerProviderRegistry[input.provider].renderTraitsPicker({ threadId: input.threadId, model: input.model, + modelOptions: input.modelOptions, + prompt: input.prompt, onPromptChange: input.onPromptChange, }); } diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2171655519..fc9ca412f1 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -2,7 +2,7 @@ import { CODEX_REASONING_EFFORT_OPTIONS, type ClaudeCodeEffort, type CodexReasoningEffort, - DEFAULT_REASONING_EFFORT_BY_PROVIDER, + type ModelSlug, ModelSelection, ProjectId, ProviderInteractionMode, @@ -14,8 +14,14 @@ import { import * as Schema from "effect/Schema"; import * as Equal from "effect/Equal"; import { DeepMutable } from "effect/Types"; -import { normalizeModelSlug } from "@t3tools/shared/model"; +import { + getDefaultModel, + normalizeModelSlug, + resolveModelSlugForProvider, +} from "@t3tools/shared/model"; +import { useMemo } from "react"; import { getLocalStorageItem } from "./hooks/useLocalStorage"; +import { resolveAppModelSelection } from "./appSettings"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; import { type TerminalContextDraft, @@ -241,6 +247,23 @@ interface ComposerDraftStoreState { clearComposerContent: (threadId: ThreadId) => void; } +export interface EffectiveComposerModelState { + selectedModel: ModelSlug; + modelOptions: ProviderModelOptions | null; +} + +function providerModelOptionsFromSelection( + modelSelection: ModelSelection | null | undefined, +): ProviderModelOptions | null { + if (!modelSelection?.options) { + return null; + } + + return { + [modelSelection.provider]: modelSelection.options, + }; +} + const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze({ draftsByThreadId: {}, draftThreadsByThreadId: {}, @@ -385,20 +408,28 @@ function normalizeProviderModelOptions( ? legacy.effort : undefined; const codexFastMode = - codexCandidate?.fastMode === true || - (provider === "codex" && legacy?.codexFastMode === true) || - (typeof legacy?.serviceTier === "string" && legacy.serviceTier === "fast"); + codexCandidate?.fastMode === true + ? true + : codexCandidate?.fastMode === false + ? false + : (provider === "codex" && legacy?.codexFastMode === true) || + (typeof legacy?.serviceTier === "string" && legacy.serviceTier === "fast") + ? true + : undefined; const codex = - codexReasoningEffort && codexReasoningEffort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex + codexReasoningEffort !== undefined || codexFastMode !== undefined ? { - reasoningEffort: codexReasoningEffort, - ...(codexFastMode ? { fastMode: true } : {}), + ...(codexReasoningEffort !== undefined ? { reasoningEffort: codexReasoningEffort } : {}), + ...(codexFastMode !== undefined ? { fastMode: codexFastMode } : {}), } - : codexFastMode - ? { fastMode: true } - : undefined; + : undefined; - const claudeThinking = claudeCandidate?.thinking === false ? false : undefined; + const claudeThinking = + claudeCandidate?.thinking === true + ? true + : claudeCandidate?.thinking === false + ? false + : undefined; const claudeEffort: ClaudeCodeEffort | undefined = claudeCandidate?.effort === "low" || claudeCandidate?.effort === "medium" || @@ -407,17 +438,18 @@ function normalizeProviderModelOptions( claudeCandidate?.effort === "ultrathink" ? claudeCandidate.effort : undefined; - const claudeFastMode = claudeCandidate?.fastMode === true; + const claudeFastMode = + claudeCandidate?.fastMode === true + ? true + : claudeCandidate?.fastMode === false + ? false + : undefined; const claude = - claudeThinking === false || - (claudeEffort && claudeEffort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeAgent) || - claudeFastMode + claudeThinking !== undefined || claudeEffort !== undefined || claudeFastMode !== undefined ? { - ...(claudeThinking === false ? { thinking: false } : {}), - ...(claudeEffort && claudeEffort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeAgent - ? { effort: claudeEffort } - : {}), - ...(claudeFastMode ? { fastMode: true } : {}), + ...(claudeThinking !== undefined ? { thinking: claudeThinking } : {}), + ...(claudeEffort !== undefined ? { effort: claudeEffort } : {}), + ...(claudeFastMode !== undefined ? { fastMode: claudeFastMode } : {}), } : undefined; @@ -512,6 +544,38 @@ function replaceProviderModelOptions( }); } +export function deriveEffectiveComposerModelState(input: { + draft: Pick | null | undefined; + selectedProvider: ProviderKind; + threadModelSelection: ModelSelection | null | undefined; + projectModelSelection: ModelSelection | null | undefined; + customModelsByProvider: Record; +}): EffectiveComposerModelState { + const baseModel = resolveModelSlugForProvider( + input.selectedProvider, + input.threadModelSelection?.model ?? + input.projectModelSelection?.model ?? + getDefaultModel(input.selectedProvider), + ); + const selectedModel = input.draft?.modelSelection?.model + ? resolveAppModelSelection( + input.selectedProvider, + input.customModelsByProvider, + input.draft.modelSelection.model, + ) + : baseModel; + const modelOptions = + input.draft?.modelOptions ?? + providerModelOptionsFromSelection(input.threadModelSelection) ?? + providerModelOptionsFromSelection(input.projectModelSelection) ?? + null; + + return { + selectedModel, + modelOptions, + }; +} + function revokeObjectPreviewUrl(previewUrl: string): void { if (typeof URL === "undefined") { return; @@ -1916,6 +1980,34 @@ export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftS return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT); } +export function useEffectiveComposerModelState(input: { + threadId: ThreadId; + selectedProvider: ProviderKind; + threadModelSelection: ModelSelection | null | undefined; + projectModelSelection: ModelSelection | null | undefined; + customModelsByProvider: Record; +}): EffectiveComposerModelState { + const draft = useComposerThreadDraft(input.threadId); + + return useMemo( + () => + deriveEffectiveComposerModelState({ + draft, + selectedProvider: input.selectedProvider, + threadModelSelection: input.threadModelSelection, + projectModelSelection: input.projectModelSelection, + customModelsByProvider: input.customModelsByProvider, + }), + [ + draft, + input.customModelsByProvider, + input.projectModelSelection, + input.selectedProvider, + input.threadModelSelection, + ], + ); +} + /** * Clear draft threads that have been promoted to server threads. * From 3c61f4bedeafedf2c538d168e9d7d1b26c710b32 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 18:15:18 -0700 Subject: [PATCH 31/38] Preserve explicit model overrides in draft selection - keep default-state overrides in model selection updates - retain provider model options instead of clearing them when values normalize --- apps/web/src/composerDraftStore.test.ts | 30 ++++++++++++++++++++----- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 2627b387c5..f3068d37a9 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -697,7 +697,7 @@ describe("composerDraftStore modelSelection", () => { ); }); - it("removes selection options when the patched provider options normalize empty", () => { + it("keeps explicit default-state overrides on the selection", () => { const store = useComposerDraftStore.getState(); store.setModelSelection( @@ -712,13 +712,21 @@ describe("composerDraftStore modelSelection", () => { }); expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( - modelSelection("claudeAgent", "claude-opus-4-6"), + modelSelection("claudeAgent", "claude-opus-4-6", { + thinking: true, + }), + ); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual( + providerModelOptions({ + claudeAgent: { + thinking: true, + }, + }), ); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toBeNull(); expect(useComposerDraftStore.getState().stickyModelSelection).toBeNull(); }); - it("removes model options entirely when the last provider entry normalizes empty", () => { + it("keeps explicit off/default codex overrides on the selection", () => { const store = useComposerDraftStore.getState(); store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4", { fastMode: true })); @@ -729,9 +737,19 @@ describe("composerDraftStore modelSelection", () => { }); expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( - modelSelection("codex", "gpt-5.4"), + modelSelection("codex", "gpt-5.4", { + reasoningEffort: "high", + fastMode: false, + }), + ); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual( + providerModelOptions({ + codex: { + reasoningEffort: "high", + fastMode: false, + }, + }), ); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toBeNull(); }); it("updates only the draft when sticky persistence is omitted", () => { From 8f7f509197a1013db4c3d6b6374680ee9be0deec Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 20:33:09 -0700 Subject: [PATCH 32/38] unify traitpicker and make model list capability driven --- .plans/18-provider-agnostic-cleanup.md | 753 ++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 92 ++- apps/web/src/components/ChatView.browser.tsx | 57 +- apps/web/src/components/ChatView.tsx | 17 +- .../components/chat/ClaudeTraitsPicker.tsx | 272 ------- .../chat/CodexTraitsPicker.browser.tsx | 154 ---- .../src/components/chat/CodexTraitsPicker.tsx | 151 ---- .../CompactComposerControlsMenu.browser.tsx | 46 +- .../components/chat/ProviderHealthBanner.tsx | 9 +- ...r.browser.tsx => TraitsPicker.browser.tsx} | 242 +++++- apps/web/src/components/chat/TraitsPicker.tsx | 320 ++++++++ .../chat/composerProviderRegistry.tsx | 135 ++-- apps/web/src/composerDraftStore.test.ts | 136 ++-- apps/web/src/composerDraftStore.ts | 543 ++++++++----- apps/web/src/hooks/useHandleNewThread.ts | 14 +- packages/contracts/src/model.ts | 184 ++++- packages/shared/src/model.test.ts | 142 ++-- packages/shared/src/model.ts | 149 +--- 18 files changed, 2139 insertions(+), 1277 deletions(-) create mode 100644 .plans/18-provider-agnostic-cleanup.md delete mode 100644 apps/web/src/components/chat/ClaudeTraitsPicker.tsx delete mode 100644 apps/web/src/components/chat/CodexTraitsPicker.browser.tsx delete mode 100644 apps/web/src/components/chat/CodexTraitsPicker.tsx rename apps/web/src/components/chat/{ClaudeTraitsPicker.browser.tsx => TraitsPicker.browser.tsx} (51%) create mode 100644 apps/web/src/components/chat/TraitsPicker.tsx diff --git a/.plans/18-provider-agnostic-cleanup.md b/.plans/18-provider-agnostic-cleanup.md new file mode 100644 index 0000000000..1543f02ba2 --- /dev/null +++ b/.plans/18-provider-agnostic-cleanup.md @@ -0,0 +1,753 @@ +# 18 - Provider-Agnostic Cleanup + +Follow-up to the `t3code/provider-kind-model` PR which introduced the `ModelSelection` +discriminated union and removed `inferProviderForModel()`. Three items remain to complete +the provider-agnostic vision and make adding new providers mechanical. + +--- + +## Item 1: Consolidate dual model-options representation + +### Problem + +The composer draft store carries two parallel representations of model options: + +``` +draft.modelSelection: { provider: "codex", model: "gpt-5.4", options: { fastMode: true } } +draft.modelOptions: { codex: { fastMode: true }, claudeAgent: { effort: "max" } } +``` + +Every mutation must sync both via `syncModelSelectionOptions()` and +`mergeModelSelectionIntoProviderModelOptions()`. The sync logic is a bug surface and +the dual representation creates ambiguity about which is authoritative. + +The `ProviderModelOptions` bag exists for a good reason: when you switch providers and +switch back, your per-provider options should survive the round-trip. The fix is not to +remove the bag concept, but to eliminate the *dual* representation. + +### Target state + +Replace the parallel fields with a single `ModelSelection`-per-provider map: + +```ts +// Before +draft.modelSelection: ModelSelection | null // active selection (provider + model + options) +draft.modelOptions: ProviderModelOptions | null // bag of options keyed by provider + +// After +draft.modelSelectionByProvider: Partial> +draft.activeProvider: ProviderKind | null +``` + +The active `ModelSelection` is derived: +`draft.modelSelectionByProvider[draft.activeProvider]`. No sync needed -- each provider's +full selection (model + options) lives in one place. Switching providers changes +`activeProvider`; the old provider's entry is preserved. + +Sticky state follows the same shape: +```ts +stickyModelSelectionByProvider: Partial> +``` + +### Phase 1A: Refactor the draft store internals + +**Files:** `apps/web/src/composerDraftStore.ts`, `apps/web/src/composerDraftStore.test.ts` + +1. Replace `ComposerThreadDraftState` fields: + - Remove `modelSelection: ModelSelection | null` + - Remove `modelOptions: ProviderModelOptions | null` + - Add `modelSelectionByProvider: Partial>` + - Add `activeProvider: ProviderKind | null` + +2. Replace global sticky fields in `ComposerDraftStoreState`: + - Remove `stickyModelSelection: ModelSelection | null` + - Remove `stickyModelOptions: ProviderModelOptions` + - Add `stickyModelSelectionByProvider: Partial>` + +3. Delete sync functions that exist only to maintain the dual representation: + - `syncModelSelectionOptions()` + - `mergeModelSelectionIntoProviderModelOptions()` + - `replaceProviderModelOptions()` + +4. Simplify store actions: + - `setModelSelection(threadId, selection)` -- writes to + `draft.modelSelectionByProvider[selection.provider]` and sets + `draft.activeProvider = selection.provider` + - `setProviderModelOptions(threadId, provider, options)` -- updates + `draft.modelSelectionByProvider[provider].options` in place. If the entry + doesn't exist yet, create it with the provider's default model. + - `setStickyModelSelection(selection)` -- writes to + `stickyModelSelectionByProvider[selection.provider]` + - Remove `setModelOptions()` (the bag setter) and `setStickyModelOptions()` + +5. Rewrite `deriveEffectiveComposerModelState()`: + - Read options from `draft.modelSelectionByProvider[provider].options` + - Fall back to `threadModelSelection.options` then + `projectModelSelection.options` + - No bag indexing needed + +6. Update persistence schema: + - `PersistedComposerThreadDraftState` replaces `modelSelection` + `modelOptions` + with `modelSelectionByProvider` + `activeProvider` + - `PersistedComposerDraftStoreState` replaces `stickyModelSelection` + + `stickyModelOptions` with `stickyModelSelectionByProvider` + - Bump storage version to 3 + - Write v2 -> v3 migration: for each draft, reconstruct + `modelSelectionByProvider` from the old `modelSelection` (active provider entry) + merged with `modelOptions` (other providers' entries with default models). + For sticky state, same approach. + +7. Update legacy migration code (`LegacyCodexFields`, `LegacyStickyModelFields`, + `LegacyThreadModelFields`) to produce the new shape directly. These can be + simplified since they no longer need to produce two parallel outputs. + +### Phase 1B: Remove `ProviderModelOptions` from contracts and UI consumers + +**Files:** `packages/contracts/src/model.ts`, `apps/web/src/components/chat/composerProviderRegistry.tsx`, +`apps/web/src/components/ChatView.tsx`, `apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx`, +`apps/web/src/components/chat/ClaudeTraitsPicker.tsx`, `apps/web/src/components/chat/CodexTraitsPicker.tsx` + +1. In `contracts/model.ts`: delete the `ProviderModelOptions` schema and type export. + Keep `CodexModelOptions` and `ClaudeModelOptions` -- they're referenced by the + `ModelSelection` variants in `orchestration.ts`. + +2. In `composerProviderRegistry.tsx`: change `ComposerProviderStateInput` to accept + `options: CodexModelOptions | ClaudeModelOptions | undefined` instead of + `modelOptions: ProviderModelOptions | null | undefined`. The registry entry + receives already-extracted provider options from `modelSelection.options`. + +3. In `ChatView.tsx`: `useEffectiveComposerModelState` returns + `{ selectedModel, options }` instead of `{ selectedModel, modelOptions }`. + `getComposerProviderState` receives the typed options directly. + +4. In `CompactComposerControlsMenu.browser.tsx`: remove the + `provider === "codex" ? props.modelOptions.codex : props.modelOptions.claudeAgent` + ternary. Component receives pre-extracted typed options. + +5. Trait pickers already receive typed `ClaudeModelOptions` / `CodexModelOptions`. + Only parent callsite plumbing changes. + +### Phase 1C: Server-side verification + +**Files:** `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts`, +`apps/server/src/provider/Layers/ProviderService.ts`, `apps/server/src/wsServer.ts` + +1. Verify the server never reads from a `ProviderModelOptions` bag. From analysis of + the current code, it doesn't -- the server works exclusively with + `ModelSelection.options`. This phase is a verification pass. + +2. Remove any residual imports of `ProviderModelOptions` in server code. + +3. Grep for remaining references to ensure the type is fully eliminated. + +--- + +## Item 2: Data-driven model capabilities + +### Problem + +`packages/shared/src/model.ts` is full of imperative capability checks: + +```ts +const CLAUDE_OPUS_4_6_MODEL = "claude-opus-4-6"; +export function supportsClaudeFastMode(model) { + return normalizeModelSlug(model, "claudeAgent") === CLAUDE_OPUS_4_6_MODEL; +} +``` + +Adding a new model or provider requires touching 5+ functions. The capability +information should be *data* on the model definition, not scattered conditionals. +This is the primary bottleneck for adding new providers. + +### Target state + +Model capabilities are declared inline with model definitions in `contracts/model.ts`. +The imperative `supportsXxx` functions in `shared/model.ts` become thin lookups against +a capabilities index. Adding a new model means adding one entry to the data table. + +### Phase 2A: Define a model capability schema + +**File:** `packages/contracts/src/model.ts` + +1. Define the capability shape: + +```ts +type ModelCapabilities = { + readonly reasoningEffortLevels: readonly string[]; + readonly supportsFastMode: boolean; + readonly supportsThinkingToggle: boolean; +}; + +type ModelDefinition = { + readonly slug: string; + readonly name: string; + readonly capabilities: ModelCapabilities; +}; +``` + +2. Embed capabilities in `MODEL_OPTIONS_BY_PROVIDER`: + +```ts +export const MODEL_OPTIONS_BY_PROVIDER = { + codex: [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + capabilities: { + reasoningEffortLevels: CODEX_REASONING_EFFORT_OPTIONS, + supportsFastMode: true, + supportsThinkingToggle: false, + }, + }, + // ... other codex models + ], + claudeAgent: [ + { + slug: "claude-opus-4-6", + name: "Claude Opus 4.6", + capabilities: { + reasoningEffortLevels: ["low", "medium", "high", "max", "ultrathink"], + supportsFastMode: true, + supportsThinkingToggle: false, + }, + }, + { + slug: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + capabilities: { + reasoningEffortLevels: ["low", "medium", "high", "ultrathink"], + supportsFastMode: false, + supportsThinkingToggle: false, + }, + }, + { + slug: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: true, + }, + }, + ], +} as const satisfies Record; +``` + +3. Build a lookup index: + +```ts +export const MODEL_CAPABILITIES_INDEX: Record< + ProviderKind, + Record +> = Object.fromEntries( + Object.entries(MODEL_OPTIONS_BY_PROVIDER).map(([provider, models]) => [ + provider, + Object.fromEntries(models.map((m) => [m.slug, m.capabilities])), + ]), +) as Record>; +``` + +4. Define provider-level defaults for custom/unknown models: + +```ts +export const DEFAULT_CAPABILITIES_BY_PROVIDER: Record = { + codex: { + reasoningEffortLevels: CODEX_REASONING_EFFORT_OPTIONS as unknown as string[], + supportsFastMode: true, + supportsThinkingToggle: false, + }, + claudeAgent: { + reasoningEffortLevels: ["low", "medium", "high"], + supportsFastMode: false, + supportsThinkingToggle: false, + }, +}; +``` + +### Phase 2B: Replace imperative gates with data lookups + +**File:** `packages/shared/src/model.ts` + +1. Add a central capability resolver: + +```ts +export function getModelCapabilities( + provider: ProviderKind, + model: string | null | undefined, +): ModelCapabilities { + const slug = normalizeModelSlug(model, provider); + if (slug && MODEL_CAPABILITIES_INDEX[provider]?.[slug]) { + return MODEL_CAPABILITIES_INDEX[provider][slug]; + } + return DEFAULT_CAPABILITIES_BY_PROVIDER[provider]; +} +``` + +2. Rewrite `supportsXxx` functions as thin lookups (signatures unchanged, all call + sites continue to work): + +```ts +export function supportsClaudeFastMode(model: string | null | undefined): boolean { + return getModelCapabilities("claudeAgent", model).supportsFastMode; +} + +export function supportsClaudeThinkingToggle(model: string | null | undefined): boolean { + return getModelCapabilities("claudeAgent", model).supportsThinkingToggle; +} + +export function supportsClaudeAdaptiveReasoning(model: string | null | undefined): boolean { + return getModelCapabilities("claudeAgent", model).reasoningEffortLevels.length > 0; +} + +export function supportsClaudeMaxEffort(model: string | null | undefined): boolean { + return getModelCapabilities("claudeAgent", model).reasoningEffortLevels.includes("max"); +} + +export function supportsClaudeUltrathinkKeyword(model: string | null | undefined): boolean { + return supportsClaudeAdaptiveReasoning(model); +} +``` + +3. Rewrite `getReasoningEffortOptions()`: + +```ts +export function getReasoningEffortOptions( + provider: ProviderKind, + model?: string | null, +): readonly string[] { + return getModelCapabilities(provider, model).reasoningEffortLevels; +} +``` + + Remove the overloaded signatures that return provider-specific types. The return + type becomes `readonly string[]`. Consumers that need the specific type can cast, + or we can add type predicates later. + +4. Rewrite `normalizeClaudeModelOptions()` and `normalizeCodexModelOptions()` to use + `getModelCapabilities()` internally instead of calling individual `supportsXxx` + functions. Logic stays the same, data source changes. + +5. Remove hardcoded model constants: + - `CLAUDE_OPUS_4_6_MODEL` + - `CLAUDE_SONNET_4_6_MODEL` + - `CLAUDE_HAIKU_4_5_MODEL` + +### Phase 2C: Verify call sites are unchanged + +**Files:** `apps/web/src/components/chat/ClaudeTraitsPicker.tsx`, +`apps/web/src/components/chat/CodexTraitsPicker.tsx`, +`apps/web/src/components/chat/composerProviderRegistry.tsx`, +`apps/server/src/provider/Layers/ClaudeAdapter.ts` + +1. All existing `supportsXxx()` call sites continue to work -- the function signatures + are unchanged, only the implementation is swapped to data lookups. + +2. `ClaudeAdapter.ts` calls to `supportsClaudeFastMode()`, + `supportsClaudeThinkingToggle()`, `getReasoningEffortOptions()` still work + identically. + +3. Trait pickers call `supportsClaudeFastMode(model)` etc. -- no change needed. + +4. **Future benefit**: adding a new provider means defining its models with inline + capabilities in `MODEL_OPTIONS_BY_PROVIDER`. No new `supportsXxx` functions needed. + Custom models from app settings get reasonable behavior via + `DEFAULT_CAPABILITIES_BY_PROVIDER[provider]`. + +### Phase 2D: Provider-generic capability functions + +Add provider-generic alternatives that don't hardcode "claude" in the name: + +```ts +export function modelSupportsFastMode(provider: ProviderKind, model: string | null | undefined): boolean { + return getModelCapabilities(provider, model).supportsFastMode; +} +``` + +The Claude-specific wrappers can remain as aliases for backward compatibility, but new +code should prefer the generic versions. + +### Phase 2E: Unify trait picker components + +With data-driven capabilities, the separate `ClaudeTraitsPicker` and `CodexTraitsPicker` +components become unnecessary. They render the same UI primitives in the same structure, +just parameterized differently: + +| Section | Codex | Claude | +|---------|-------|--------| +| Effort/reasoning radio group | Always (label "Reasoning") | If model has effort levels (label "Effort") | +| Thinking toggle | Never | If `supportsThinkingToggle` (Haiku) | +| Fast mode toggle | Always | If `supportsFastMode` (Opus) | +| Trigger label | `"{effort} · Fast"` | `"{effort/thinking} · Fast"` | + +The only truly provider-specific behavior is Claude's **ultrathink prompt injection**: +when effort is "ultrathink", it modifies the prompt text instead of setting an option, +and locks the effort radio group with an explanatory message. + +#### 2E.1: Add trait metadata to capabilities + +**File:** `packages/contracts/src/model.ts` + +Extend `ModelCapabilities` with display and behavioral metadata: + +```ts +type ModelCapabilities = { + readonly reasoningEffortLevels: readonly string[]; + readonly supportsFastMode: boolean; + readonly supportsThinkingToggle: boolean; + readonly effortSectionLabel: string; // "Reasoning" | "Effort" + readonly promptInjectedEffortLevels: readonly string[]; // ["ultrathink"] for Claude, [] for Codex +}; +``` + +Add a provider-level effort label config: + +```ts +export const EFFORT_DISPLAY_LABELS: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max", + ultrathink: "Ultrathink", +}; +``` + +This replaces the separate `CLAUDE_EFFORT_LABELS` and `CODEX_REASONING_LABELS` records +in each picker component. + +#### 2E.2: Create unified `TraitsPicker` component + +**File:** `apps/web/src/components/chat/TraitsPicker.tsx` (new, replaces both pickers) + +The component receives `provider`, `model`, `threadId`, `options`, `prompt`, +`onPromptChange` and renders sections purely from capabilities data: + +```tsx +function TraitsMenuContent({ provider, model, threadId, options, prompt, onPromptChange }) { + const caps = getModelCapabilities(provider, model); + const effortLevels = caps.reasoningEffortLevels; + const promptInjected = caps.promptInjectedEffortLevels; + + return ( + <> + {effortLevels.length > 0 && ( + + )} + {caps.supportsThinkingToggle && ( + + )} + {caps.supportsFastMode && ( + + )} + + ); +} +``` + +Key design points: + +- **Effort section** handles both normal effort (set via options) and prompt-injected + effort (like ultrathink) via the `promptInjectedLevels` parameter. If the current + prompt contains a prompt-injected effort keyword, the radio group is locked with an + explanatory message -- same as current Claude behavior, but driven by data. + +- **Trigger label** is built generically: collect the active effort label (or thinking + state if no effort), append "Fast" if fast mode is on, join with " · ". + +- **`onEffortChange` handler** checks `promptInjectedLevels.includes(nextEffort)`. If + true, it injects into the prompt via `onPromptChange`. Otherwise, it updates options + via `setProviderModelOptions`. This generalizes the current ultrathink-specific logic. + +- **No provider `if` branches** in the component body. All behavior flows from `caps`. + +#### 2E.3: Update the provider registry + +**File:** `apps/web/src/components/chat/composerProviderRegistry.tsx` + +The registry entries for both providers now point to the same `TraitsMenuContent` and +`TraitsPicker` components. The registry still exists (it's a `Record` +so it's exhaustive-safe), but both entries delegate to the unified component: + +```ts +const composerProviderRegistry: Record = { + codex: { + getState: (input) => getProviderStateFromCapabilities(input), + renderTraitsMenuContent: (input) => , + renderTraitsPicker: (input) => , + }, + claudeAgent: { + getState: (input) => getProviderStateFromCapabilities(input), + renderTraitsMenuContent: (input) => , + renderTraitsPicker: (input) => , + }, +}; +``` + +At this point, the registry becomes a thin pass-through. It can optionally be simplified +further (e.g. a single `getProviderRegistryEntry()` that returns the same object for +all providers), but the `Record` shape is still useful as a compile- +time exhaustiveness check when new providers are added. + +`getProviderStateFromCapabilities` replaces the two inline `getState` implementations. +It uses `getModelCapabilities(provider, model)` to determine prompt effort, normalize +options, and compute CSS classes (e.g. ultrathink frame styling is driven by +`caps.promptInjectedEffortLevels` + prompt content, not by `provider === "claudeAgent"`). + +#### 2E.4: Delete old picker components + +**Files to delete:** +- `apps/web/src/components/chat/ClaudeTraitsPicker.tsx` +- `apps/web/src/components/chat/CodexTraitsPicker.tsx` + +**Files to update:** +- `apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx` -- rename to + `TraitsPicker.browser.tsx`, update to test unified component with both providers +- `apps/web/src/components/chat/CodexTraitsPicker.browser.tsx` -- merge test cases into + `TraitsPicker.browser.tsx` +- `apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx` -- import from + unified component +- Any other import sites + +#### 2E.5: Normalize options generically + +**File:** `packages/shared/src/model.ts` + +Replace `normalizeClaudeModelOptions()` and `normalizeCodexModelOptions()` with a single +generic function: + +```ts +export function normalizeModelOptions( + provider: ProviderKind, + model: string | null | undefined, + options: Record | null | undefined, +): Record | undefined { + const caps = getModelCapabilities(provider, model); + const result: Record = {}; + + // Reasoning effort + if (options?.reasoningEffort || options?.effort) { + const effortKey = provider === "codex" ? "reasoningEffort" : "effort"; + const raw = (options?.reasoningEffort ?? options?.effort) as string; + const resolved = caps.reasoningEffortLevels.includes(raw) ? raw : null; + const isPromptInjected = caps.promptInjectedEffortLevels?.includes(resolved ?? ""); + const isDefault = resolved === getDefaultReasoningEffort(provider); + if (resolved && !isPromptInjected && !isDefault) { + result[effortKey] = resolved; + } + } + + // Thinking toggle + if (caps.supportsThinkingToggle && options?.thinking === false) { + result.thinking = false; + } + + // Fast mode + if (caps.supportsFastMode && options?.fastMode === true) { + result.fastMode = true; + } + + return Object.keys(result).length > 0 ? result : undefined; +} +``` + +Note: the effort key name difference (`reasoningEffort` for Codex vs `effort` for +Claude) is an existing schema inconsistency. This can either be normalized in a future +schema migration, or handled via a `effortOptionKey` field on the capabilities. For now +the function handles both. + +The old `normalizeClaudeModelOptions()` and `normalizeCodexModelOptions()` become thin +wrappers that call `normalizeModelOptions()` with the appropriate provider, for backward +compatibility at existing call sites. + +--- + +## Item 3: Exhaustiveness enforcement + +### Problem + +Ternary branches like `provider === "codex" ? ... : ...` silently give the `else` branch +to any new provider added to `ProviderKind`. The `Record` pattern +(used in contracts and the provider registry) is already exhaustive-safe, but ~10 sites +use unsafe ternaries. + +### Target state + +All provider dispatches either use `Record` lookups (already +compile-time safe) or `switch` statements with `assertNever` in the default branch. +A lint marker or convention makes the pattern obvious. + +### Phase 3A: Add an `assertNever` utility + +**File:** `packages/shared/src/assertNever.ts` (new) + +```ts +/** + * Compile-time exhaustiveness check. Use in `default:` branches of switch + * statements over discriminated unions. Produces a type error if any variant + * is unhandled, and throws at runtime if reached. + */ +export function assertNever(value: never, message?: string): never { + throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`); +} +``` + +The codebase already uses the `_exhaustiveCheck: never` pattern in `wsServer.ts`. +This standardizes it as a reusable utility. + +### Phase 3B: Add `PROVIDER_DISPLAY_NAMES` to contracts + +**File:** `packages/contracts/src/model.ts` + +```ts +export const PROVIDER_DISPLAY_NAMES: Record = { + codex: "Codex", + claudeAgent: "Claude", +}; +``` + +This eliminates display-name ternaries across the UI. + +### Phase 3C: Convert unsafe ternaries + +Listed by priority (most likely to cause silent bugs when a new provider is added): + +1. **`apps/web/src/composerDraftStore.ts` line 492** + ```ts + // Before + const options = provider === "codex" ? modelOptions?.codex : modelOptions?.claudeAgent; + // After (eliminated entirely by Item 1 -- modelSelectionByProvider replaces this) + ``` + +2. **`apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx` line 59** + ```ts + // Before: inline ternary selecting which traits component to render + provider === "codex" ? : + + // After: delegate to the existing registry (already Record) + renderProviderTraitsMenuContent({ provider, threadId, model, ... }) + ``` + +3. **`apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx` line 36** + ```ts + // Before + const providerModelOptions = provider === "codex" + ? props.modelOptions.codex : props.modelOptions.claudeAgent; + // After (eliminated by Item 1 -- options arrive pre-extracted) + ``` + +4. **`apps/web/src/composerDraftStore.ts` lines 403, 415, 490** + Legacy migration code. Low priority since it runs once on old data. Convert to + switch + assertNever for safety, or leave with a `// LEGACY` comment if migration + removal is planned. + +5. **`apps/web/src/components/chat/ProviderHealthBanner.tsx` lines 16-20** + ```ts + // Before + const providerLabel = status.provider === "codex" ? "Codex" + : status.provider === "claudeAgent" ? "Claude" : status.provider; + // After + const providerLabel = PROVIDER_DISPLAY_NAMES[status.provider] ?? status.provider; + ``` + +6. **`apps/web/src/store.ts` `toLegacyProvider()`** + ```ts + // Before: coerces untyped string|null to ProviderKind with "codex" default + // After: if providerName comes from a typed source, remove the function. + // If it comes from an untyped runtime source (e.g. session event), keep it + // but add a warning log for unknown values. + ``` + +7. **`packages/shared/src/model.ts` `getReasoningEffortOptions()`** + ```ts + // Before: if (provider === "claudeAgent") { ... } return REASONING_EFFORT_OPTIONS_BY_PROVIDER[provider]; + // After: eliminated by Item 2 (data-driven capability lookup) + ``` + +8. **`apps/web/src/components/ChatView.tsx` line 199** + ```ts + // Before + if (params.provider === "claudeAgent" && params.effort === "ultrathink") { ... } + // After (with Item 2 in place): + const caps = getModelCapabilities(params.provider, params.model); + if (caps.reasoningEffortLevels.includes("ultrathink") && params.effort === "ultrathink") { ... } + ``` + +### Phase 3D: Adapter guards are correct as-is + +The patterns in `ClaudeAdapter.ts` and `CodexAdapter.ts` like +`input.modelSelection?.provider === "claudeAgent"` are **not** provider dispatches. +They are adapter-internal guards: "only process my own provider's data." These are +correct and do not need exhaustiveness -- an adapter should only care about its own +provider. No changes needed. + +--- + +## Execution order + +``` +Item 2 (data-driven capabilities) -- purely additive, no breaking changes + | + v +Item 1 (consolidate dual options) -- biggest refactor, touches draft store heavily + | + v +Item 3 (exhaustiveness enforcement) -- small cleanup pass, most sites already fixed by 1 & 2 +``` + +**Item 2 first**: phases 2A-2D are purely additive -- every `supportsXxx` function +becomes a data lookup with identical signatures. Tests pass with zero call-site changes. +Phase 2E (unified trait picker) builds on the capabilities data to collapse +`ClaudeTraitsPicker` and `CodexTraitsPicker` into a single capabilities-driven component. +Phases 2A-2D and 2E can be separate PRs or one combined PR. + +**Item 1 second**: the largest change, touching the draft store, persistence, and +localStorage migration. Doing Item 2 first means the capability logic is already clean, +reducing cognitive load during this refactor. Warrants its own PR with careful testing +of the v2 -> v3 storage migration. + +**Item 3 last**: after Items 1 and 2, most unsafe ternaries are already eliminated. What +remains is adding `assertNever`, `PROVIDER_DISPLAY_NAMES`, and converting a handful of +surviving dispatch sites. Can be batched with Item 2 into a single PR since both are +small and contained. + +--- + +## Files affected (summary) + +### Item 1 +- `packages/contracts/src/model.ts` -- remove `ProviderModelOptions` +- `apps/web/src/composerDraftStore.ts` -- major refactor +- `apps/web/src/composerDraftStore.test.ts` -- update tests +- `apps/web/src/components/ChatView.tsx` -- plumbing changes +- `apps/web/src/components/chat/composerProviderRegistry.tsx` -- input type change +- `apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx` -- simplified +- `apps/web/src/hooks/useHandleNewThread.ts` -- reads sticky state differently + +### Item 2 +- `packages/contracts/src/model.ts` -- add capabilities to model definitions, add `EFFORT_DISPLAY_LABELS` +- `packages/shared/src/model.ts` -- rewrite ~10 functions to data lookups, add `normalizeModelOptions` +- `packages/shared/src/model.test.ts` -- same tests, same assertions +- `apps/web/src/components/chat/TraitsPicker.tsx` -- new unified component +- `apps/web/src/components/chat/ClaudeTraitsPicker.tsx` -- deleted +- `apps/web/src/components/chat/CodexTraitsPicker.tsx` -- deleted +- `apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx` -- merged into `TraitsPicker.browser.tsx` +- `apps/web/src/components/chat/CodexTraitsPicker.browser.tsx` -- merged into `TraitsPicker.browser.tsx` +- `apps/web/src/components/chat/composerProviderRegistry.tsx` -- both entries use unified component +- `apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx` -- import from unified + +### Item 3 +- `packages/shared/src/assertNever.ts` -- new utility +- `packages/contracts/src/model.ts` -- add `PROVIDER_DISPLAY_NAMES` +- `apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx` -- use registry +- `apps/web/src/components/chat/ProviderHealthBanner.tsx` -- use display names +- `apps/web/src/components/ChatView.tsx` -- use capability check +- `apps/web/src/store.ts` -- tighten `toLegacyProvider` diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 4c5ee29e51..af88fa634a 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -38,15 +38,13 @@ import { ThreadId, TurnId, type UserInputQuestion, + ClaudeCodeEffort, } from "@t3tools/contracts"; import { + hasEffortLevel, applyClaudePromptEffortPrefix, - getEffectiveClaudeCodeEffort, - getReasoningEffortOptions, - resolveReasoningEffortForProvider, - supportsClaudeFastMode, - supportsClaudeThinkingToggle, - supportsClaudeUltrathinkKeyword, + getModelCapabilities, + trimOrNull, } from "@t3tools/shared/model"; import { Cause, @@ -158,6 +156,7 @@ interface ClaudeSessionContext { readonly inFlightTools: Map; turnState: ClaudeTurnState | undefined; lastKnownContextWindow: number | undefined; + lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; lastAssistantUuid: string | undefined; lastThreadStartedId: string | undefined; stopped: boolean; @@ -211,6 +210,15 @@ function normalizeClaudeStreamMessages(cause: Cause.Cause): ReadonlyArray return squashed.length > 0 ? [squashed] : []; } +function getEffectiveClaudeCodeEffort( + effort: ClaudeCodeEffort | null | undefined, +): Exclude | null { + if (!effort) { + return null; + } + return effort === "ultrathink" ? null : effort; +} + function isClaudeInterruptedMessage(message: string): boolean { const normalized = message.toLowerCase(); return ( @@ -512,21 +520,16 @@ const CLAUDE_SETTING_SOURCES = [ ] as const satisfies ReadonlyArray; function buildPromptText(input: ProviderSendTurnInput): string { - const requestedEffort = resolveReasoningEffortForProvider( - "claudeAgent", - input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null, - ); - const supportedEffortOptions = getReasoningEffortOptions( - "claudeAgent", - input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined, - ); + const rawEffort = + input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null; + const requestedEffort = trimOrNull(rawEffort); + const claudeModel = + input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined; + const caps = getModelCapabilities("claudeAgent", claudeModel); const promptEffort = - requestedEffort === "ultrathink" && - supportsClaudeUltrathinkKeyword( - input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined, - ) + requestedEffort === "ultrathink" && caps.reasoningEffortLevels.length > 0 ? "ultrathink" - : requestedEffort && supportedEffortOptions.includes(requestedEffort) + : requestedEffort && hasEffortLevel(caps, requestedEffort) ? requestedEffort : null; return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); @@ -1374,14 +1377,33 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { const resultUsage = result?.usage && typeof result.usage === "object" ? { ...result.usage } : undefined; const resultContextWindow = maxClaudeContextWindowFromModelUsage(result?.modelUsage); - const usageSnapshot = normalizeClaudeTokenUsage( - resultUsage, - resultContextWindow ?? context.lastKnownContextWindow, - ); if (resultContextWindow !== undefined) { context.lastKnownContextWindow = resultContextWindow; } + // The SDK result.usage contains *accumulated* totals across all API calls + // (input_tokens, cache_read_input_tokens, etc. summed over every request). + // This does NOT represent the current context window size. + // Instead, use the last known context-window-accurate usage from task_progress + // events and treat the accumulated total as totalProcessedTokens. + const accumulatedSnapshot = normalizeClaudeTokenUsage( + resultUsage, + resultContextWindow ?? context.lastKnownContextWindow, + ); + const lastGoodUsage = context.lastKnownTokenUsage; + const maxTokens = resultContextWindow ?? context.lastKnownContextWindow; + const usageSnapshot: ThreadTokenUsageSnapshot | undefined = lastGoodUsage + ? { + ...lastGoodUsage, + ...(typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0 + ? { maxTokens } + : {}), + ...(accumulatedSnapshot && accumulatedSnapshot.usedTokens > lastGoodUsage.usedTokens + ? { totalProcessedTokens: accumulatedSnapshot.usedTokens } + : {}), + } + : accumulatedSnapshot; + const turnState = context.turnState; if (!turnState) { if (usageSnapshot) { @@ -2057,6 +2079,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { context.lastKnownContextWindow, ); if (normalizedUsage) { + context.lastKnownTokenUsage = normalizedUsage; const usageStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ ...base, @@ -2088,6 +2111,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { context.lastKnownContextWindow, ); if (normalizedUsage) { + context.lastKnownTokenUsage = normalizedUsage; const usageStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ ...base, @@ -2706,24 +2730,13 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { const providerOptions = input.providerOptions?.claudeAgent; const modelSelection = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; - const requestedEffort = resolveReasoningEffortForProvider( - "claudeAgent", - modelSelection?.options?.effort ?? null, - ); - const supportedEffortOptions = getReasoningEffortOptions( - "claudeAgent", - modelSelection?.model, - ); + const requestedEffort = trimOrNull(modelSelection?.options?.effort ?? null); + const caps = getModelCapabilities("claudeAgent", modelSelection?.model); const effort = - requestedEffort && supportedEffortOptions.includes(requestedEffort) - ? requestedEffort - : null; - const fastMode = - modelSelection?.options?.fastMode === true && - supportsClaudeFastMode(modelSelection?.model); + requestedEffort && hasEffortLevel(caps, requestedEffort) ? requestedEffort : null; + const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode; const thinking = - typeof modelSelection?.options?.thinking === "boolean" && - supportsClaudeThinkingToggle(modelSelection?.model) + typeof modelSelection?.options?.thinking === "boolean" && caps.supportsThinkingToggle ? modelSelection.options.thinking : undefined; const effectiveEffort = getEffectiveClaudeCodeEffort(effort); @@ -2806,6 +2819,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { inFlightTools, turnState: undefined, lastKnownContextWindow: undefined, + lastKnownTokenUsage: undefined, lastAssistantUuid: resumeState?.resumeSessionAt, lastThreadStartedId: undefined, stopped: false, diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 13671016c5..cec0ebc278 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -826,8 +826,7 @@ describe("ChatView timeline estimator parity (full app)", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, - stickyModelSelection: null, - stickyModelOptions: {}, + stickyModelSelectionByProvider: {}, }); useStore.setState({ projects: [], @@ -1492,18 +1491,14 @@ describe("ChatView timeline estimator parity (full app)", () => { it("snapshots sticky codex settings into a new draft thread", async () => { useComposerDraftStore.setState({ - stickyModelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "medium", - fastMode: true, - }, - }, - stickyModelOptions: { + stickyModelSelectionByProvider: { codex: { - reasoningEffort: "medium", - fastMode: true, + provider: "codex", + model: "gpt-5.3-codex", + options: { + reasoningEffort: "medium", + fastMode: true, + }, }, }, }); @@ -1545,18 +1540,14 @@ describe("ChatView timeline estimator parity (full app)", () => { it("hydrates the provider alongside a sticky claude model", async () => { useComposerDraftStore.setState({ - stickyModelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - fastMode: true, - }, - }, - stickyModelOptions: { + stickyModelSelectionByProvider: { claudeAgent: { - effort: "max", - fastMode: true, + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { + effort: "max", + fastMode: true, + }, }, }, }); @@ -1628,18 +1619,14 @@ describe("ChatView timeline estimator parity (full app)", () => { it("prefers draft state over sticky composer settings and defaults", async () => { useComposerDraftStore.setState({ - stickyModelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "medium", - fastMode: true, - }, - }, - stickyModelOptions: { + stickyModelSelectionByProvider: { codex: { - reasoningEffort: "medium", - fastMode: true, + provider: "codex", + model: "gpt-5.3-codex", + options: { + reasoningEffort: "medium", + fastMode: true, + }, }, }, }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f045a8c6ff..fbc887bf62 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -22,7 +22,11 @@ import { ProviderInteractionMode, RuntimeMode, } from "@t3tools/contracts"; -import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; +import { + applyClaudePromptEffortPrefix, + getModelCapabilities, + normalizeModelSlug, +} from "@t3tools/shared/model"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; @@ -193,10 +197,12 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = { - low: "Low", - medium: "Medium", - high: "High", - max: "Max", - ultrathink: "Ultrathink", -}; - -const ULTRATHINK_PROMPT_PREFIX = "Ultrathink:\n"; - -function getSelectedClaudeTraits( - model: string | null | undefined, - prompt: string, - modelOptions: ClaudeModelOptions | null | undefined, -): { - effort: Exclude | null; - thinkingEnabled: boolean | null; - fastModeEnabled: boolean; - options: ReadonlyArray; - ultrathinkPromptControlled: boolean; - supportsFastMode: boolean; -} { - const options = getReasoningEffortOptions(PROVIDER, model); - const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER) as Exclude< - ClaudeCodeEffort, - "ultrathink" - >; - const resolvedEffort = resolveReasoningEffortForProvider(PROVIDER, modelOptions?.effort); - const effort = - resolvedEffort && resolvedEffort !== "ultrathink" && options.includes(resolvedEffort) - ? resolvedEffort - : options.includes(defaultReasoningEffort) - ? defaultReasoningEffort - : null; - const thinkingEnabled = supportsClaudeThinkingToggle(model) - ? (modelOptions?.thinking ?? true) - : null; - const supportsFastMode = supportsClaudeFastMode(model); - return { - effort, - thinkingEnabled, - fastModeEnabled: supportsFastMode && modelOptions?.fastMode === true, - options, - ultrathinkPromptControlled: - supportsClaudeUltrathinkKeyword(model) && isClaudeUltrathinkPrompt(prompt), - supportsFastMode, - }; -} - -interface ClaudeTraitsMenuContentProps { - threadId: ThreadId; - model: string | null | undefined; - prompt: string; - onPromptChange: (prompt: string) => void; - modelOptions?: ClaudeModelOptions | null | undefined; -} - -export const ClaudeTraitsMenuContent = memo(function ClaudeTraitsMenuContentImpl({ - threadId, - model, - prompt, - onPromptChange, - modelOptions, -}: ClaudeTraitsMenuContentProps) { - const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); - const { - effort, - thinkingEnabled, - fastModeEnabled, - options, - ultrathinkPromptControlled, - supportsFastMode, - } = getSelectedClaudeTraits(model, prompt, modelOptions); - const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER); - - const handleEffortChange = useCallback( - (value: ClaudeCodeEffort) => { - if (ultrathinkPromptControlled) return; - if (!value) return; - const nextEffort = options.find((option) => option === value); - if (!nextEffort) return; - if (nextEffort === "ultrathink") { - const nextPrompt = - prompt.trim().length === 0 - ? ULTRATHINK_PROMPT_PREFIX - : applyClaudePromptEffortPrefix(prompt, "ultrathink"); - onPromptChange(nextPrompt); - return; - } - setProviderModelOptions( - threadId, - PROVIDER, - { - ...modelOptions, - effort: nextEffort, - }, - { persistSticky: true }, - ); - }, - [ - ultrathinkPromptControlled, - modelOptions, - onPromptChange, - threadId, - setProviderModelOptions, - options, - prompt, - ], - ); - - if (effort === null && thinkingEnabled === null) { - return null; - } - - return ( - <> - {effort ? ( - <> - -
Effort
- {ultrathinkPromptControlled ? ( -
- Remove Ultrathink from the prompt to change effort. -
- ) : null} - - {options.map((option) => ( - - {CLAUDE_EFFORT_LABELS[option]} - {option === defaultReasoningEffort ? " (default)" : ""} - - ))} - -
- - ) : thinkingEnabled !== null ? ( - -
Thinking
- { - setProviderModelOptions( - threadId, - PROVIDER, - { - ...modelOptions, - thinking: value === "on", - }, - { persistSticky: true }, - ); - }} - > - On (default) - Off - -
- ) : null} - {supportsFastMode ? ( - <> - - -
Fast Mode
- { - setProviderModelOptions( - threadId, - PROVIDER, - { - ...modelOptions, - fastMode: value === "on", - }, - { persistSticky: true }, - ); - }} - > - off - on - -
- - ) : null} - - ); -}); - -export const ClaudeTraitsPicker = memo(function ClaudeTraitsPicker({ - threadId, - model, - prompt, - onPromptChange, - modelOptions, -}: ClaudeTraitsMenuContentProps) { - const [isMenuOpen, setIsMenuOpen] = useState(false); - const { effort, thinkingEnabled, fastModeEnabled, ultrathinkPromptControlled, supportsFastMode } = - getSelectedClaudeTraits(model, prompt, modelOptions); - const triggerLabel = [ - ultrathinkPromptControlled - ? "Ultrathink" - : effort - ? CLAUDE_EFFORT_LABELS[effort] - : thinkingEnabled === null - ? null - : `Thinking ${thinkingEnabled ? "On" : "Off"}`, - ...(supportsFastMode && fastModeEnabled ? ["Fast"] : []), - ] - .filter(Boolean) - .join(" · "); - - return ( - { - setIsMenuOpen(open); - }} - > - - } - > - {triggerLabel} - - - - - - ); -}); diff --git a/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx b/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx deleted file mode 100644 index fc786b0f58..0000000000 --- a/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import "../../index.css"; - -import { DEFAULT_MODEL_BY_PROVIDER, ProjectId, ThreadId } from "@t3tools/contracts"; -import { page } from "vitest/browser"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { render } from "vitest-browser-react"; - -import { CodexTraitsPicker } from "./CodexTraitsPicker"; -import { COMPOSER_DRAFT_STORAGE_KEY, useComposerDraftStore } from "../../composerDraftStore"; - -async function mountPicker(props: { - reasoningEffort?: "low" | "medium" | "high" | "xhigh"; - fastModeEnabled: boolean; -}) { - const threadId = ThreadId.makeUnsafe("thread-codex-traits"); - const draftsByThreadId = {} as ReturnType< - typeof useComposerDraftStore.getState - >["draftsByThreadId"]; - draftsByThreadId[threadId] = { - prompt: "", - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - terminalContexts: [], - modelSelection: { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER["codex"], - options: { - ...(props.reasoningEffort ? { reasoningEffort: props.reasoningEffort } : {}), - ...(props.fastModeEnabled ? { fastMode: true } : {}), - }, - }, - modelOptions: { - codex: { - ...(props.reasoningEffort ? { reasoningEffort: props.reasoningEffort } : {}), - ...(props.fastModeEnabled ? { fastMode: true } : {}), - }, - }, - runtimeMode: null, - interactionMode: null, - }; - useComposerDraftStore.setState({ - draftsByThreadId, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: { - [ProjectId.makeUnsafe("project-codex-traits")]: threadId, - }, - }); - const host = document.createElement("div"); - document.body.append(host); - const screen = await render( - , - { container: host }, - ); - - return { - cleanup: async () => { - await screen.unmount(); - host.remove(); - }, - }; -} - -describe("CodexTraitsPicker", () => { - afterEach(() => { - document.body.innerHTML = ""; - localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); - useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - stickyModelOptions: {}, - }); - }); - - it("shows fast mode controls", async () => { - const mounted = await mountPicker({ - fastModeEnabled: false, - }); - - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Fast Mode"); - expect(text).toContain("off"); - expect(text).toContain("on"); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("shows Fast in the trigger label when fast mode is active", async () => { - const mounted = await mountPicker({ - fastModeEnabled: true, - }); - - try { - await vi.waitFor(() => { - expect(document.body.textContent ?? "").toContain("High · Fast"); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("shows only the provided effort options", async () => { - const mounted = await mountPicker({ - fastModeEnabled: false, - }); - - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Low"); - expect(text).toContain("Medium"); - expect(text).toContain("High"); - expect(text).toContain("Extra High"); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("persists sticky codex model options when traits change", async () => { - const mounted = await mountPicker({ - fastModeEnabled: false, - }); - - try { - await page.getByRole("button").click(); - await page.getByRole("menuitemradio", { name: "on" }).click(); - - expect(useComposerDraftStore.getState().stickyModelSelection).toMatchObject({ - provider: "codex", - options: { - fastMode: true, - }, - }); - } finally { - await mounted.cleanup(); - } - }); -}); diff --git a/apps/web/src/components/chat/CodexTraitsPicker.tsx b/apps/web/src/components/chat/CodexTraitsPicker.tsx deleted file mode 100644 index e9dfbf07ea..0000000000 --- a/apps/web/src/components/chat/CodexTraitsPicker.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import type { - CodexModelOptions, - CodexReasoningEffort, - ProviderKind, - ThreadId, -} from "@t3tools/contracts"; -import { - getDefaultReasoningEffort, - getReasoningEffortOptions, - resolveReasoningEffortForProvider, -} from "@t3tools/shared/model"; -import { memo, useState } from "react"; -import { ChevronDownIcon } from "lucide-react"; -import { useComposerDraftStore } from "../../composerDraftStore"; -import { Button } from "../ui/button"; -import { - Menu, - MenuGroup, - MenuPopup, - MenuRadioGroup, - MenuRadioItem, - MenuSeparator as MenuDivider, - MenuTrigger, -} from "../ui/menu"; - -const PROVIDER = "codex" as const satisfies ProviderKind; - -const CODEX_REASONING_LABELS: Record = { - low: "Low", - medium: "Medium", - high: "High", - xhigh: "Extra High", -}; - -function getSelectedCodexTraits(modelOptions: CodexModelOptions | null | undefined): { - effort: CodexReasoningEffort; - fastModeEnabled: boolean; -} { - const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER); - return { - effort: - resolveReasoningEffortForProvider(PROVIDER, modelOptions?.reasoningEffort) ?? - defaultReasoningEffort, - fastModeEnabled: modelOptions?.fastMode === true, - }; -} - -function CodexTraitsMenuContentImpl(props: { - threadId: ThreadId; - modelOptions?: CodexModelOptions | null | undefined; -}) { - const modelOptions = props.modelOptions; - const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); - const options = getReasoningEffortOptions(PROVIDER); - const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER); - const { effort, fastModeEnabled } = getSelectedCodexTraits(modelOptions); - - return ( - <> - -
Reasoning
- { - if (!value) return; - const nextEffort = options.find((option) => option === value); - if (!nextEffort) return; - setProviderModelOptions( - props.threadId, - PROVIDER, - { - ...modelOptions, - reasoningEffort: nextEffort, - }, - { persistSticky: true }, - ); - }} - > - {options.map((option) => ( - - {CODEX_REASONING_LABELS[option]} - {option === defaultReasoningEffort ? " (default)" : ""} - - ))} - -
- - -
Fast Mode
- { - setProviderModelOptions( - props.threadId, - PROVIDER, - { - ...modelOptions, - fastMode: value === "on", - }, - { persistSticky: true }, - ); - }} - > - off - on - -
- - ); -} - -export const CodexTraitsMenuContent = memo(CodexTraitsMenuContentImpl); - -export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { - threadId: ThreadId; - modelOptions?: CodexModelOptions | null | undefined; -}) { - const [isMenuOpen, setIsMenuOpen] = useState(false); - const modelOptions = props.modelOptions; - const { effort, fastModeEnabled } = getSelectedCodexTraits(modelOptions); - const triggerLabel = [CODEX_REASONING_LABELS[effort], ...(fastModeEnabled ? ["Fast"] : [])] - .filter(Boolean) - .join(" · "); - - return ( - { - setIsMenuOpen(open); - }} - > - - } - > - - {triggerLabel} - - - - - - - ); -}); diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index eb961b5c4d..815ef3a8c4 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -6,8 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; -import { ClaudeTraitsMenuContent } from "./ClaudeTraitsPicker"; -import { CodexTraitsMenuContent } from "./CodexTraitsPicker"; +import { TraitsMenuContent } from "./TraitsPicker"; import { useComposerDraftStore } from "../../composerDraftStore"; async function mountMenu(props?: { @@ -21,23 +20,23 @@ async function mountMenu(props?: { const draftsByThreadId = {} as ReturnType< typeof useComposerDraftStore.getState >["draftsByThreadId"]; + const model = DEFAULT_MODEL_BY_PROVIDER["claudeAgent"]; + const providerOpts = + provider === "codex" ? props?.modelOptions?.codex : props?.modelOptions?.claudeAgent; draftsByThreadId[threadId] = { prompt: props?.prompt ?? "", images: [], nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], - modelSelection: { - provider, - model: DEFAULT_MODEL_BY_PROVIDER["claudeAgent"], - ...(props?.modelOptions - ? { - options: - provider === "codex" ? props.modelOptions.codex : props.modelOptions.claudeAgent, - } - : {}), + modelSelectionByProvider: { + [provider]: { + provider, + model, + ...(providerOpts ? { options: providerOpts } : {}), + }, }, - modelOptions: props?.modelOptions ?? null, + activeProvider: provider, runtimeMode: null, interactionMode: null, }; @@ -49,6 +48,8 @@ async function mountMenu(props?: { const host = document.createElement("div"); document.body.append(host); const onPromptChange = vi.fn(); + const providerOptions = + provider === "codex" ? props?.modelOptions?.codex : props?.modelOptions?.claudeAgent; const screen = await render( - ) : ( - - ) + } onToggleInteractionMode={vi.fn()} onTogglePlanSidebar={vi.fn()} @@ -90,7 +88,7 @@ describe("CompactComposerControlsMenu", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, - stickyModelOptions: {}, + stickyModelSelectionByProvider: {}, }); }); diff --git a/apps/web/src/components/chat/ProviderHealthBanner.tsx b/apps/web/src/components/chat/ProviderHealthBanner.tsx index 73cb77eae9..bfdefe58ec 100644 --- a/apps/web/src/components/chat/ProviderHealthBanner.tsx +++ b/apps/web/src/components/chat/ProviderHealthBanner.tsx @@ -1,4 +1,4 @@ -import { type ServerProviderStatus } from "@t3tools/contracts"; +import { PROVIDER_DISPLAY_NAMES, type ServerProviderStatus } from "@t3tools/contracts"; import { memo } from "react"; import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { CircleAlertIcon } from "lucide-react"; @@ -12,12 +12,7 @@ export const ProviderHealthBanner = memo(function ProviderHealthBanner({ return null; } - const providerLabel = - status.provider === "codex" - ? "Codex" - : status.provider === "claudeAgent" - ? "Claude" - : status.provider; + const providerLabel = PROVIDER_DISPLAY_NAMES[status.provider] ?? status.provider; const defaultMessage = status.status === "error" ? `${providerLabel} provider is unavailable.` diff --git a/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx similarity index 51% rename from apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx rename to apps/web/src/components/chat/TraitsPicker.browser.tsx index 934dfbb2b8..e947d0af79 100644 --- a/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -1,28 +1,31 @@ import "../../index.css"; -import { type ModelSelection, ThreadId } from "@t3tools/contracts"; +import { type ModelSelection, DEFAULT_MODEL_BY_PROVIDER, ProjectId, ThreadId } from "@t3tools/contracts"; import { page } from "vitest/browser"; import { useCallback } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; -import { ClaudeTraitsPicker } from "./ClaudeTraitsPicker"; +import { TraitsPicker } from "./TraitsPicker"; import { + COMPOSER_DRAFT_STORAGE_KEY, useComposerDraftStore, useComposerThreadDraft, useEffectiveComposerModelState, } from "../../composerDraftStore"; -const THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits"); +// ── Claude TraitsPicker tests ───────────────────────────────────────── + +const CLAUDE_THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits"); function ClaudeTraitsPickerHarness(props: { model: string; fallbackModelSelection: ModelSelection | null; }) { - const prompt = useComposerThreadDraft(THREAD_ID).prompt; + const prompt = useComposerThreadDraft(CLAUDE_THREAD_ID).prompt; const setPrompt = useComposerDraftStore((store) => store.setPrompt); const { modelOptions, selectedModel } = useEffectiveComposerModelState({ - threadId: THREAD_ID, + threadId: CLAUDE_THREAD_ID, selectedProvider: "claudeAgent", threadModelSelection: props.fallbackModelSelection, projectModelSelection: null, @@ -30,14 +33,15 @@ function ClaudeTraitsPickerHarness(props: { }); const handlePromptChange = useCallback( (nextPrompt: string) => { - setPrompt(THREAD_ID, nextPrompt); + setPrompt(CLAUDE_THREAD_ID, nextPrompt); }, [setPrompt], ); return ( - ["draftsByThreadId"]; const model = props?.model ?? "claude-opus-4-6"; - draftsByThreadId[THREAD_ID] = { + const claudeOptions = !props?.skipDraftModelOptions + ? { + ...(props?.effort ? { effort: props.effort } : {}), + ...(props?.thinkingEnabled === false ? { thinking: false } : {}), + ...(props?.fastModeEnabled ? { fastMode: true } : {}), + } + : undefined; + draftsByThreadId[CLAUDE_THREAD_ID] = { prompt: props?.prompt ?? "", images: [], nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], - modelSelection: { - provider: "claudeAgent", - model, - ...(!props?.skipDraftModelOptions - ? { - options: { - ...(props?.effort ? { effort: props.effort } : {}), - ...(props?.thinkingEnabled === false ? { thinking: false } : {}), - ...(props?.fastModeEnabled ? { fastMode: true } : {}), - }, - } - : {}), - }, - modelOptions: props?.skipDraftModelOptions - ? null + modelSelectionByProvider: props?.skipDraftModelOptions + ? {} : { claudeAgent: { - ...(props?.effort ? { effort: props.effort } : {}), - ...(props?.thinkingEnabled === false ? { thinking: false } : {}), - ...(props?.fastModeEnabled ? { fastMode: true } : {}), + provider: "claudeAgent", + model, + ...(claudeOptions && Object.keys(claudeOptions).length > 0 + ? { options: claudeOptions } + : {}), }, }, + activeProvider: "claudeAgent", runtimeMode: null, interactionMode: null, }; @@ -122,20 +123,19 @@ async function mountPicker(props?: { }; } -describe("ClaudeTraitsPicker", () => { +describe("TraitsPicker (Claude)", () => { afterEach(() => { document.body.innerHTML = ""; useComposerDraftStore.setState({ draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, - stickyModelSelection: null, - stickyModelOptions: {}, + stickyModelSelectionByProvider: {}, }); }); it("shows fast mode controls for Opus", async () => { - const mounted = await mountPicker(); + const mounted = await mountClaudePicker(); try { await page.getByRole("button").click(); @@ -152,7 +152,7 @@ describe("ClaudeTraitsPicker", () => { }); it("hides fast mode controls for non-Opus models", async () => { - const mounted = await mountPicker({ model: "claude-sonnet-4-6" }); + const mounted = await mountClaudePicker({ model: "claude-sonnet-4-6" }); try { await page.getByRole("button").click(); @@ -166,7 +166,7 @@ describe("ClaudeTraitsPicker", () => { }); it("shows only the provided effort options", async () => { - const mounted = await mountPicker({ + const mounted = await mountClaudePicker({ model: "claude-sonnet-4-6", }); @@ -187,7 +187,7 @@ describe("ClaudeTraitsPicker", () => { }); it("shows a thinking on/off dropdown for Haiku", async () => { - const mounted = await mountPicker({ + const mounted = await mountClaudePicker({ model: "claude-haiku-4-5", thinkingEnabled: true, }); @@ -210,7 +210,7 @@ describe("ClaudeTraitsPicker", () => { }); it("shows prompt-controlled Ultrathink state with disabled effort controls", async () => { - const mounted = await mountPicker({ + const mounted = await mountClaudePicker({ effort: "high", model: "claude-opus-4-6", prompt: "Ultrathink:\nInvestigate this", @@ -236,7 +236,7 @@ describe("ClaudeTraitsPicker", () => { }); it("persists sticky claude model options when traits change", async () => { - const mounted = await mountPicker({ + const mounted = await mountClaudePicker({ model: "claude-opus-4-6", effort: "medium", fastModeEnabled: false, @@ -246,7 +246,9 @@ describe("ClaudeTraitsPicker", () => { await page.getByRole("button").click(); await page.getByRole("menuitemradio", { name: "Max" }).click(); - expect(useComposerDraftStore.getState().stickyModelSelection).toMatchObject({ + expect( + useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent, + ).toMatchObject({ provider: "claudeAgent", options: { effort: "max", @@ -258,7 +260,7 @@ describe("ClaudeTraitsPicker", () => { }); it("can turn inherited fast mode off without snapping back", async () => { - const mounted = await mountPicker({ + const mounted = await mountClaudePicker({ model: "claude-opus-4-6", skipDraftModelOptions: true, fallbackModelOptions: { @@ -275,11 +277,12 @@ describe("ClaudeTraitsPicker", () => { await page.getByRole("menuitemradio", { name: "off" }).click(); await vi.waitFor(() => { - expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]?.modelOptions).toEqual({ - claudeAgent: { - effort: "high", - fastMode: false, - }, + expect( + useComposerDraftStore.getState().draftsByThreadId[CLAUDE_THREAD_ID] + ?.modelSelectionByProvider.claudeAgent?.options, + ).toEqual({ + effort: "high", + fastMode: false, }); }); await expect.element(trigger).toHaveTextContent("High"); @@ -289,3 +292,154 @@ describe("ClaudeTraitsPicker", () => { } }); }); + +// ── Codex TraitsPicker tests ────────────────────────────────────────── + +async function mountCodexPicker(props: { + reasoningEffort?: "low" | "medium" | "high" | "xhigh"; + fastModeEnabled: boolean; +}) { + const threadId = ThreadId.makeUnsafe("thread-codex-traits"); + const draftsByThreadId = {} as ReturnType< + typeof useComposerDraftStore.getState + >["draftsByThreadId"]; + const codexOptions = { + ...(props.reasoningEffort ? { reasoningEffort: props.reasoningEffort } : {}), + ...(props.fastModeEnabled ? { fastMode: true } : {}), + }; + draftsByThreadId[threadId] = { + prompt: "", + images: [], + nonPersistedImageIds: [], + persistedAttachments: [], + terminalContexts: [], + modelSelectionByProvider: { + codex: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER["codex"], + ...(Object.keys(codexOptions).length > 0 ? { options: codexOptions } : {}), + }, + }, + activeProvider: "codex", + runtimeMode: null, + interactionMode: null, + }; + useComposerDraftStore.setState({ + draftsByThreadId, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: { + [ProjectId.makeUnsafe("project-codex-traits")]: threadId, + }, + }); + const host = document.createElement("div"); + document.body.append(host); + const screen = await render( + {}} + />, + { container: host }, + ); + + return { + cleanup: async () => { + await screen.unmount(); + host.remove(); + }, + }; +} + +describe("TraitsPicker (Codex)", () => { + afterEach(() => { + document.body.innerHTML = ""; + localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + stickyModelSelectionByProvider: {}, + }); + }); + + it("shows fast mode controls", async () => { + const mounted = await mountCodexPicker({ + fastModeEnabled: false, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Fast Mode"); + expect(text).toContain("off"); + expect(text).toContain("on"); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("shows Fast in the trigger label when fast mode is active", async () => { + const mounted = await mountCodexPicker({ + fastModeEnabled: true, + }); + + try { + await vi.waitFor(() => { + expect(document.body.textContent ?? "").toContain("High · Fast"); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("shows only the provided effort options", async () => { + const mounted = await mountCodexPicker({ + fastModeEnabled: false, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Low"); + expect(text).toContain("Medium"); + expect(text).toContain("High"); + expect(text).toContain("Extra High"); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("persists sticky codex model options when traits change", async () => { + const mounted = await mountCodexPicker({ + fastModeEnabled: false, + }); + + try { + await page.getByRole("button").click(); + await page.getByRole("menuitemradio", { name: "on" }).click(); + + expect( + useComposerDraftStore.getState().stickyModelSelectionByProvider.codex, + ).toMatchObject({ + provider: "codex", + options: { + fastMode: true, + }, + }); + } finally { + await mounted.cleanup(); + } + }); +}); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx new file mode 100644 index 0000000000..e43c094283 --- /dev/null +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -0,0 +1,320 @@ +import { + type ClaudeModelOptions, + type CodexModelOptions, + type ProviderKind, + type ProviderModelOptions, + type ThreadId, +} from "@t3tools/contracts"; +import { + applyClaudePromptEffortPrefix, + getModelCapabilities, + isClaudeUltrathinkPrompt, + trimOrNull, + getDefaultEffort, + hasEffortLevel, +} from "@t3tools/shared/model"; +import { memo, useCallback, useState } from "react"; +import { ChevronDownIcon } from "lucide-react"; +import { Button } from "../ui/button"; +import { + Menu, + MenuGroup, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator as MenuDivider, + MenuTrigger, +} from "../ui/menu"; +import { useComposerDraftStore } from "../../composerDraftStore"; + +type ProviderOptions = ProviderModelOptions[ProviderKind]; + +const ULTRATHINK_PROMPT_PREFIX = "Ultrathink:\n"; + +function getRawEffort( + provider: ProviderKind, + modelOptions: ProviderOptions | null | undefined, +): string | null { + if (provider === "codex") { + return trimOrNull((modelOptions as CodexModelOptions | undefined)?.reasoningEffort); + } + return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort); +} + +function buildNextOptions( + provider: ProviderKind, + modelOptions: ProviderOptions | null | undefined, + patch: Record, +): ProviderOptions { + if (provider === "codex") { + return { ...(modelOptions as CodexModelOptions | undefined), ...patch } as CodexModelOptions; + } + return { ...(modelOptions as ClaudeModelOptions | undefined), ...patch } as ClaudeModelOptions; +} + +function getSelectedTraits( + provider: ProviderKind, + model: string | null | undefined, + prompt: string, + modelOptions: ProviderOptions | null | undefined, +) { + const caps = getModelCapabilities(provider, model); + const effortLevels = caps.reasoningEffortLevels; + const defaultEffort = getDefaultEffort(caps); + + // Resolve effort from options (provider-specific key) + const resolvedEffort = getRawEffort(provider, modelOptions); + + // Filter out prompt-injected efforts from the "current effort" display + const isPromptInjected = resolvedEffort + ? caps.promptInjectedEffortLevels.includes(resolvedEffort) + : false; + const effort = + resolvedEffort && !isPromptInjected && hasEffortLevel(caps, resolvedEffort) + ? resolvedEffort + : defaultEffort && hasEffortLevel(caps, defaultEffort) + ? defaultEffort + : null; + + // Thinking toggle (only for models that support it) + const thinkingEnabled = caps.supportsThinkingToggle + ? ((modelOptions as ClaudeModelOptions | undefined)?.thinking ?? true) + : null; + + // Fast mode + const fastModeEnabled = + caps.supportsFastMode && + (modelOptions as { fastMode?: boolean } | undefined)?.fastMode === true; + + // Prompt-controlled effort (e.g. ultrathink in prompt text) + const ultrathinkPromptControlled = + caps.promptInjectedEffortLevels.length > 0 && isClaudeUltrathinkPrompt(prompt); + + return { + caps, + effort, + effortLevels, + thinkingEnabled, + fastModeEnabled, + ultrathinkPromptControlled, + }; +} + +export interface TraitsMenuContentProps { + provider: ProviderKind; + threadId: ThreadId; + model: string | null | undefined; + prompt: string; + onPromptChange: (prompt: string) => void; + modelOptions?: ProviderOptions | null | undefined; +} + +export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ + provider, + threadId, + model, + prompt, + onPromptChange, + modelOptions, +}: TraitsMenuContentProps) { + const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); + const { + caps, + effort, + effortLevels, + thinkingEnabled, + fastModeEnabled, + ultrathinkPromptControlled, + } = getSelectedTraits(provider, model, prompt, modelOptions); + const defaultEffort = getDefaultEffort(caps); + + const handleEffortChange = useCallback( + (value: string) => { + if (ultrathinkPromptControlled) return; + if (!value) return; + const nextOption = effortLevels.find((option) => option.value === value); + if (!nextOption) return; + if (caps.promptInjectedEffortLevels.includes(nextOption.value)) { + const nextPrompt = + prompt.trim().length === 0 + ? ULTRATHINK_PROMPT_PREFIX + : applyClaudePromptEffortPrefix(prompt, "ultrathink"); + onPromptChange(nextPrompt); + return; + } + const effortKey = provider === "codex" ? "reasoningEffort" : "effort"; + setProviderModelOptions( + threadId, + provider, + buildNextOptions(provider, modelOptions, { [effortKey]: nextOption.value }), + { persistSticky: true }, + ); + }, + [ + ultrathinkPromptControlled, + modelOptions, + onPromptChange, + threadId, + setProviderModelOptions, + effortLevels, + prompt, + caps.promptInjectedEffortLevels, + provider, + ], + ); + + if (effort === null && thinkingEnabled === null) { + return null; + } + + return ( + <> + {effort ? ( + <> + +
Effort
+ {ultrathinkPromptControlled ? ( +
+ Remove Ultrathink from the prompt to change effort. +
+ ) : null} + + {effortLevels.map((option) => ( + + {option.label} + {option.value === defaultEffort ? " (default)" : ""} + + ))} + +
+ + ) : thinkingEnabled !== null ? ( + +
Thinking
+ { + setProviderModelOptions( + threadId, + provider, + buildNextOptions(provider, modelOptions, { thinking: value === "on" }), + { persistSticky: true }, + ); + }} + > + On (default) + Off + +
+ ) : null} + {caps.supportsFastMode ? ( + <> + + +
Fast Mode
+ { + setProviderModelOptions( + threadId, + provider, + buildNextOptions(provider, modelOptions, { fastMode: value === "on" }), + { persistSticky: true }, + ); + }} + > + off + on + +
+ + ) : null} + + ); +}); + +export const TraitsPicker = memo(function TraitsPicker({ + provider, + threadId, + model, + prompt, + onPromptChange, + modelOptions, +}: TraitsMenuContentProps) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const { + caps, + effort, + effortLevels, + thinkingEnabled, + fastModeEnabled, + ultrathinkPromptControlled, + } = getSelectedTraits(provider, model, prompt, modelOptions); + + const effortLabel = effort + ? (effortLevels.find((l) => l.value === effort)?.label ?? effort) + : null; + const triggerLabel = [ + ultrathinkPromptControlled + ? "Ultrathink" + : effortLabel + ? effortLabel + : thinkingEnabled === null + ? null + : `Thinking ${thinkingEnabled ? "On" : "Off"}`, + ...(caps.supportsFastMode && fastModeEnabled ? ["Fast"] : []), + ] + .filter(Boolean) + .join(" · "); + + const isCodexStyle = provider === "codex"; + + return ( + { + setIsMenuOpen(open); + }} + > + + } + > + {isCodexStyle ? ( + + {triggerLabel} + + ) : ( + <> + {triggerLabel} + + + + + + ); +}); diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index 1d6e5439ea..088a2a47be 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -1,23 +1,20 @@ import { type ModelSlug, - type ClaudeModelOptions, - type CodexModelOptions, type ProviderKind, type ProviderModelOptions, type ThreadId, } from "@t3tools/contracts"; import { - getDefaultReasoningEffort, - getReasoningEffortOptions, + getModelCapabilities, isClaudeUltrathinkPrompt, normalizeClaudeModelOptions, normalizeCodexModelOptions, - resolveReasoningEffortForProvider, - supportsClaudeUltrathinkKeyword, + trimOrNull, + getDefaultEffort, + hasEffortLevel, } from "@t3tools/shared/model"; import type { ReactNode } from "react"; -import { ClaudeTraitsMenuContent, ClaudeTraitsPicker } from "./ClaudeTraitsPicker"; -import { CodexTraitsMenuContent, CodexTraitsPicker } from "./CodexTraitsPicker"; +import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; export type ComposerProviderStateInput = { provider: ProviderKind; @@ -53,76 +50,98 @@ type ProviderRegistryEntry = { }) => ReactNode; }; +function getProviderStateFromCapabilities( + input: ComposerProviderStateInput, +): ComposerProviderState { + const { provider, model, prompt, modelOptions } = input; + const caps = getModelCapabilities(provider, model); + const providerOptions = modelOptions?.[provider]; + + // Resolve effort + const rawEffort = providerOptions + ? "effort" in providerOptions + ? providerOptions.effort + : "reasoningEffort" in providerOptions + ? providerOptions.reasoningEffort + : null + : null; + + const draftEffort = trimOrNull(rawEffort); + const defaultEffort = getDefaultEffort(caps); + const isPromptInjected = draftEffort + ? caps.promptInjectedEffortLevels.includes(draftEffort) + : false; + const promptEffort = + draftEffort && !isPromptInjected && hasEffortLevel(caps, draftEffort) + ? draftEffort + : defaultEffort && hasEffortLevel(caps, defaultEffort) + ? defaultEffort + : null; + + // Normalize options for dispatch + const normalizedOptions = + provider === "codex" + ? normalizeCodexModelOptions(model, providerOptions) + : normalizeClaudeModelOptions(model, providerOptions); + + // Ultrathink styling (driven by capabilities data, not provider identity) + const ultrathinkActive = + caps.promptInjectedEffortLevels.length > 0 && isClaudeUltrathinkPrompt(prompt); + + return { + provider, + promptEffort, + modelOptionsForDispatch: normalizedOptions, + ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame" } : {}), + ...(ultrathinkActive + ? { composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]" } + : {}), + ...(ultrathinkActive ? { modelPickerIconClassName: "ultrathink-chroma" } : {}), + }; +} + const composerProviderRegistry: Record = { codex: { - getState: ({ modelOptions }) => { - const promptEffort = - resolveReasoningEffortForProvider("codex", modelOptions?.codex?.reasoningEffort) ?? - getDefaultReasoningEffort("codex"); - const normalizedCodexOptions = normalizeCodexModelOptions(modelOptions?.codex); - - return { - provider: "codex", - promptEffort, - modelOptionsForDispatch: normalizedCodexOptions, - }; - }, - renderTraitsMenuContent: ({ threadId, modelOptions }) => ( - getProviderStateFromCapabilities(input), + renderTraitsMenuContent: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( + ), - renderTraitsPicker: ({ threadId, modelOptions }) => ( - ( + ), }, claudeAgent: { - getState: ({ model, prompt, modelOptions }) => { - const reasoningOptions = getReasoningEffortOptions("claudeAgent", model); - const draftEffort = resolveReasoningEffortForProvider( - "claudeAgent", - modelOptions?.claudeAgent?.effort, - ); - const defaultEffort = getDefaultReasoningEffort("claudeAgent"); - const promptEffort = - draftEffort && draftEffort !== "ultrathink" && reasoningOptions.includes(draftEffort) - ? draftEffort - : reasoningOptions.includes(defaultEffort) - ? defaultEffort - : null; - const normalizedClaudeOptions = normalizeClaudeModelOptions(model, modelOptions?.claudeAgent); - const ultrathinkActive = - supportsClaudeUltrathinkKeyword(model) && isClaudeUltrathinkPrompt(prompt); - - return { - provider: "claudeAgent", - promptEffort, - modelOptionsForDispatch: normalizedClaudeOptions, - ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame" } : {}), - ...(ultrathinkActive - ? { composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]" } - : {}), - ...(ultrathinkActive ? { modelPickerIconClassName: "ultrathink-chroma" } : {}), - }; - }, + getState: (input) => getProviderStateFromCapabilities(input), renderTraitsMenuContent: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( - ), renderTraitsPicker: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( - diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index f3068d37a9..5fa858029d 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -72,8 +72,7 @@ function resetComposerDraftStore() { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, - stickyModelSelection: null, - stickyModelOptions: {}, + stickyModelSelectionByProvider: {}, }); } @@ -218,7 +217,7 @@ describe("composerDraftStore syncPersistedAttachments", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, - stickyModelOptions: {}, + stickyModelSelectionByProvider: {}, }); }); @@ -275,7 +274,7 @@ describe("composerDraftStore terminal contexts", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, - stickyModelOptions: {}, + stickyModelSelectionByProvider: {}, }); }); @@ -635,7 +634,9 @@ describe("composerDraftStore modelSelection", () => { }), ); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( + expect( + useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex, + ).toEqual( modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "xhigh", fastMode: true, @@ -647,9 +648,9 @@ describe("composerDraftStore modelSelection", () => { const store = useComposerDraftStore.getState(); store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4")); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( - modelSelection("codex", "gpt-5.4"), - ); + expect( + useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex, + ).toEqual(modelSelection("codex", "gpt-5.4")); }); it("replaces only the targeted provider options on the current model selection", () => { @@ -678,23 +679,19 @@ describe("composerDraftStore modelSelection", () => { { persistSticky: true }, ); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( + expect( + useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider + .claudeAgent, + ).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { thinking: false, }), ); - expect(useComposerDraftStore.getState().stickyModelSelection).toEqual( + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { thinking: false, }), ); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual( - providerModelOptions({ - claudeAgent: { - thinking: false, - }, - }), - ); }); it("keeps explicit default-state overrides on the selection", () => { @@ -711,19 +708,15 @@ describe("composerDraftStore modelSelection", () => { thinking: true, }); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( + expect( + useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider + .claudeAgent, + ).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { thinking: true, }), ); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual( - providerModelOptions({ - claudeAgent: { - thinking: true, - }, - }), - ); - expect(useComposerDraftStore.getState().stickyModelSelection).toBeNull(); + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider).toEqual({}); }); it("keeps explicit off/default codex overrides on the selection", () => { @@ -736,20 +729,14 @@ describe("composerDraftStore modelSelection", () => { fastMode: false, }); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( + expect( + useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex, + ).toEqual( modelSelection("codex", "gpt-5.4", { reasoningEffort: "high", fastMode: false, }), ); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual( - providerModelOptions({ - codex: { - reasoningEffort: "high", - fastMode: false, - }, - }), - ); }); it("updates only the draft when sticky persistence is omitted", () => { @@ -767,12 +754,15 @@ describe("composerDraftStore modelSelection", () => { thinking: false, }); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( + expect( + useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider + .claudeAgent, + ).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { thinking: false, }), ); - expect(useComposerDraftStore.getState().stickyModelSelection).toEqual( + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), ); }); @@ -790,13 +780,12 @@ describe("composerDraftStore modelSelection", () => { store.setModelSelection(threadId, modelSelection("claudeAgent", "claude-opus-4-6")); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ - modelSelection: modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), - modelOptions: providerModelOptions({ - codex: { fastMode: true }, - claudeAgent: { effort: "max" }, - }), - }); + const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + expect(draft?.modelSelectionByProvider.claudeAgent).toEqual( + modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), + ); + expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ fastMode: true }); + expect(draft?.activeProvider).toBe("claudeAgent"); }); it("creates the first sticky snapshot from provider option changes", () => { @@ -813,7 +802,7 @@ describe("composerDraftStore modelSelection", () => { { persistSticky: true }, ); - expect(useComposerDraftStore.getState().stickyModelSelection).toEqual( + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toEqual( modelSelection("codex", "gpt-5.4", { fastMode: true, }), @@ -840,12 +829,15 @@ describe("composerDraftStore modelSelection", () => { { persistSticky: false }, ); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( + expect( + useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider + .claudeAgent, + ).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { thinking: false, }), ); - expect(useComposerDraftStore.getState().stickyModelSelection).toEqual( + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), ); }); @@ -863,9 +855,9 @@ describe("composerDraftStore setModelSelection", () => { store.setModelSelection(threadId, modelSelection("codex", "gpt-5.3-codex")); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( - modelSelection("codex", "gpt-5.3-codex"), - ); + expect( + useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex, + ).toEqual(modelSelection("codex", "gpt-5.3-codex")); }); }); @@ -884,22 +876,12 @@ describe("composerDraftStore sticky composer settings", () => { }), ); - expect(useComposerDraftStore.getState()).toMatchObject({ - stickyModelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "medium", - fastMode: true, - }, - }, - stickyModelOptions: { - codex: { - reasoningEffort: "medium", - fastMode: true, - }, - }, - }); + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toEqual( + modelSelection("codex", "gpt-5.3-codex", { + reasoningEffort: "medium", + fastMode: true, + }), + ); }); it("normalizes empty sticky model options by dropping selection options", () => { @@ -907,10 +889,9 @@ describe("composerDraftStore sticky composer settings", () => { store.setStickyModelSelection(modelSelection("codex", "gpt-5.4")); - expect(useComposerDraftStore.getState().stickyModelSelection).toEqual( + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toEqual( modelSelection("codex", "gpt-5.4"), ); - expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({}); }); }); @@ -930,19 +911,12 @@ describe("composerDraftStore provider-scoped option updates", () => { }), ); store.setProviderModelOptions(threadId, "claudeAgent", { effort: "max" }); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ - modelSelection: modelSelection("codex", "gpt-5.3-codex", { - reasoningEffort: "medium", - }), - modelOptions: providerModelOptions({ - codex: { - reasoningEffort: "medium", - }, - claudeAgent: { - effort: "max", - }, - }), - }); + const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + expect(draft?.modelSelectionByProvider.codex).toEqual( + modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium" }), + ); + expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" }); + expect(draft?.activeProvider).toBe("codex"); }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index fc9ca412f1..5ce1ff86ed 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -33,7 +33,7 @@ import { createJSONStorage, persist } from "zustand/middleware"; import { createDebouncedStorage, createMemoryStorage } from "./lib/storage"; export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; -const COMPOSER_DRAFT_STORAGE_VERSION = 2; +const COMPOSER_DRAFT_STORAGE_VERSION = 3; const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; @@ -80,8 +80,10 @@ const PersistedComposerThreadDraftState = Schema.Struct({ prompt: Schema.String, attachments: Schema.Array(PersistedComposerImageAttachment), terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)), - modelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), - modelOptions: Schema.optionalKey(Schema.NullOr(ProviderModelOptions)), + modelSelectionByProvider: Schema.optionalKey( + Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)), + ), + activeProvider: Schema.optionalKey(Schema.NullOr(ProviderKind)), runtimeMode: Schema.optionalKey(RuntimeMode), interactionMode: Schema.optionalKey(ProviderInteractionMode), }); @@ -101,9 +103,15 @@ const LegacyThreadModelFields = Schema.Struct({ }); type LegacyThreadModelFields = typeof LegacyThreadModelFields.Type; +type LegacyV2ThreadDraftFields = { + modelSelection?: ModelSelection | null; + modelOptions?: ProviderModelOptions | null; +}; + type LegacyPersistedComposerThreadDraftState = PersistedComposerThreadDraftState & LegacyCodexFields & - LegacyThreadModelFields; + LegacyThreadModelFields & + LegacyV2ThreadDraftFields; const LegacyStickyModelFields = Schema.Struct({ stickyProvider: Schema.optionalKey(ProviderKind), @@ -112,8 +120,14 @@ const LegacyStickyModelFields = Schema.Struct({ }); type LegacyStickyModelFields = typeof LegacyStickyModelFields.Type; +type LegacyV2StoreFields = { + stickyModelSelection?: ModelSelection | null; + stickyModelOptions?: ProviderModelOptions | null; +}; + type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState & - LegacyStickyModelFields; + LegacyStickyModelFields & + LegacyV2StoreFields; const PersistedDraftThreadState = Schema.Struct({ projectId: ProjectId, @@ -130,8 +144,9 @@ const PersistedComposerDraftStoreState = Schema.Struct({ draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState), draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState), projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId), - stickyModelSelection: Schema.NullOr(ModelSelection), - stickyModelOptions: Schema.optionalKey(Schema.NullOr(ProviderModelOptions)), + stickyModelSelectionByProvider: Schema.optionalKey( + Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)), + ), }); type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type; @@ -146,8 +161,8 @@ interface ComposerThreadDraftState { nonPersistedImageIds: string[]; persistedAttachments: PersistedComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; - modelSelection: ModelSelection | null; - modelOptions: ProviderModelOptions | null; + modelSelectionByProvider: Partial>; + activeProvider: ProviderKind | null; runtimeMode: RuntimeMode | null; interactionMode: ProviderInteractionMode | null; } @@ -170,8 +185,7 @@ interface ComposerDraftStoreState { draftsByThreadId: Record; draftThreadsByThreadId: Record; projectDraftThreadIdByProjectId: Record; - stickyModelSelection: ModelSelection | null; - stickyModelOptions: ProviderModelOptions; + stickyModelSelectionByProvider: Partial>; getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null; getDraftThread: (threadId: ThreadId) => DraftThreadState | null; setProjectDraftThreadId: ( @@ -202,7 +216,6 @@ interface ComposerDraftStoreState { clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; clearDraftThread: (threadId: ThreadId) => void; setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void; - setStickyModelOptions: (modelOptions: ProviderModelOptions | null | undefined) => void; setPrompt: (threadId: ThreadId, prompt: string) => void; setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void; setModelSelection: ( @@ -213,6 +226,7 @@ interface ComposerDraftStoreState { threadId: ThreadId, modelOptions: ProviderModelOptions | null | undefined, ) => void; + applyStickyState: (threadId: ThreadId) => void; setProviderModelOptions: ( threadId: ThreadId, provider: ProviderKind, @@ -264,12 +278,24 @@ function providerModelOptionsFromSelection( }; } +function modelSelectionByProviderToOptions( + map: Partial> | null | undefined, +): ProviderModelOptions | null { + if (!map) return null; + const result: Record = {}; + for (const [provider, selection] of Object.entries(map)) { + if (selection?.options) { + result[provider] = selection.options; + } + } + return Object.keys(result).length > 0 ? (result as ProviderModelOptions) : null; +} + const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze({ draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, - stickyModelSelection: null, - stickyModelOptions: {}, + stickyModelSelectionByProvider: {}, }); const EMPTY_IMAGES: ComposerImageAttachment[] = []; @@ -279,14 +305,17 @@ const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = []; Object.freeze(EMPTY_IMAGES); Object.freeze(EMPTY_IDS); Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); +const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial> = + Object.freeze({}); + const EMPTY_THREAD_DRAFT = Object.freeze({ prompt: "", images: EMPTY_IMAGES, nonPersistedImageIds: EMPTY_IDS, persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, terminalContexts: EMPTY_TERMINAL_CONTEXTS, - modelSelection: null, - modelOptions: null, + modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER, + activeProvider: null, runtimeMode: null, interactionMode: null, }); @@ -298,8 +327,8 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], - modelSelection: null, - modelOptions: null, + modelSelectionByProvider: {}, + activeProvider: null, runtimeMode: null, interactionMode: null, }; @@ -368,8 +397,8 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { draft.images.length === 0 && draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && - draft.modelSelection === null && - draft.modelOptions === null && + Object.keys(draft.modelSelectionByProvider).length === 0 && + draft.activeProvider === null && draft.runtimeMode === null && draft.interactionMode === null ); @@ -497,7 +526,9 @@ function normalizeModelSelection( }; } -function syncModelSelectionOptions( +// ── Legacy sync helpers (used only during migration from v2 storage) ── + +function legacySyncModelSelectionOptions( modelSelection: ModelSelection | null, modelOptions: ProviderModelOptions | null | undefined, ): ModelSelection | null { @@ -512,21 +543,21 @@ function syncModelSelectionOptions( }; } -function mergeModelSelectionIntoProviderModelOptions( +function legacyMergeModelSelectionIntoProviderModelOptions( modelSelection: ModelSelection | null, currentModelOptions: ProviderModelOptions | null | undefined, ): ProviderModelOptions | null { if (modelSelection?.options === undefined) { return normalizeProviderModelOptions(currentModelOptions); } - return replaceProviderModelOptions( + return legacyReplaceProviderModelOptions( normalizeProviderModelOptions(currentModelOptions), modelSelection.provider, modelSelection.options, ); } -function replaceProviderModelOptions( +function legacyReplaceProviderModelOptions( currentModelOptions: ProviderModelOptions | null | undefined, provider: ProviderKind, nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined, @@ -544,8 +575,41 @@ function replaceProviderModelOptions( }); } +// ── New helpers for the consolidated representation ──────────────────── + +function legacyToModelSelectionByProvider( + modelSelection: ModelSelection | null, + modelOptions: ProviderModelOptions | null | undefined, +): Partial> { + const result: Partial> = {}; + // Add entries from the options bag (for non-active providers) + if (modelOptions) { + for (const provider of ["codex", "claudeAgent"] as const) { + const options = modelOptions[provider]; + if (options && Object.keys(options).length > 0) { + result[provider] = { + provider, + model: + modelSelection?.provider === provider + ? modelSelection.model + : getDefaultModel(provider), + options, + }; + } + } + } + // Add/overwrite the active selection (it's authoritative for its provider) + if (modelSelection) { + result[modelSelection.provider] = modelSelection; + } + return result; +} + export function deriveEffectiveComposerModelState(input: { - draft: Pick | null | undefined; + draft: + | Pick + | null + | undefined; selectedProvider: ProviderKind; threadModelSelection: ModelSelection | null | undefined; projectModelSelection: ModelSelection | null | undefined; @@ -557,15 +621,16 @@ export function deriveEffectiveComposerModelState(input: { input.projectModelSelection?.model ?? getDefaultModel(input.selectedProvider), ); - const selectedModel = input.draft?.modelSelection?.model + const activeSelection = input.draft?.modelSelectionByProvider?.[input.selectedProvider]; + const selectedModel = activeSelection?.model ? resolveAppModelSelection( input.selectedProvider, input.customModelsByProvider, - input.draft.modelSelection.model, + activeSelection.model, ) : baseModel; const modelOptions = - input.draft?.modelOptions ?? + modelSelectionByProviderToOptions(input.draft?.modelSelectionByProvider) ?? providerModelOptionsFromSelection(input.threadModelSelection) ?? providerModelOptionsFromSelection(input.projectModelSelection) ?? null; @@ -803,30 +868,59 @@ function normalizePersistedDraftsByThreadId( promptCandidate, terminalContexts.length, ); + // If the draft already has the v3 shape, use it directly const legacyDraftCandidate = draftValue as LegacyPersistedComposerThreadDraftState; - const normalizedModelOptions = - normalizeProviderModelOptions( - draftCandidate.modelOptions ?? legacyDraftCandidate.modelOptions, - undefined, - legacyDraftCandidate, - ) ?? null; - const normalizedModelSelection = normalizeModelSelection(draftCandidate.modelSelection, { - provider: legacyDraftCandidate.provider, - model: legacyDraftCandidate.model, - modelOptions: normalizedModelOptions ?? legacyDraftCandidate.modelOptions, - legacyCodex: legacyDraftCandidate, - }); - const modelOptions = mergeModelSelectionIntoProviderModelOptions( - normalizedModelSelection, - normalizedModelOptions, - ); - const modelSelection = syncModelSelectionOptions(normalizedModelSelection, modelOptions); + let modelSelectionByProvider: Partial> = {}; + let activeProvider: ProviderKind | null = null; + + if ( + draftCandidate.modelSelectionByProvider && + typeof draftCandidate.modelSelectionByProvider === "object" + ) { + // v3 format + modelSelectionByProvider = draftCandidate.modelSelectionByProvider as Partial< + Record + >; + activeProvider = normalizeProviderKind(draftCandidate.activeProvider); + } else { + // v2 or legacy format: migrate + const normalizedModelOptions = + normalizeProviderModelOptions( + legacyDraftCandidate.modelOptions, + undefined, + legacyDraftCandidate, + ) ?? null; + const normalizedModelSelection = normalizeModelSelection( + legacyDraftCandidate.modelSelection, + { + provider: legacyDraftCandidate.provider, + model: legacyDraftCandidate.model, + modelOptions: normalizedModelOptions ?? legacyDraftCandidate.modelOptions, + legacyCodex: legacyDraftCandidate, + }, + ); + const mergedModelOptions = legacyMergeModelSelectionIntoProviderModelOptions( + normalizedModelSelection, + normalizedModelOptions, + ); + const modelSelection = legacySyncModelSelectionOptions( + normalizedModelSelection, + mergedModelOptions, + ); + modelSelectionByProvider = legacyToModelSelectionByProvider( + modelSelection, + mergedModelOptions, + ); + activeProvider = modelSelection?.provider ?? null; + } + + const hasModelData = + Object.keys(modelSelectionByProvider).length > 0 || activeProvider !== null; if ( promptCandidate.length === 0 && attachments.length === 0 && terminalContexts.length === 0 && - modelSelection === null && - modelOptions === null && + !hasModelData && !runtimeMode && !interactionMode ) { @@ -836,8 +930,7 @@ function normalizePersistedDraftsByThreadId( prompt, attachments, ...(terminalContexts.length > 0 ? { terminalContexts } : {}), - ...(modelSelection ? { modelSelection } : {}), - ...(modelOptions ? { modelOptions } : {}), + ...(hasModelData ? { modelSelectionByProvider, activeProvider } : {}), ...(runtimeMode ? { runtimeMode } : {}), ...(interactionMode ? { interactionMode } : {}), }; @@ -856,20 +949,27 @@ function migratePersistedComposerDraftStoreState( const rawDraftMap = candidate.draftsByThreadId; const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId; const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId; + + // Migrate sticky state from v2 (dual) to v3 (consolidated) const stickyModelOptions = normalizeProviderModelOptions(candidate.stickyModelOptions) ?? {}; const normalizedStickyModelSelection = normalizeModelSelection(candidate.stickyModelSelection, { provider: candidate.stickyProvider ?? "codex", model: candidate.stickyModel, modelOptions: stickyModelOptions, }); - const nextStickyModelOptions = mergeModelSelectionIntoProviderModelOptions( + const nextStickyModelOptions = legacyMergeModelSelectionIntoProviderModelOptions( normalizedStickyModelSelection, stickyModelOptions, ); - const stickyModelSelection = syncModelSelectionOptions( + const stickyModelSelection = legacySyncModelSelectionOptions( normalizedStickyModelSelection, nextStickyModelOptions, ); + const stickyModelSelectionByProvider = legacyToModelSelectionByProvider( + stickyModelSelection, + nextStickyModelOptions, + ); + const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } = normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectId); const draftsByThreadId = normalizePersistedDraftsByThreadId(rawDraftMap); @@ -877,8 +977,7 @@ function migratePersistedComposerDraftStoreState( draftsByThreadId, draftThreadsByThreadId, projectDraftThreadIdByProjectId, - stickyModelSelection, - stickyModelOptions: nextStickyModelOptions ?? {}, + stickyModelSelectionByProvider, }; } @@ -892,12 +991,13 @@ function partializeComposerDraftStoreState( if (typeof threadId !== "string" || threadId.length === 0) { continue; } + const hasModelData = + Object.keys(draft.modelSelectionByProvider).length > 0 || draft.activeProvider !== null; if ( draft.prompt.length === 0 && draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && - draft.modelSelection === null && - draft.modelOptions === null && + !hasModelData && draft.runtimeMode === null && draft.interactionMode === null ) { @@ -919,8 +1019,12 @@ function partializeComposerDraftStoreState( })), } : {}), - ...(draft.modelSelection ? { modelSelection: draft.modelSelection } : {}), - ...(draft.modelOptions ? { modelOptions: draft.modelOptions } : {}), + ...(hasModelData + ? { + modelSelectionByProvider: draft.modelSelectionByProvider, + activeProvider: draft.activeProvider, + } + : {}), ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}), ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}), }; @@ -930,8 +1034,7 @@ function partializeComposerDraftStoreState( draftsByThreadId: persistedDraftsByThreadId, draftThreadsByThreadId: state.draftThreadsByThreadId, projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId, - stickyModelSelection: state.stickyModelSelection, - stickyModelOptions: state.stickyModelOptions, + stickyModelSelectionByProvider: state.stickyModelSelectionByProvider, }; } @@ -947,30 +1050,48 @@ function normalizeCurrentPersistedComposerDraftStoreState( normalizedPersistedState.draftThreadsByThreadId, normalizedPersistedState.projectDraftThreadIdByProjectId, ); - const stickyModelOptions = - normalizeProviderModelOptions(normalizedPersistedState.stickyModelOptions) ?? {}; - const normalizedStickyModelSelection = normalizeModelSelection( - normalizedPersistedState.stickyModelSelection, - { - provider: normalizedPersistedState.stickyProvider, - model: normalizedPersistedState.stickyModel, - modelOptions: stickyModelOptions, - }, - ); - const nextStickyModelOptions = mergeModelSelectionIntoProviderModelOptions( - normalizedStickyModelSelection, - stickyModelOptions, - ); - const stickyModelSelection = syncModelSelectionOptions( - normalizedStickyModelSelection, - nextStickyModelOptions, - ); + + // Handle both v3 (modelSelectionByProvider) and v2/legacy formats + let stickyModelSelectionByProvider: Partial> = {}; + if ( + normalizedPersistedState.stickyModelSelectionByProvider && + typeof normalizedPersistedState.stickyModelSelectionByProvider === "object" + ) { + stickyModelSelectionByProvider = + normalizedPersistedState.stickyModelSelectionByProvider as Partial< + Record + >; + } else { + // Legacy migration path + const stickyModelOptions = + normalizeProviderModelOptions(normalizedPersistedState.stickyModelOptions) ?? {}; + const normalizedStickyModelSelection = normalizeModelSelection( + normalizedPersistedState.stickyModelSelection, + { + provider: normalizedPersistedState.stickyProvider, + model: normalizedPersistedState.stickyModel, + modelOptions: stickyModelOptions, + }, + ); + const nextStickyModelOptions = legacyMergeModelSelectionIntoProviderModelOptions( + normalizedStickyModelSelection, + stickyModelOptions, + ); + const stickyModelSelection = legacySyncModelSelectionOptions( + normalizedStickyModelSelection, + nextStickyModelOptions, + ); + stickyModelSelectionByProvider = legacyToModelSelectionByProvider( + stickyModelSelection, + nextStickyModelOptions, + ); + } + return { draftsByThreadId: normalizePersistedDraftsByThreadId(normalizedPersistedState.draftsByThreadId), draftThreadsByThreadId, projectDraftThreadIdByProjectId, - stickyModelSelection, - stickyModelOptions: nextStickyModelOptions ?? {}, + stickyModelSelectionByProvider, }; } @@ -1097,19 +1218,11 @@ function hydrateImagesFromPersisted( function toHydratedThreadDraft( persistedDraft: PersistedComposerThreadDraftState, ): ComposerThreadDraftState { - const legacyPersistedDraft = persistedDraft as LegacyPersistedComposerThreadDraftState; - const normalizedModelSelection = normalizeModelSelection(persistedDraft.modelSelection, { - provider: legacyPersistedDraft.provider, - model: legacyPersistedDraft.model, - modelOptions: legacyPersistedDraft.modelOptions, - legacyCodex: legacyPersistedDraft, - }); - const normalizedModelOptions = normalizeProviderModelOptions( - persistedDraft.modelOptions ?? legacyPersistedDraft.modelOptions, - ); - const modelOptions = - mergeModelSelectionIntoProviderModelOptions(normalizedModelSelection, normalizedModelOptions) ?? - null; + // The persisted draft is already in v3 shape (migration handles older formats) + const modelSelectionByProvider: Partial> = + persistedDraft.modelSelectionByProvider ?? {}; + const activeProvider = normalizeProviderKind(persistedDraft.activeProvider) ?? null; + return { prompt: persistedDraft.prompt, images: hydrateImagesFromPersisted(persistedDraft.attachments), @@ -1120,8 +1233,8 @@ function toHydratedThreadDraft( ...context, text: "", })) ?? [], - modelSelection: syncModelSelectionOptions(normalizedModelSelection, modelOptions), - modelOptions, + modelSelectionByProvider, + activeProvider, runtimeMode: persistedDraft.runtimeMode ?? null, interactionMode: persistedDraft.interactionMode ?? null, }; @@ -1133,8 +1246,7 @@ export const useComposerDraftStore = create()( draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, - stickyModelSelection: null, - stickyModelOptions: {}, + stickyModelSelectionByProvider: {}, getDraftThreadByProjectId: (projectId) => { if (projectId.length === 0) { return null; @@ -1381,52 +1493,56 @@ export const useComposerDraftStore = create()( }); }, setStickyModelSelection: (modelSelection) => { - const normalizedModelSelection = normalizeModelSelection(modelSelection); - set((state) => - Equal.equals( - state.stickyModelSelection, - syncModelSelectionOptions( - normalizedModelSelection, - mergeModelSelectionIntoProviderModelOptions( - normalizedModelSelection, - state.stickyModelOptions, - ), - ), - ) - ? state - : { - stickyModelSelection: syncModelSelectionOptions( - normalizedModelSelection, - mergeModelSelectionIntoProviderModelOptions( - normalizedModelSelection, - state.stickyModelOptions, - ), - ), - stickyModelOptions: - mergeModelSelectionIntoProviderModelOptions( - normalizedModelSelection, - state.stickyModelOptions, - ) ?? {}, - }, - ); + const normalized = normalizeModelSelection(modelSelection); + set((state) => { + if (!normalized) { + return state; + } + const nextMap: Partial> = { + ...state.stickyModelSelectionByProvider, + [normalized.provider]: normalized, + }; + if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) { + return state; + } + return { stickyModelSelectionByProvider: nextMap }; + }); }, - setStickyModelOptions: (modelOptions) => { - const normalizedModelOptions = normalizeProviderModelOptions(modelOptions) ?? {}; + applyStickyState: (threadId) => { + if (threadId.length === 0) { + return; + } set((state) => { - const nextStickyModelSelection = syncModelSelectionOptions( - state.stickyModelSelection, - normalizedModelOptions, - ); - if ( - Equal.equals(state.stickyModelOptions, normalizedModelOptions) && - Equal.equals(state.stickyModelSelection, nextStickyModelSelection) - ) { + const stickyMap = state.stickyModelSelectionByProvider; + if (Object.keys(stickyMap).length === 0) { return state; } - return { - stickyModelSelection: nextStickyModelSelection, - stickyModelOptions: normalizedModelOptions, + const existing = state.draftsByThreadId[threadId]; + const base = existing ?? createEmptyThreadDraft(); + const nextMap = { ...base.modelSelectionByProvider }; + for (const [provider, selection] of Object.entries(stickyMap)) { + if (selection) { + const current = nextMap[provider as ProviderKind]; + nextMap[provider as ProviderKind] = { + ...selection, + model: current?.model ?? selection.model, + }; + } + } + if (Equal.equals(base.modelSelectionByProvider, nextMap)) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...base, + modelSelectionByProvider: nextMap, }; + const nextDraftsByThreadId = { ...state.draftsByThreadId }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadId[threadId]; + } else { + nextDraftsByThreadId[threadId] = nextDraft; + } + return { draftsByThreadId: nextDraftsByThreadId }; }); }, setPrompt: (threadId, prompt) => { @@ -1476,31 +1592,39 @@ export const useComposerDraftStore = create()( if (threadId.length === 0) { return; } - const normalizedModelSelection = normalizeModelSelection(modelSelection); + const normalized = normalizeModelSelection(modelSelection); set((state) => { const existing = state.draftsByThreadId[threadId]; - if (!existing && normalizedModelSelection === null) { + if (!existing && normalized === null) { return state; } const base = existing ?? createEmptyThreadDraft(); - const nextModelOptions = mergeModelSelectionIntoProviderModelOptions( - normalizedModelSelection, - base.modelOptions, - ); - const nextModelSelection = syncModelSelectionOptions( - normalizedModelSelection, - nextModelOptions, - ); + const nextMap = { ...base.modelSelectionByProvider }; + if (normalized) { + const current = nextMap[normalized.provider]; + if (normalized.options !== undefined) { + // Explicit options provided → use them + nextMap[normalized.provider] = normalized; + } else { + // No options in selection → preserve existing options, update provider+model + nextMap[normalized.provider] = { + provider: normalized.provider, + model: normalized.model, + ...(current?.options ? { options: current.options } : {}), + }; + } + } + const nextActiveProvider = normalized?.provider ?? base.activeProvider; if ( - Equal.equals(base.modelSelection, nextModelSelection) && - Equal.equals(base.modelOptions, nextModelOptions) + Equal.equals(base.modelSelectionByProvider, nextMap) && + base.activeProvider === nextActiveProvider ) { return state; } const nextDraft: ComposerThreadDraftState = { ...base, - modelSelection: nextModelSelection, - modelOptions: nextModelOptions, + modelSelectionByProvider: nextMap, + activeProvider: nextActiveProvider, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; if (shouldRemoveDraft(nextDraft)) { @@ -1515,27 +1639,35 @@ export const useComposerDraftStore = create()( if (threadId.length === 0) { return; } + const normalizedOpts = normalizeProviderModelOptions(modelOptions); set((state) => { const existing = state.draftsByThreadId[threadId]; - const nextModelOptions = normalizeProviderModelOptions(modelOptions); - if (!existing && nextModelOptions === null) { + if (!existing && normalizedOpts === null) { return state; } const base = existing ?? createEmptyThreadDraft(); - const nextModelSelection = syncModelSelectionOptions( - base.modelSelection, - nextModelOptions, - ); - if ( - Equal.equals(base.modelOptions, nextModelOptions) && - Equal.equals(base.modelSelection, nextModelSelection) - ) { + const nextMap = { ...base.modelSelectionByProvider }; + for (const provider of ["codex", "claudeAgent"] as const) { + const opts = normalizedOpts?.[provider]; + const current = nextMap[provider]; + if (opts) { + nextMap[provider] = { + provider, + model: current?.model ?? getDefaultModel(provider), + options: opts, + }; + } else if (current?.options) { + // Remove options but keep the selection + const { options: _, ...rest } = current; + nextMap[provider] = rest as ModelSelection; + } + } + if (Equal.equals(base.modelSelectionByProvider, nextMap)) { return state; } const nextDraft: ComposerThreadDraftState = { ...base, - modelSelection: nextModelSelection, - modelOptions: nextModelOptions, + modelSelectionByProvider: nextMap, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; if (shouldRemoveDraft(nextDraft)) { @@ -1554,47 +1686,64 @@ export const useComposerDraftStore = create()( if (normalizedProvider === null) { return; } + // Normalize just this provider's options + const normalizedOpts = normalizeProviderModelOptions( + { [normalizedProvider]: nextProviderOptions }, + normalizedProvider, + ); + const providerOpts = normalizedOpts?.[normalizedProvider]; + set((state) => { const existing = state.draftsByThreadId[threadId]; const base = existing ?? createEmptyThreadDraft(); - const nextModelOptions = replaceProviderModelOptions( - base.modelOptions, - normalizedProvider, - nextProviderOptions, - ); - const nextModelSelection = syncModelSelectionOptions( - base.modelSelection, - nextModelOptions, - ); - const nextStickyModelOptions = - options?.persistSticky === true - ? (replaceProviderModelOptions( - state.stickyModelOptions, - normalizedProvider, - nextProviderOptions, - ) ?? {}) - : state.stickyModelOptions; - const stickySelectionBase = - state.stickyModelSelection ?? - (options?.persistSticky === true ? base.modelSelection : null); - const nextStickyModelSelection = - options?.persistSticky === true - ? syncModelSelectionOptions(stickySelectionBase, nextStickyModelOptions) - : state.stickyModelSelection; + + // Update the map entry for this provider + const nextMap = { ...base.modelSelectionByProvider }; + const currentForProvider = nextMap[normalizedProvider]; + if (providerOpts) { + nextMap[normalizedProvider] = { + provider: normalizedProvider, + model: currentForProvider?.model ?? getDefaultModel(normalizedProvider), + options: providerOpts, + }; + } else if (currentForProvider?.options) { + const { options: _, ...rest } = currentForProvider; + nextMap[normalizedProvider] = rest as ModelSelection; + } + + // Handle sticky persistence + let nextStickyMap = state.stickyModelSelectionByProvider; + if (options?.persistSticky === true) { + nextStickyMap = { ...state.stickyModelSelectionByProvider }; + const stickyBase = + nextStickyMap[normalizedProvider] ?? + base.modelSelectionByProvider[normalizedProvider] ?? + ({ + provider: normalizedProvider, + model: getDefaultModel(normalizedProvider), + } as ModelSelection); + if (providerOpts) { + nextStickyMap[normalizedProvider] = { + ...stickyBase, + provider: normalizedProvider, + options: providerOpts, + }; + } else if (stickyBase.options) { + const { options: _, ...rest } = stickyBase; + nextStickyMap[normalizedProvider] = rest as ModelSelection; + } + } if ( - Equal.equals(base.modelSelection, nextModelSelection) && - Equal.equals(base.modelOptions, nextModelOptions) && - Equal.equals(state.stickyModelOptions, nextStickyModelOptions) && - Equal.equals(state.stickyModelSelection, nextStickyModelSelection) + Equal.equals(base.modelSelectionByProvider, nextMap) && + Equal.equals(state.stickyModelSelectionByProvider, nextStickyMap) ) { return state; } const nextDraft: ComposerThreadDraftState = { ...base, - modelSelection: nextModelSelection, - modelOptions: nextModelOptions, + modelSelectionByProvider: nextMap, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; if (shouldRemoveDraft(nextDraft)) { @@ -1606,10 +1755,7 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId, ...(options?.persistSticky === true - ? { - stickyModelSelection: nextStickyModelSelection, - stickyModelOptions: nextStickyModelOptions, - } + ? { stickyModelSelectionByProvider: nextStickyMap } : {}), }; }); @@ -1968,8 +2114,7 @@ export const useComposerDraftStore = create()( draftsByThreadId, draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId, projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId, - stickyModelSelection: normalizedPersisted.stickyModelSelection as ModelSelection | null, - stickyModelOptions: normalizedPersisted.stickyModelOptions ?? {}, + stickyModelSelectionByProvider: normalizedPersisted.stickyModelSelectionByProvider ?? {}, }; }, }, diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 69010dfe94..ffb4b5cf67 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -12,8 +12,6 @@ import { useStore } from "../store"; export function useHandleNewThread() { const projects = useStore((store) => store.projects); const threads = useStore((store) => store.threads); - const stickyModelSelection = useComposerDraftStore((store) => store.stickyModelSelection); - const stickyModelOptions = useComposerDraftStore((store) => store.stickyModelOptions); const navigate = useNavigate(); const routeThreadId = useParams({ strict: false, @@ -40,8 +38,7 @@ export function useHandleNewThread() { clearProjectDraftThreadId, getDraftThread, getDraftThreadByProjectId, - setModelSelection, - setModelOptions, + applyStickyState, setDraftThreadContext, setProjectDraftThreadId, } = useComposerDraftStore.getState(); @@ -100,12 +97,7 @@ export function useHandleNewThread() { envMode: options?.envMode ?? "local", runtimeMode: DEFAULT_RUNTIME_MODE, }); - if (stickyModelSelection) { - setModelSelection(threadId, stickyModelSelection); - } - if (Object.keys(stickyModelOptions).length > 0) { - setModelOptions(threadId, stickyModelOptions); - } + applyStickyState(threadId); await navigate({ to: "/$threadId", @@ -113,7 +105,7 @@ export function useHandleNewThread() { }); })(); }, - [navigate, routeThreadId, stickyModelOptions, stickyModelSelection], + [navigate, routeThreadId], ); return { diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index dac8ce6ae0..9997809d2b 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -26,26 +26,166 @@ export const ProviderModelOptions = Schema.Struct({ }); export type ProviderModelOptions = typeof ProviderModelOptions.Type; -type ModelOption = { +export type EffortOption = { + readonly value: string; + readonly label: string; + readonly isDefault?: true; +}; + +export type ModelCapabilities = { + readonly reasoningEffortLevels: readonly EffortOption[]; + readonly supportsFastMode: boolean; + readonly supportsThinkingToggle: boolean; + readonly promptInjectedEffortLevels: readonly string[]; +}; + +type ModelDefinition = { readonly slug: string; readonly name: string; + readonly capabilities: ModelCapabilities; }; +/** + * TODO: This should not be a static array, each provider + * should return its own model list over the WS API. + */ export const MODEL_OPTIONS_BY_PROVIDER = { codex: [ - { slug: "gpt-5.4", name: "GPT-5.4" }, - { slug: "gpt-5.4-mini", name: "GPT-5.4 Mini" }, - { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, - { slug: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark" }, - { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, - { slug: "gpt-5.2", name: "GPT-5.2" }, + { + slug: "gpt-5.4", + name: "GPT-5.4", + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.4-mini", + name: "GPT-5.4 Mini", + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.3-codex-spark", + name: "GPT-5.3 Codex Spark", + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.2-codex", + name: "GPT-5.2 Codex", + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.2", + name: "GPT-5.2", + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, ], claudeAgent: [ - { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, - { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, - { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" }, + { + slug: "claude-opus-4-6", + name: "Claude Opus 4.6", + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "max", label: "Max" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: ["ultrathink"], + }, + }, + { + slug: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: ["ultrathink"], + }, + }, + { + slug: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: true, + promptInjectedEffortLevels: [], + }, + }, ], -} as const satisfies Record; +} as const satisfies Record; export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; type BuiltInModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"]; @@ -85,12 +225,18 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record; +// ── Model capabilities index ────────────────────────────────────────── -export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = { - codex: "high", - claudeAgent: "high", -} as const satisfies Record; +export const MODEL_CAPABILITIES_INDEX = Object.fromEntries( + Object.entries(MODEL_OPTIONS_BY_PROVIDER).map(([provider, models]) => [ + provider, + Object.fromEntries(models.map((m) => [m.slug, m.capabilities])), + ]), +) as unknown as Record>; + +// ── Provider display names ──────────────────────────────────────────── + +export const PROVIDER_DISPLAY_NAMES: Record = { + codex: "Codex", + claudeAgent: "Claude", +}; diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 75fc4be539..d62a273c2c 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -2,32 +2,25 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_MODEL, DEFAULT_MODEL_BY_PROVIDER, - DEFAULT_REASONING_EFFORT_BY_PROVIDER, MODEL_OPTIONS, MODEL_OPTIONS_BY_PROVIDER, - REASONING_EFFORT_OPTIONS_BY_PROVIDER, + CODEX_REASONING_EFFORT_OPTIONS, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, - getEffectiveClaudeCodeEffort, getDefaultModel, - getDefaultReasoningEffort, + getModelCapabilities, getModelOptions, - getReasoningEffortOptions, isClaudeUltrathinkPrompt, normalizeClaudeModelOptions, normalizeCodexModelOptions, normalizeModelSlug, - resolveReasoningEffortForProvider, resolveSelectableModel, resolveModelSlug, resolveModelSlugForProvider, - supportsClaudeAdaptiveReasoning, - supportsClaudeFastMode, - supportsClaudeMaxEffort, - supportsClaudeThinkingToggle, - supportsClaudeUltrathinkKeyword, + getDefaultEffort, + hasEffortLevel, } from "./model"; describe("normalizeModelSlug", () => { @@ -158,13 +151,16 @@ describe("resolveSelectableModel", () => { }); }); -describe("getReasoningEffortOptions", () => { +describe("getModelCapabilities reasoningEffortLevels", () => { + const values = (provider: "codex" | "claudeAgent", model: string | null) => + getModelCapabilities(provider, model).reasoningEffortLevels.map((l) => l.value); + it("returns codex reasoning options for codex", () => { - expect(getReasoningEffortOptions("codex")).toEqual(REASONING_EFFORT_OPTIONS_BY_PROVIDER.codex); + expect(values("codex", "gpt-5.4")).toEqual([...CODEX_REASONING_EFFORT_OPTIONS]); }); it("returns claude effort options for Opus 4.6", () => { - expect(getReasoningEffortOptions("claudeAgent", "claude-opus-4-6")).toEqual([ + expect(values("claudeAgent", "claude-opus-4-6")).toEqual([ "low", "medium", "high", @@ -174,7 +170,7 @@ describe("getReasoningEffortOptions", () => { }); it("returns claude effort options for Sonnet 4.6", () => { - expect(getReasoningEffortOptions("claudeAgent", "claude-sonnet-4-6")).toEqual([ + expect(values("claudeAgent", "claude-sonnet-4-6")).toEqual([ "low", "medium", "high", @@ -183,28 +179,37 @@ describe("getReasoningEffortOptions", () => { }); it("returns no claude effort options for Haiku 4.5", () => { - expect(getReasoningEffortOptions("claudeAgent", "claude-haiku-4-5")).toEqual([]); + expect(values("claudeAgent", "claude-haiku-4-5")).toEqual([]); }); -}); -describe("getDefaultReasoningEffort", () => { - it("returns provider-scoped defaults", () => { - expect(getDefaultReasoningEffort("codex")).toBe(DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex); - expect(getDefaultReasoningEffort("claudeAgent")).toBe( - DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeAgent, + it("co-locates labels with effort values", () => { + const levels = getModelCapabilities("claudeAgent", "claude-opus-4-6").reasoningEffortLevels; + const high = levels.find((l) => l.value === "high"); + expect(high).toEqual({ value: "high", label: "High", isDefault: true }); + const xhigh = getModelCapabilities("codex", "gpt-5.4").reasoningEffortLevels.find( + (l) => l.value === "xhigh", ); + expect(xhigh).toEqual({ value: "xhigh", label: "Extra High" }); }); }); -describe("resolveReasoningEffortForProvider", () => { - it("accepts provider-scoped effort values", () => { - expect(resolveReasoningEffortForProvider("codex", "xhigh")).toBe("xhigh"); - expect(resolveReasoningEffortForProvider("claudeAgent", "ultrathink")).toBe("ultrathink"); +describe("getDefaultEffort", () => { + it("returns the default effort from capabilities", () => { + expect(getDefaultEffort(getModelCapabilities("codex", "gpt-5.4"))).toBe("high"); + expect(getDefaultEffort(getModelCapabilities("claudeAgent", "claude-opus-4-6"))).toBe("high"); + expect(getDefaultEffort(getModelCapabilities("claudeAgent", "claude-haiku-4-5"))).toBeNull(); }); +}); + +describe("hasEffortLevel", () => { + it("validates effort against model capabilities", () => { + const opusCaps = getModelCapabilities("claudeAgent", "claude-opus-4-6"); + expect(hasEffortLevel(opusCaps, "max")).toBe(true); + expect(hasEffortLevel(opusCaps, "xhigh")).toBe(false); - it("rejects effort values from the wrong provider", () => { - expect(resolveReasoningEffortForProvider("codex", "max")).toBeNull(); - expect(resolveReasoningEffortForProvider("claudeAgent", "xhigh")).toBeNull(); + const codexCaps = getModelCapabilities("codex", "gpt-5.4"); + expect(hasEffortLevel(codexCaps, "xhigh")).toBe(true); + expect(hasEffortLevel(codexCaps, "max")).toBe(false); }); }); @@ -223,27 +228,17 @@ describe("applyClaudePromptEffortPrefix", () => { }); }); -describe("getEffectiveClaudeCodeEffort", () => { - it("does not persist ultrathink into Claude runtime configuration", () => { - expect(getEffectiveClaudeCodeEffort("ultrathink")).toBeNull(); - expect(getEffectiveClaudeCodeEffort("high")).toBe("high"); - }); - - it("returns null when no claude effort is selected", () => { - expect(getEffectiveClaudeCodeEffort(null)).toBeNull(); - expect(getEffectiveClaudeCodeEffort(undefined)).toBeNull(); - }); -}); - describe("normalizeCodexModelOptions", () => { it("drops default-only codex options", () => { expect( - normalizeCodexModelOptions({ reasoningEffort: "high", fastMode: false }), + normalizeCodexModelOptions("gpt-5.4", { reasoningEffort: "high", fastMode: false }), ).toBeUndefined(); }); it("preserves non-default codex options", () => { - expect(normalizeCodexModelOptions({ reasoningEffort: "xhigh", fastMode: true })).toEqual({ + expect( + normalizeCodexModelOptions("gpt-5.4", { reasoningEffort: "xhigh", fastMode: true }), + ).toEqual({ reasoningEffort: "xhigh", fastMode: true, }); @@ -272,49 +267,50 @@ describe("normalizeClaudeModelOptions", () => { }); }); -describe("supportsClaudeAdaptiveReasoning", () => { +describe("getModelCapabilities Claude capability flags", () => { it("only enables adaptive reasoning for Opus 4.6 and Sonnet 4.6", () => { - expect(supportsClaudeAdaptiveReasoning("claude-opus-4-6")).toBe(true); - expect(supportsClaudeAdaptiveReasoning("claude-sonnet-4-6")).toBe(true); - expect(supportsClaudeAdaptiveReasoning("claude-haiku-4-5")).toBe(false); - expect(supportsClaudeAdaptiveReasoning(undefined)).toBe(false); + const has = (m: string | undefined) => + getModelCapabilities("claudeAgent", m).reasoningEffortLevels.length > 0; + expect(has("claude-opus-4-6")).toBe(true); + expect(has("claude-sonnet-4-6")).toBe(true); + expect(has("claude-haiku-4-5")).toBe(false); + expect(has(undefined)).toBe(false); }); -}); -describe("supportsClaudeMaxEffort", () => { it("only enables max effort for Opus 4.6", () => { - expect(supportsClaudeMaxEffort("claude-opus-4-6")).toBe(true); - expect(supportsClaudeMaxEffort("claude-sonnet-4-6")).toBe(false); - expect(supportsClaudeMaxEffort("claude-haiku-4-5")).toBe(false); - expect(supportsClaudeMaxEffort(undefined)).toBe(false); + const has = (m: string | undefined) => + getModelCapabilities("claudeAgent", m).reasoningEffortLevels.some((l) => l.value === "max"); + expect(has("claude-opus-4-6")).toBe(true); + expect(has("claude-sonnet-4-6")).toBe(false); + expect(has("claude-haiku-4-5")).toBe(false); + expect(has(undefined)).toBe(false); }); -}); -describe("supportsClaudeFastMode", () => { it("only enables Claude fast mode for Opus 4.6", () => { - expect(supportsClaudeFastMode("claude-opus-4-6")).toBe(true); - expect(supportsClaudeFastMode("opus")).toBe(true); - expect(supportsClaudeFastMode("claude-sonnet-4-6")).toBe(false); - expect(supportsClaudeFastMode("claude-haiku-4-5")).toBe(false); - expect(supportsClaudeFastMode(undefined)).toBe(false); + const has = (m: string | undefined) => getModelCapabilities("claudeAgent", m).supportsFastMode; + expect(has("claude-opus-4-6")).toBe(true); + expect(has("opus")).toBe(true); + expect(has("claude-sonnet-4-6")).toBe(false); + expect(has("claude-haiku-4-5")).toBe(false); + expect(has(undefined)).toBe(false); }); -}); -describe("supportsClaudeUltrathinkKeyword", () => { it("only enables ultrathink keyword handling for Opus 4.6 and Sonnet 4.6", () => { - expect(supportsClaudeUltrathinkKeyword("claude-opus-4-6")).toBe(true); - expect(supportsClaudeUltrathinkKeyword("claude-sonnet-4-6")).toBe(true); - expect(supportsClaudeUltrathinkKeyword("claude-haiku-4-5")).toBe(false); + const has = (m: string | undefined) => + getModelCapabilities("claudeAgent", m).reasoningEffortLevels.length > 0; + expect(has("claude-opus-4-6")).toBe(true); + expect(has("claude-sonnet-4-6")).toBe(true); + expect(has("claude-haiku-4-5")).toBe(false); }); -}); -describe("supportsClaudeThinkingToggle", () => { it("only enables the Claude thinking toggle for Haiku 4.5", () => { - expect(supportsClaudeThinkingToggle("claude-opus-4-6")).toBe(false); - expect(supportsClaudeThinkingToggle("claude-sonnet-4-6")).toBe(false); - expect(supportsClaudeThinkingToggle("claude-haiku-4-5")).toBe(true); - expect(supportsClaudeThinkingToggle("haiku")).toBe(true); - expect(supportsClaudeThinkingToggle(undefined)).toBe(false); + const has = (m: string | undefined) => + getModelCapabilities("claudeAgent", m).supportsThinkingToggle; + expect(has("claude-opus-4-6")).toBe(false); + expect(has("claude-sonnet-4-6")).toBe(false); + expect(has("claude-haiku-4-5")).toBe(true); + expect(has("haiku")).toBe(true); + expect(has(undefined)).toBe(false); }); }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 4b678531fd..53ebc856fd 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,18 +1,15 @@ import { - CLAUDE_CODE_EFFORT_OPTIONS, - CODEX_REASONING_EFFORT_OPTIONS, DEFAULT_MODEL_BY_PROVIDER, - DEFAULT_REASONING_EFFORT_BY_PROVIDER, + MODEL_CAPABILITIES_INDEX, MODEL_OPTIONS_BY_PROVIDER, MODEL_SLUG_ALIASES_BY_PROVIDER, - REASONING_EFFORT_OPTIONS_BY_PROVIDER, type ClaudeModelOptions, type ClaudeCodeEffort, type CodexModelOptions, - type CodexReasoningEffort, + type ModelCapabilities, type ModelSlug, - type ProviderReasoningEffort, type ProviderKind, + CodexReasoningEffort, } from "@t3tools/contracts"; const MODEL_SLUG_SET_BY_PROVIDER: Record> = { @@ -20,10 +17,6 @@ const MODEL_SLUG_SET_BY_PROVIDER: Record> = codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), }; -const CLAUDE_OPUS_4_6_MODEL = "claude-opus-4-6"; -const CLAUDE_SONNET_4_6_MODEL = "claude-sonnet-4-6"; -const CLAUDE_HAIKU_4_5_MODEL = "claude-haiku-4-5"; - export interface SelectableModelOption { slug: string; name: string; @@ -37,25 +30,34 @@ export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug { return DEFAULT_MODEL_BY_PROVIDER[provider]; } -export function supportsClaudeFastMode(model: string | null | undefined): boolean { - return normalizeModelSlug(model, "claudeAgent") === CLAUDE_OPUS_4_6_MODEL; -} +// ── Effort helpers ──────────────────────────────────────────────────── -export function supportsClaudeAdaptiveReasoning(model: string | null | undefined): boolean { - const normalized = normalizeModelSlug(model, "claudeAgent"); - return normalized === CLAUDE_OPUS_4_6_MODEL || normalized === CLAUDE_SONNET_4_6_MODEL; +/** Check whether a capabilities object includes a given effort value. */ +export function hasEffortLevel(caps: ModelCapabilities, value: string): boolean { + return caps.reasoningEffortLevels.some((l) => l.value === value); } -export function supportsClaudeMaxEffort(model: string | null | undefined): boolean { - return normalizeModelSlug(model, "claudeAgent") === CLAUDE_OPUS_4_6_MODEL; +/** Return the default effort value for a capabilities object, or null if none. */ +export function getDefaultEffort(caps: ModelCapabilities): string | null { + return caps.reasoningEffortLevels.find((l) => l.isDefault)?.value ?? null; } -export function supportsClaudeUltrathinkKeyword(model: string | null | undefined): boolean { - return supportsClaudeAdaptiveReasoning(model); -} +// ── Data-driven capability resolver ─────────────────────────────────── -export function supportsClaudeThinkingToggle(model: string | null | undefined): boolean { - return normalizeModelSlug(model, "claudeAgent") === CLAUDE_HAIKU_4_5_MODEL; +export function getModelCapabilities( + provider: ProviderKind, + model: string | null | undefined, +): ModelCapabilities { + const slug = normalizeModelSlug(model, provider); + if (slug && MODEL_CAPABILITIES_INDEX[provider]?.[slug]) { + return MODEL_CAPABILITIES_INDEX[provider][slug]; + } + return { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }; } export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { @@ -136,85 +138,20 @@ export function resolveModelSlugForProvider( return resolveModelSlug(model, provider); } -export function getReasoningEffortOptions(provider: "codex"): ReadonlyArray; -export function getReasoningEffortOptions( - provider: "claudeAgent", - model?: string | null | undefined, -): ReadonlyArray; -export function getReasoningEffortOptions( - provider?: ProviderKind, - model?: string | null | undefined, -): ReadonlyArray; -export function getReasoningEffortOptions( - provider: ProviderKind = "codex", - model?: string | null | undefined, -): ReadonlyArray { - if (provider === "claudeAgent") { - if (supportsClaudeMaxEffort(model)) { - return ["low", "medium", "high", "max", "ultrathink"]; - } - if (supportsClaudeAdaptiveReasoning(model)) { - return ["low", "medium", "high", "ultrathink"]; - } - return []; - } - return REASONING_EFFORT_OPTIONS_BY_PROVIDER[provider]; -} - -export function getDefaultReasoningEffort(provider: "codex"): CodexReasoningEffort; -export function getDefaultReasoningEffort(provider: "claudeAgent"): ClaudeCodeEffort; -export function getDefaultReasoningEffort(provider?: ProviderKind): ProviderReasoningEffort; -export function getDefaultReasoningEffort( - provider: ProviderKind = "codex", -): ProviderReasoningEffort { - return DEFAULT_REASONING_EFFORT_BY_PROVIDER[provider]; -} - -export function resolveReasoningEffortForProvider( - provider: "codex", - effort: string | null | undefined, -): CodexReasoningEffort | null; -export function resolveReasoningEffortForProvider( - provider: "claudeAgent", - effort: string | null | undefined, -): ClaudeCodeEffort | null; -export function resolveReasoningEffortForProvider( - provider: ProviderKind, - effort: string | null | undefined, -): ProviderReasoningEffort | null; -export function resolveReasoningEffortForProvider( - provider: ProviderKind, - effort: string | null | undefined, -): ProviderReasoningEffort | null { - if (typeof effort !== "string") { - return null; - } - - const trimmed = effort.trim(); - if (!trimmed) { - return null; - } - - const options = REASONING_EFFORT_OPTIONS_BY_PROVIDER[provider] as ReadonlyArray; - return options.includes(trimmed) ? (trimmed as ProviderReasoningEffort) : null; -} - -export function getEffectiveClaudeCodeEffort( - effort: ClaudeCodeEffort | null | undefined, -): Exclude | null { - if (!effort) { - return null; - } - return effort === "ultrathink" ? null : effort; +/** Trim a string, returning null for empty/missing values. */ +export function trimOrNull(value: T | null | undefined): T | null { + if (typeof value !== "string") return null; + const trimmed = value.trim() as T; + return trimmed || null; } export function normalizeCodexModelOptions( + model: string | null | undefined, modelOptions: CodexModelOptions | null | undefined, ): CodexModelOptions | undefined { - const defaultReasoningEffort = getDefaultReasoningEffort("codex"); - const reasoningEffort = - resolveReasoningEffortForProvider("codex", modelOptions?.reasoningEffort) ?? - defaultReasoningEffort; + const caps = getModelCapabilities("codex", model); + const defaultReasoningEffort = getDefaultEffort(caps) as CodexReasoningEffort; + const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort; const fastModeEnabled = modelOptions?.fastMode === true; const nextOptions: CodexModelOptions = { ...(reasoningEffort !== defaultReasoningEffort ? { reasoningEffort } : {}), @@ -227,20 +164,20 @@ export function normalizeClaudeModelOptions( model: string | null | undefined, modelOptions: ClaudeModelOptions | null | undefined, ): ClaudeModelOptions | undefined { - const reasoningOptions = getReasoningEffortOptions("claudeAgent", model); - const defaultReasoningEffort = getDefaultReasoningEffort("claudeAgent"); - const resolvedEffort = resolveReasoningEffortForProvider("claudeAgent", modelOptions?.effort); + const caps = getModelCapabilities("claudeAgent", model); + const defaultReasoningEffort = getDefaultEffort(caps); + const resolvedEffort = trimOrNull(modelOptions?.effort); + const isPromptInjected = caps.promptInjectedEffortLevels.includes(resolvedEffort ?? ""); const effort = resolvedEffort && - resolvedEffort !== "ultrathink" && - reasoningOptions.includes(resolvedEffort) && + !isPromptInjected && + hasEffortLevel(caps, resolvedEffort) && resolvedEffort !== defaultReasoningEffort ? resolvedEffort : undefined; const thinking = - supportsClaudeThinkingToggle(model) && modelOptions?.thinking === false ? false : undefined; - const fastMode = - supportsClaudeFastMode(model) && modelOptions?.fastMode === true ? true : undefined; + caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; + const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; const nextOptions: ClaudeModelOptions = { ...(thinking === false ? { thinking: false } : {}), ...(effort ? { effort } : {}), @@ -265,5 +202,3 @@ export function applyClaudePromptEffortPrefix( } return `Ultrathink:\n${trimmed}`; } - -export { CLAUDE_CODE_EFFORT_OPTIONS, CODEX_REASONING_EFFORT_OPTIONS }; From bfb4d5be0a9ded518315c2272f0aa2d49ebd7fa6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 20:34:36 -0700 Subject: [PATCH 33/38] fmt --- apps/web/src/components/chat/TraitsPicker.browser.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index e947d0af79..f3e92752a9 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -1,6 +1,11 @@ import "../../index.css"; -import { type ModelSelection, DEFAULT_MODEL_BY_PROVIDER, ProjectId, ThreadId } from "@t3tools/contracts"; +import { + type ModelSelection, + DEFAULT_MODEL_BY_PROVIDER, + ProjectId, + ThreadId, +} from "@t3tools/contracts"; import { page } from "vitest/browser"; import { useCallback } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -430,9 +435,7 @@ describe("TraitsPicker (Codex)", () => { await page.getByRole("button").click(); await page.getByRole("menuitemradio", { name: "on" }).click(); - expect( - useComposerDraftStore.getState().stickyModelSelectionByProvider.codex, - ).toMatchObject({ + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toMatchObject({ provider: "codex", options: { fastMode: true, From f6f860d91b537adfe9474ae3b624d1d41079cbc0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 20:35:49 -0700 Subject: [PATCH 34/38] kewl --- .plans/18-provider-agnostic-cleanup.md | 753 ------------------------- 1 file changed, 753 deletions(-) delete mode 100644 .plans/18-provider-agnostic-cleanup.md diff --git a/.plans/18-provider-agnostic-cleanup.md b/.plans/18-provider-agnostic-cleanup.md deleted file mode 100644 index 1543f02ba2..0000000000 --- a/.plans/18-provider-agnostic-cleanup.md +++ /dev/null @@ -1,753 +0,0 @@ -# 18 - Provider-Agnostic Cleanup - -Follow-up to the `t3code/provider-kind-model` PR which introduced the `ModelSelection` -discriminated union and removed `inferProviderForModel()`. Three items remain to complete -the provider-agnostic vision and make adding new providers mechanical. - ---- - -## Item 1: Consolidate dual model-options representation - -### Problem - -The composer draft store carries two parallel representations of model options: - -``` -draft.modelSelection: { provider: "codex", model: "gpt-5.4", options: { fastMode: true } } -draft.modelOptions: { codex: { fastMode: true }, claudeAgent: { effort: "max" } } -``` - -Every mutation must sync both via `syncModelSelectionOptions()` and -`mergeModelSelectionIntoProviderModelOptions()`. The sync logic is a bug surface and -the dual representation creates ambiguity about which is authoritative. - -The `ProviderModelOptions` bag exists for a good reason: when you switch providers and -switch back, your per-provider options should survive the round-trip. The fix is not to -remove the bag concept, but to eliminate the *dual* representation. - -### Target state - -Replace the parallel fields with a single `ModelSelection`-per-provider map: - -```ts -// Before -draft.modelSelection: ModelSelection | null // active selection (provider + model + options) -draft.modelOptions: ProviderModelOptions | null // bag of options keyed by provider - -// After -draft.modelSelectionByProvider: Partial> -draft.activeProvider: ProviderKind | null -``` - -The active `ModelSelection` is derived: -`draft.modelSelectionByProvider[draft.activeProvider]`. No sync needed -- each provider's -full selection (model + options) lives in one place. Switching providers changes -`activeProvider`; the old provider's entry is preserved. - -Sticky state follows the same shape: -```ts -stickyModelSelectionByProvider: Partial> -``` - -### Phase 1A: Refactor the draft store internals - -**Files:** `apps/web/src/composerDraftStore.ts`, `apps/web/src/composerDraftStore.test.ts` - -1. Replace `ComposerThreadDraftState` fields: - - Remove `modelSelection: ModelSelection | null` - - Remove `modelOptions: ProviderModelOptions | null` - - Add `modelSelectionByProvider: Partial>` - - Add `activeProvider: ProviderKind | null` - -2. Replace global sticky fields in `ComposerDraftStoreState`: - - Remove `stickyModelSelection: ModelSelection | null` - - Remove `stickyModelOptions: ProviderModelOptions` - - Add `stickyModelSelectionByProvider: Partial>` - -3. Delete sync functions that exist only to maintain the dual representation: - - `syncModelSelectionOptions()` - - `mergeModelSelectionIntoProviderModelOptions()` - - `replaceProviderModelOptions()` - -4. Simplify store actions: - - `setModelSelection(threadId, selection)` -- writes to - `draft.modelSelectionByProvider[selection.provider]` and sets - `draft.activeProvider = selection.provider` - - `setProviderModelOptions(threadId, provider, options)` -- updates - `draft.modelSelectionByProvider[provider].options` in place. If the entry - doesn't exist yet, create it with the provider's default model. - - `setStickyModelSelection(selection)` -- writes to - `stickyModelSelectionByProvider[selection.provider]` - - Remove `setModelOptions()` (the bag setter) and `setStickyModelOptions()` - -5. Rewrite `deriveEffectiveComposerModelState()`: - - Read options from `draft.modelSelectionByProvider[provider].options` - - Fall back to `threadModelSelection.options` then - `projectModelSelection.options` - - No bag indexing needed - -6. Update persistence schema: - - `PersistedComposerThreadDraftState` replaces `modelSelection` + `modelOptions` - with `modelSelectionByProvider` + `activeProvider` - - `PersistedComposerDraftStoreState` replaces `stickyModelSelection` + - `stickyModelOptions` with `stickyModelSelectionByProvider` - - Bump storage version to 3 - - Write v2 -> v3 migration: for each draft, reconstruct - `modelSelectionByProvider` from the old `modelSelection` (active provider entry) - merged with `modelOptions` (other providers' entries with default models). - For sticky state, same approach. - -7. Update legacy migration code (`LegacyCodexFields`, `LegacyStickyModelFields`, - `LegacyThreadModelFields`) to produce the new shape directly. These can be - simplified since they no longer need to produce two parallel outputs. - -### Phase 1B: Remove `ProviderModelOptions` from contracts and UI consumers - -**Files:** `packages/contracts/src/model.ts`, `apps/web/src/components/chat/composerProviderRegistry.tsx`, -`apps/web/src/components/ChatView.tsx`, `apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx`, -`apps/web/src/components/chat/ClaudeTraitsPicker.tsx`, `apps/web/src/components/chat/CodexTraitsPicker.tsx` - -1. In `contracts/model.ts`: delete the `ProviderModelOptions` schema and type export. - Keep `CodexModelOptions` and `ClaudeModelOptions` -- they're referenced by the - `ModelSelection` variants in `orchestration.ts`. - -2. In `composerProviderRegistry.tsx`: change `ComposerProviderStateInput` to accept - `options: CodexModelOptions | ClaudeModelOptions | undefined` instead of - `modelOptions: ProviderModelOptions | null | undefined`. The registry entry - receives already-extracted provider options from `modelSelection.options`. - -3. In `ChatView.tsx`: `useEffectiveComposerModelState` returns - `{ selectedModel, options }` instead of `{ selectedModel, modelOptions }`. - `getComposerProviderState` receives the typed options directly. - -4. In `CompactComposerControlsMenu.browser.tsx`: remove the - `provider === "codex" ? props.modelOptions.codex : props.modelOptions.claudeAgent` - ternary. Component receives pre-extracted typed options. - -5. Trait pickers already receive typed `ClaudeModelOptions` / `CodexModelOptions`. - Only parent callsite plumbing changes. - -### Phase 1C: Server-side verification - -**Files:** `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts`, -`apps/server/src/provider/Layers/ProviderService.ts`, `apps/server/src/wsServer.ts` - -1. Verify the server never reads from a `ProviderModelOptions` bag. From analysis of - the current code, it doesn't -- the server works exclusively with - `ModelSelection.options`. This phase is a verification pass. - -2. Remove any residual imports of `ProviderModelOptions` in server code. - -3. Grep for remaining references to ensure the type is fully eliminated. - ---- - -## Item 2: Data-driven model capabilities - -### Problem - -`packages/shared/src/model.ts` is full of imperative capability checks: - -```ts -const CLAUDE_OPUS_4_6_MODEL = "claude-opus-4-6"; -export function supportsClaudeFastMode(model) { - return normalizeModelSlug(model, "claudeAgent") === CLAUDE_OPUS_4_6_MODEL; -} -``` - -Adding a new model or provider requires touching 5+ functions. The capability -information should be *data* on the model definition, not scattered conditionals. -This is the primary bottleneck for adding new providers. - -### Target state - -Model capabilities are declared inline with model definitions in `contracts/model.ts`. -The imperative `supportsXxx` functions in `shared/model.ts` become thin lookups against -a capabilities index. Adding a new model means adding one entry to the data table. - -### Phase 2A: Define a model capability schema - -**File:** `packages/contracts/src/model.ts` - -1. Define the capability shape: - -```ts -type ModelCapabilities = { - readonly reasoningEffortLevels: readonly string[]; - readonly supportsFastMode: boolean; - readonly supportsThinkingToggle: boolean; -}; - -type ModelDefinition = { - readonly slug: string; - readonly name: string; - readonly capabilities: ModelCapabilities; -}; -``` - -2. Embed capabilities in `MODEL_OPTIONS_BY_PROVIDER`: - -```ts -export const MODEL_OPTIONS_BY_PROVIDER = { - codex: [ - { - slug: "gpt-5.4", - name: "GPT-5.4", - capabilities: { - reasoningEffortLevels: CODEX_REASONING_EFFORT_OPTIONS, - supportsFastMode: true, - supportsThinkingToggle: false, - }, - }, - // ... other codex models - ], - claudeAgent: [ - { - slug: "claude-opus-4-6", - name: "Claude Opus 4.6", - capabilities: { - reasoningEffortLevels: ["low", "medium", "high", "max", "ultrathink"], - supportsFastMode: true, - supportsThinkingToggle: false, - }, - }, - { - slug: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - capabilities: { - reasoningEffortLevels: ["low", "medium", "high", "ultrathink"], - supportsFastMode: false, - supportsThinkingToggle: false, - }, - }, - { - slug: "claude-haiku-4-5", - name: "Claude Haiku 4.5", - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: true, - }, - }, - ], -} as const satisfies Record; -``` - -3. Build a lookup index: - -```ts -export const MODEL_CAPABILITIES_INDEX: Record< - ProviderKind, - Record -> = Object.fromEntries( - Object.entries(MODEL_OPTIONS_BY_PROVIDER).map(([provider, models]) => [ - provider, - Object.fromEntries(models.map((m) => [m.slug, m.capabilities])), - ]), -) as Record>; -``` - -4. Define provider-level defaults for custom/unknown models: - -```ts -export const DEFAULT_CAPABILITIES_BY_PROVIDER: Record = { - codex: { - reasoningEffortLevels: CODEX_REASONING_EFFORT_OPTIONS as unknown as string[], - supportsFastMode: true, - supportsThinkingToggle: false, - }, - claudeAgent: { - reasoningEffortLevels: ["low", "medium", "high"], - supportsFastMode: false, - supportsThinkingToggle: false, - }, -}; -``` - -### Phase 2B: Replace imperative gates with data lookups - -**File:** `packages/shared/src/model.ts` - -1. Add a central capability resolver: - -```ts -export function getModelCapabilities( - provider: ProviderKind, - model: string | null | undefined, -): ModelCapabilities { - const slug = normalizeModelSlug(model, provider); - if (slug && MODEL_CAPABILITIES_INDEX[provider]?.[slug]) { - return MODEL_CAPABILITIES_INDEX[provider][slug]; - } - return DEFAULT_CAPABILITIES_BY_PROVIDER[provider]; -} -``` - -2. Rewrite `supportsXxx` functions as thin lookups (signatures unchanged, all call - sites continue to work): - -```ts -export function supportsClaudeFastMode(model: string | null | undefined): boolean { - return getModelCapabilities("claudeAgent", model).supportsFastMode; -} - -export function supportsClaudeThinkingToggle(model: string | null | undefined): boolean { - return getModelCapabilities("claudeAgent", model).supportsThinkingToggle; -} - -export function supportsClaudeAdaptiveReasoning(model: string | null | undefined): boolean { - return getModelCapabilities("claudeAgent", model).reasoningEffortLevels.length > 0; -} - -export function supportsClaudeMaxEffort(model: string | null | undefined): boolean { - return getModelCapabilities("claudeAgent", model).reasoningEffortLevels.includes("max"); -} - -export function supportsClaudeUltrathinkKeyword(model: string | null | undefined): boolean { - return supportsClaudeAdaptiveReasoning(model); -} -``` - -3. Rewrite `getReasoningEffortOptions()`: - -```ts -export function getReasoningEffortOptions( - provider: ProviderKind, - model?: string | null, -): readonly string[] { - return getModelCapabilities(provider, model).reasoningEffortLevels; -} -``` - - Remove the overloaded signatures that return provider-specific types. The return - type becomes `readonly string[]`. Consumers that need the specific type can cast, - or we can add type predicates later. - -4. Rewrite `normalizeClaudeModelOptions()` and `normalizeCodexModelOptions()` to use - `getModelCapabilities()` internally instead of calling individual `supportsXxx` - functions. Logic stays the same, data source changes. - -5. Remove hardcoded model constants: - - `CLAUDE_OPUS_4_6_MODEL` - - `CLAUDE_SONNET_4_6_MODEL` - - `CLAUDE_HAIKU_4_5_MODEL` - -### Phase 2C: Verify call sites are unchanged - -**Files:** `apps/web/src/components/chat/ClaudeTraitsPicker.tsx`, -`apps/web/src/components/chat/CodexTraitsPicker.tsx`, -`apps/web/src/components/chat/composerProviderRegistry.tsx`, -`apps/server/src/provider/Layers/ClaudeAdapter.ts` - -1. All existing `supportsXxx()` call sites continue to work -- the function signatures - are unchanged, only the implementation is swapped to data lookups. - -2. `ClaudeAdapter.ts` calls to `supportsClaudeFastMode()`, - `supportsClaudeThinkingToggle()`, `getReasoningEffortOptions()` still work - identically. - -3. Trait pickers call `supportsClaudeFastMode(model)` etc. -- no change needed. - -4. **Future benefit**: adding a new provider means defining its models with inline - capabilities in `MODEL_OPTIONS_BY_PROVIDER`. No new `supportsXxx` functions needed. - Custom models from app settings get reasonable behavior via - `DEFAULT_CAPABILITIES_BY_PROVIDER[provider]`. - -### Phase 2D: Provider-generic capability functions - -Add provider-generic alternatives that don't hardcode "claude" in the name: - -```ts -export function modelSupportsFastMode(provider: ProviderKind, model: string | null | undefined): boolean { - return getModelCapabilities(provider, model).supportsFastMode; -} -``` - -The Claude-specific wrappers can remain as aliases for backward compatibility, but new -code should prefer the generic versions. - -### Phase 2E: Unify trait picker components - -With data-driven capabilities, the separate `ClaudeTraitsPicker` and `CodexTraitsPicker` -components become unnecessary. They render the same UI primitives in the same structure, -just parameterized differently: - -| Section | Codex | Claude | -|---------|-------|--------| -| Effort/reasoning radio group | Always (label "Reasoning") | If model has effort levels (label "Effort") | -| Thinking toggle | Never | If `supportsThinkingToggle` (Haiku) | -| Fast mode toggle | Always | If `supportsFastMode` (Opus) | -| Trigger label | `"{effort} · Fast"` | `"{effort/thinking} · Fast"` | - -The only truly provider-specific behavior is Claude's **ultrathink prompt injection**: -when effort is "ultrathink", it modifies the prompt text instead of setting an option, -and locks the effort radio group with an explanatory message. - -#### 2E.1: Add trait metadata to capabilities - -**File:** `packages/contracts/src/model.ts` - -Extend `ModelCapabilities` with display and behavioral metadata: - -```ts -type ModelCapabilities = { - readonly reasoningEffortLevels: readonly string[]; - readonly supportsFastMode: boolean; - readonly supportsThinkingToggle: boolean; - readonly effortSectionLabel: string; // "Reasoning" | "Effort" - readonly promptInjectedEffortLevels: readonly string[]; // ["ultrathink"] for Claude, [] for Codex -}; -``` - -Add a provider-level effort label config: - -```ts -export const EFFORT_DISPLAY_LABELS: Record = { - low: "Low", - medium: "Medium", - high: "High", - xhigh: "Extra High", - max: "Max", - ultrathink: "Ultrathink", -}; -``` - -This replaces the separate `CLAUDE_EFFORT_LABELS` and `CODEX_REASONING_LABELS` records -in each picker component. - -#### 2E.2: Create unified `TraitsPicker` component - -**File:** `apps/web/src/components/chat/TraitsPicker.tsx` (new, replaces both pickers) - -The component receives `provider`, `model`, `threadId`, `options`, `prompt`, -`onPromptChange` and renders sections purely from capabilities data: - -```tsx -function TraitsMenuContent({ provider, model, threadId, options, prompt, onPromptChange }) { - const caps = getModelCapabilities(provider, model); - const effortLevels = caps.reasoningEffortLevels; - const promptInjected = caps.promptInjectedEffortLevels; - - return ( - <> - {effortLevels.length > 0 && ( - - )} - {caps.supportsThinkingToggle && ( - - )} - {caps.supportsFastMode && ( - - )} - - ); -} -``` - -Key design points: - -- **Effort section** handles both normal effort (set via options) and prompt-injected - effort (like ultrathink) via the `promptInjectedLevels` parameter. If the current - prompt contains a prompt-injected effort keyword, the radio group is locked with an - explanatory message -- same as current Claude behavior, but driven by data. - -- **Trigger label** is built generically: collect the active effort label (or thinking - state if no effort), append "Fast" if fast mode is on, join with " · ". - -- **`onEffortChange` handler** checks `promptInjectedLevels.includes(nextEffort)`. If - true, it injects into the prompt via `onPromptChange`. Otherwise, it updates options - via `setProviderModelOptions`. This generalizes the current ultrathink-specific logic. - -- **No provider `if` branches** in the component body. All behavior flows from `caps`. - -#### 2E.3: Update the provider registry - -**File:** `apps/web/src/components/chat/composerProviderRegistry.tsx` - -The registry entries for both providers now point to the same `TraitsMenuContent` and -`TraitsPicker` components. The registry still exists (it's a `Record` -so it's exhaustive-safe), but both entries delegate to the unified component: - -```ts -const composerProviderRegistry: Record = { - codex: { - getState: (input) => getProviderStateFromCapabilities(input), - renderTraitsMenuContent: (input) => , - renderTraitsPicker: (input) => , - }, - claudeAgent: { - getState: (input) => getProviderStateFromCapabilities(input), - renderTraitsMenuContent: (input) => , - renderTraitsPicker: (input) => , - }, -}; -``` - -At this point, the registry becomes a thin pass-through. It can optionally be simplified -further (e.g. a single `getProviderRegistryEntry()` that returns the same object for -all providers), but the `Record` shape is still useful as a compile- -time exhaustiveness check when new providers are added. - -`getProviderStateFromCapabilities` replaces the two inline `getState` implementations. -It uses `getModelCapabilities(provider, model)` to determine prompt effort, normalize -options, and compute CSS classes (e.g. ultrathink frame styling is driven by -`caps.promptInjectedEffortLevels` + prompt content, not by `provider === "claudeAgent"`). - -#### 2E.4: Delete old picker components - -**Files to delete:** -- `apps/web/src/components/chat/ClaudeTraitsPicker.tsx` -- `apps/web/src/components/chat/CodexTraitsPicker.tsx` - -**Files to update:** -- `apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx` -- rename to - `TraitsPicker.browser.tsx`, update to test unified component with both providers -- `apps/web/src/components/chat/CodexTraitsPicker.browser.tsx` -- merge test cases into - `TraitsPicker.browser.tsx` -- `apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx` -- import from - unified component -- Any other import sites - -#### 2E.5: Normalize options generically - -**File:** `packages/shared/src/model.ts` - -Replace `normalizeClaudeModelOptions()` and `normalizeCodexModelOptions()` with a single -generic function: - -```ts -export function normalizeModelOptions( - provider: ProviderKind, - model: string | null | undefined, - options: Record | null | undefined, -): Record | undefined { - const caps = getModelCapabilities(provider, model); - const result: Record = {}; - - // Reasoning effort - if (options?.reasoningEffort || options?.effort) { - const effortKey = provider === "codex" ? "reasoningEffort" : "effort"; - const raw = (options?.reasoningEffort ?? options?.effort) as string; - const resolved = caps.reasoningEffortLevels.includes(raw) ? raw : null; - const isPromptInjected = caps.promptInjectedEffortLevels?.includes(resolved ?? ""); - const isDefault = resolved === getDefaultReasoningEffort(provider); - if (resolved && !isPromptInjected && !isDefault) { - result[effortKey] = resolved; - } - } - - // Thinking toggle - if (caps.supportsThinkingToggle && options?.thinking === false) { - result.thinking = false; - } - - // Fast mode - if (caps.supportsFastMode && options?.fastMode === true) { - result.fastMode = true; - } - - return Object.keys(result).length > 0 ? result : undefined; -} -``` - -Note: the effort key name difference (`reasoningEffort` for Codex vs `effort` for -Claude) is an existing schema inconsistency. This can either be normalized in a future -schema migration, or handled via a `effortOptionKey` field on the capabilities. For now -the function handles both. - -The old `normalizeClaudeModelOptions()` and `normalizeCodexModelOptions()` become thin -wrappers that call `normalizeModelOptions()` with the appropriate provider, for backward -compatibility at existing call sites. - ---- - -## Item 3: Exhaustiveness enforcement - -### Problem - -Ternary branches like `provider === "codex" ? ... : ...` silently give the `else` branch -to any new provider added to `ProviderKind`. The `Record` pattern -(used in contracts and the provider registry) is already exhaustive-safe, but ~10 sites -use unsafe ternaries. - -### Target state - -All provider dispatches either use `Record` lookups (already -compile-time safe) or `switch` statements with `assertNever` in the default branch. -A lint marker or convention makes the pattern obvious. - -### Phase 3A: Add an `assertNever` utility - -**File:** `packages/shared/src/assertNever.ts` (new) - -```ts -/** - * Compile-time exhaustiveness check. Use in `default:` branches of switch - * statements over discriminated unions. Produces a type error if any variant - * is unhandled, and throws at runtime if reached. - */ -export function assertNever(value: never, message?: string): never { - throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`); -} -``` - -The codebase already uses the `_exhaustiveCheck: never` pattern in `wsServer.ts`. -This standardizes it as a reusable utility. - -### Phase 3B: Add `PROVIDER_DISPLAY_NAMES` to contracts - -**File:** `packages/contracts/src/model.ts` - -```ts -export const PROVIDER_DISPLAY_NAMES: Record = { - codex: "Codex", - claudeAgent: "Claude", -}; -``` - -This eliminates display-name ternaries across the UI. - -### Phase 3C: Convert unsafe ternaries - -Listed by priority (most likely to cause silent bugs when a new provider is added): - -1. **`apps/web/src/composerDraftStore.ts` line 492** - ```ts - // Before - const options = provider === "codex" ? modelOptions?.codex : modelOptions?.claudeAgent; - // After (eliminated entirely by Item 1 -- modelSelectionByProvider replaces this) - ``` - -2. **`apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx` line 59** - ```ts - // Before: inline ternary selecting which traits component to render - provider === "codex" ? : - - // After: delegate to the existing registry (already Record) - renderProviderTraitsMenuContent({ provider, threadId, model, ... }) - ``` - -3. **`apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx` line 36** - ```ts - // Before - const providerModelOptions = provider === "codex" - ? props.modelOptions.codex : props.modelOptions.claudeAgent; - // After (eliminated by Item 1 -- options arrive pre-extracted) - ``` - -4. **`apps/web/src/composerDraftStore.ts` lines 403, 415, 490** - Legacy migration code. Low priority since it runs once on old data. Convert to - switch + assertNever for safety, or leave with a `// LEGACY` comment if migration - removal is planned. - -5. **`apps/web/src/components/chat/ProviderHealthBanner.tsx` lines 16-20** - ```ts - // Before - const providerLabel = status.provider === "codex" ? "Codex" - : status.provider === "claudeAgent" ? "Claude" : status.provider; - // After - const providerLabel = PROVIDER_DISPLAY_NAMES[status.provider] ?? status.provider; - ``` - -6. **`apps/web/src/store.ts` `toLegacyProvider()`** - ```ts - // Before: coerces untyped string|null to ProviderKind with "codex" default - // After: if providerName comes from a typed source, remove the function. - // If it comes from an untyped runtime source (e.g. session event), keep it - // but add a warning log for unknown values. - ``` - -7. **`packages/shared/src/model.ts` `getReasoningEffortOptions()`** - ```ts - // Before: if (provider === "claudeAgent") { ... } return REASONING_EFFORT_OPTIONS_BY_PROVIDER[provider]; - // After: eliminated by Item 2 (data-driven capability lookup) - ``` - -8. **`apps/web/src/components/ChatView.tsx` line 199** - ```ts - // Before - if (params.provider === "claudeAgent" && params.effort === "ultrathink") { ... } - // After (with Item 2 in place): - const caps = getModelCapabilities(params.provider, params.model); - if (caps.reasoningEffortLevels.includes("ultrathink") && params.effort === "ultrathink") { ... } - ``` - -### Phase 3D: Adapter guards are correct as-is - -The patterns in `ClaudeAdapter.ts` and `CodexAdapter.ts` like -`input.modelSelection?.provider === "claudeAgent"` are **not** provider dispatches. -They are adapter-internal guards: "only process my own provider's data." These are -correct and do not need exhaustiveness -- an adapter should only care about its own -provider. No changes needed. - ---- - -## Execution order - -``` -Item 2 (data-driven capabilities) -- purely additive, no breaking changes - | - v -Item 1 (consolidate dual options) -- biggest refactor, touches draft store heavily - | - v -Item 3 (exhaustiveness enforcement) -- small cleanup pass, most sites already fixed by 1 & 2 -``` - -**Item 2 first**: phases 2A-2D are purely additive -- every `supportsXxx` function -becomes a data lookup with identical signatures. Tests pass with zero call-site changes. -Phase 2E (unified trait picker) builds on the capabilities data to collapse -`ClaudeTraitsPicker` and `CodexTraitsPicker` into a single capabilities-driven component. -Phases 2A-2D and 2E can be separate PRs or one combined PR. - -**Item 1 second**: the largest change, touching the draft store, persistence, and -localStorage migration. Doing Item 2 first means the capability logic is already clean, -reducing cognitive load during this refactor. Warrants its own PR with careful testing -of the v2 -> v3 storage migration. - -**Item 3 last**: after Items 1 and 2, most unsafe ternaries are already eliminated. What -remains is adding `assertNever`, `PROVIDER_DISPLAY_NAMES`, and converting a handful of -surviving dispatch sites. Can be batched with Item 2 into a single PR since both are -small and contained. - ---- - -## Files affected (summary) - -### Item 1 -- `packages/contracts/src/model.ts` -- remove `ProviderModelOptions` -- `apps/web/src/composerDraftStore.ts` -- major refactor -- `apps/web/src/composerDraftStore.test.ts` -- update tests -- `apps/web/src/components/ChatView.tsx` -- plumbing changes -- `apps/web/src/components/chat/composerProviderRegistry.tsx` -- input type change -- `apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx` -- simplified -- `apps/web/src/hooks/useHandleNewThread.ts` -- reads sticky state differently - -### Item 2 -- `packages/contracts/src/model.ts` -- add capabilities to model definitions, add `EFFORT_DISPLAY_LABELS` -- `packages/shared/src/model.ts` -- rewrite ~10 functions to data lookups, add `normalizeModelOptions` -- `packages/shared/src/model.test.ts` -- same tests, same assertions -- `apps/web/src/components/chat/TraitsPicker.tsx` -- new unified component -- `apps/web/src/components/chat/ClaudeTraitsPicker.tsx` -- deleted -- `apps/web/src/components/chat/CodexTraitsPicker.tsx` -- deleted -- `apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx` -- merged into `TraitsPicker.browser.tsx` -- `apps/web/src/components/chat/CodexTraitsPicker.browser.tsx` -- merged into `TraitsPicker.browser.tsx` -- `apps/web/src/components/chat/composerProviderRegistry.tsx` -- both entries use unified component -- `apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx` -- import from unified - -### Item 3 -- `packages/shared/src/assertNever.ts` -- new utility -- `packages/contracts/src/model.ts` -- add `PROVIDER_DISPLAY_NAMES` -- `apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx` -- use registry -- `apps/web/src/components/chat/ProviderHealthBanner.tsx` -- use display names -- `apps/web/src/components/ChatView.tsx` -- use capability check -- `apps/web/src/store.ts` -- tighten `toLegacyProvider` From 42570ee4db8bcf9d5f84739b1530b1d794e22c29 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 20:40:26 -0700 Subject: [PATCH 35/38] whip --- .../persistence/Layers/ProjectionProjects.ts | 26 +------------------ 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index d45b5cf951..7ff19f55ae 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -1,6 +1,6 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Option, Schema, Struct } from "effect"; +import { Effect, Layer, Schema, Struct } from "effect"; import { ModelSelection, ProjectScript } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; @@ -114,35 +114,11 @@ const makeProjectionProjectRepository = Effect.gen(function* () { const getById: ProjectionProjectRepositoryShape["getById"] = (input) => getProjectionProjectRow(input).pipe( Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.getById:query")), - Effect.map( - Option.map((row) => ({ - projectId: row.projectId, - title: row.title, - workspaceRoot: row.workspaceRoot, - defaultModelSelection: row.defaultModelSelection, - scripts: row.scripts, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - })), - ), ); const listAll: ProjectionProjectRepositoryShape["listAll"] = () => listProjectionProjectRows().pipe( Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.listAll:query")), - Effect.map((rows) => - rows.map((row) => ({ - projectId: row.projectId, - title: row.title, - workspaceRoot: row.workspaceRoot, - defaultModelSelection: row.defaultModelSelection, - scripts: row.scripts, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - })), - ), ); const deleteById: ProjectionProjectRepositoryShape["deleteById"] = (input) => From 607a4b82393f5e1a718c70ead3a3694990456731 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 21:15:49 -0700 Subject: [PATCH 36/38] Canonicalize provider-specific model selections - Persist model options as provider-specific JSON - Handle legacy fallback defaults during migration - Update composer and picker tests for split selections --- .../Layers/ProjectionRepositories.test.ts | 2 +- .../016_CanonicalizeModelSelections.test.ts | 65 ++- .../016_CanonicalizeModelSelections.ts | 86 ++-- apps/web/src/components/ChatView.browser.tsx | 69 +-- .../CompactComposerControlsMenu.browser.tsx | 168 ++++---- .../components/chat/TraitsPicker.browser.tsx | 404 +++++++----------- apps/web/src/composerDraftStore.test.ts | 20 + apps/web/src/composerDraftStore.ts | 6 +- 8 files changed, 424 insertions(+), 396 deletions(-) diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index b44846d136..0ca13f2e97 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -67,7 +67,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { }), ); - it.effect("stores SQL NULL for missing thread model options", () => + it.effect("stores JSON for thread model options", () => Effect.gen(function* () { const threads = yield* ProjectionThreadRepository; const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts index 954e4f014c..06134be6db 100644 --- a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts +++ b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts @@ -114,6 +114,20 @@ layer("016_CanonicalizeModelSelections", (it) => { '{"projectId":"project-1","title":"Project","workspaceRoot":"/tmp/project","defaultModel":"claude-opus-4-6","defaultModelOptions":{"codex":{"reasoningEffort":"high"},"claudeAgent":{"effort":"max"}},"scripts":[],"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}', '{}' ), + ( + 'event-project-created-fallback', + 'project', + 'project-2', + 1, + 'project.created', + '2026-01-01T00:00:00.000Z', + 'command-project-created-fallback', + NULL, + 'correlation-project-created-fallback', + 'user', + '{"projectId":"project-2","title":"Fallback Project","workspaceRoot":"/tmp/project-2","defaultModel":"claude-opus-4-6","defaultModelOptions":{"codex":{"reasoningEffort":"low"}},"scripts":[],"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), ( 'event-thread-created', 'thread', @@ -128,6 +142,20 @@ layer("016_CanonicalizeModelSelections", (it) => { '{"threadId":"thread-1","projectId":"project-1","title":"Thread","model":"claude-opus-4-6","modelOptions":{"codex":{"reasoningEffort":"high"},"claudeAgent":{"effort":"max","thinking":false}},"runtimeMode":"full-access","interactionMode":"default","branch":null,"worktreePath":null,"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}', '{}' ), + ( + 'event-thread-created-fallback', + 'thread', + 'thread-2', + 1, + 'thread.created', + '2026-01-01T00:00:00.000Z', + 'command-thread-created-fallback', + NULL, + 'correlation-thread-created-fallback', + 'user', + '{"threadId":"thread-2","projectId":"project-1","title":"Fallback Thread","model":"gpt-5.4","modelOptions":{"claudeAgent":{"effort":"max"}},"runtimeMode":"full-access","interactionMode":"default","branch":null,"worktreePath":null,"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), ( 'event-turn-start-requested', 'thread', @@ -226,6 +254,22 @@ layer("016_CanonicalizeModelSelections", (it) => { }); assert.deepStrictEqual(JSON.parse(eventRows[1]!.payloadJson), { + projectId: "project-2", + title: "Fallback Project", + workspaceRoot: "/tmp/project-2", + defaultModelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { + reasoningEffort: "low", + }, + }, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + + assert.deepStrictEqual(JSON.parse(eventRows[2]!.payloadJson), { threadId: "thread-1", projectId: "project-1", title: "Thread", @@ -245,7 +289,26 @@ layer("016_CanonicalizeModelSelections", (it) => { updatedAt: "2026-01-01T00:00:00.000Z", }); - assert.deepStrictEqual(JSON.parse(eventRows[2]!.payloadJson), { + assert.deepStrictEqual(JSON.parse(eventRows[3]!.payloadJson), { + threadId: "thread-2", + projectId: "project-1", + title: "Fallback Thread", + modelSelection: { + provider: "codex", + model: "gpt-5.4", + options: { + effort: "max", + }, + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + + assert.deepStrictEqual(JSON.parse(eventRows[4]!.payloadJson), { threadId: "thread-1", turnId: "turn-1", input: "hi", diff --git a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.ts b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.ts index 8154bfde9b..46adf2fd8b 100644 --- a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.ts +++ b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.ts @@ -89,29 +89,47 @@ export default Effect.gen(function* () { 'model', json_extract(payload_json, '$.defaultModel') ), - CASE - WHEN json_type(payload_json, '$.defaultModelOptions') IS NULL THEN '{}' - WHEN json_type(payload_json, '$.defaultModelOptions.codex') IS NOT NULL - OR json_type(payload_json, '$.defaultModelOptions.claudeAgent') IS NOT NULL - THEN CASE - WHEN ( + CASE + WHEN json_type(payload_json, '$.defaultModelOptions') IS NULL THEN '{}' + WHEN json_type(payload_json, '$.defaultModelOptions.codex') IS NOT NULL + OR json_type(payload_json, '$.defaultModelOptions.claudeAgent') IS NOT NULL + THEN CASE + WHEN ( CASE WHEN json_extract(payload_json, '$.defaultProvider') IS NOT NULL THEN json_extract(payload_json, '$.defaultProvider') WHEN lower(json_extract(payload_json, '$.defaultModel')) LIKE '%claude%' THEN 'claudeAgent' ELSE 'codex' + END + ) = 'claudeAgent' + THEN CASE + WHEN json_type(payload_json, '$.defaultModelOptions.claudeAgent') IS NOT NULL + THEN json_object( + 'options', + json(json_extract(payload_json, '$.defaultModelOptions.claudeAgent')) + ) + WHEN json_type(payload_json, '$.defaultModelOptions.codex') IS NOT NULL + THEN json_object( + 'options', + json(json_extract(payload_json, '$.defaultModelOptions.codex')) + ) + ELSE '{}' END - ) = 'claudeAgent' - THEN json_object( - 'options', - json(json_extract(payload_json, '$.defaultModelOptions.claudeAgent')) - ) - ELSE json_object( - 'options', - json(json_extract(payload_json, '$.defaultModelOptions.codex')) - ) - END + ELSE CASE + WHEN json_type(payload_json, '$.defaultModelOptions.codex') IS NOT NULL + THEN json_object( + 'options', + json(json_extract(payload_json, '$.defaultModelOptions.codex')) + ) + WHEN json_type(payload_json, '$.defaultModelOptions.claudeAgent') IS NOT NULL + THEN json_object( + 'options', + json(json_extract(payload_json, '$.defaultModelOptions.claudeAgent')) + ) + ELSE '{}' + END + END ELSE json_object( 'options', json(json_extract(payload_json, '$.defaultModelOptions')) @@ -160,16 +178,34 @@ export default Effect.gen(function* () { WHEN lower(json_extract(payload_json, '$.model')) LIKE '%claude%' THEN 'claudeAgent' ELSE 'codex' - END + END ) = 'claudeAgent' - THEN json_object( - 'options', - json(json_extract(payload_json, '$.modelOptions.claudeAgent')) - ) - ELSE json_object( - 'options', - json(json_extract(payload_json, '$.modelOptions.codex')) - ) + THEN CASE + WHEN json_type(payload_json, '$.modelOptions.claudeAgent') IS NOT NULL + THEN json_object( + 'options', + json(json_extract(payload_json, '$.modelOptions.claudeAgent')) + ) + WHEN json_type(payload_json, '$.modelOptions.codex') IS NOT NULL + THEN json_object( + 'options', + json(json_extract(payload_json, '$.modelOptions.codex')) + ) + ELSE '{}' + END + ELSE CASE + WHEN json_type(payload_json, '$.modelOptions.codex') IS NOT NULL + THEN json_object( + 'options', + json(json_extract(payload_json, '$.modelOptions.codex')) + ) + WHEN json_type(payload_json, '$.modelOptions.claudeAgent') IS NOT NULL + THEN json_object( + 'options', + json(json_extract(payload_json, '$.modelOptions.claudeAgent')) + ) + ELSE '{}' + END END ELSE json_object('options', json(json_extract(payload_json, '$.modelOptions'))) END diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index cec0ebc278..c2671b074f 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -90,6 +90,7 @@ interface UserRowMeasurement { } interface MountedChatView { + [Symbol.asyncDispose]: () => Promise; cleanup: () => Promise; measureUserRow: (targetMessageId: MessageId) => Promise; setViewport: (viewport: ViewportSpec) => Promise; @@ -765,11 +766,14 @@ async function mountChatView(options: { await waitForLayout(); + const cleanup = async () => { + await screen.unmount(); + host.remove(); + }; + return { - cleanup: async () => { - await screen.unmount(); - host.remove(); - }, + [Symbol.asyncDispose]: cleanup, + cleanup, measureUserRow: async (targetMessageId: MessageId) => measureUserRow({ host, targetMessageId }), setViewport: async (viewport: ViewportSpec) => { await setViewport(viewport); @@ -1525,13 +1529,16 @@ describe("ChatView timeline estimator parity (full app)", () => { const newThreadId = newThreadPath.slice(1) as ThreadId; expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ - modelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - fastMode: true, + modelSelectionByProvider: { + codex: { + provider: "codex", + model: "gpt-5.3-codex", + options: { + fastMode: true, + }, }, }, + activeProvider: null, }); } finally { await mounted.cleanup(); @@ -1574,16 +1581,18 @@ describe("ChatView timeline estimator parity (full app)", () => { const newThreadId = newThreadPath.slice(1) as ThreadId; expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - fastMode: true, + modelSelectionByProvider: { + claudeAgent: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { + effort: "max", + fastMode: true, + }, }, }, + activeProvider: null, }); - await expect.element(page.getByText("Claude Opus 4.6")).toBeInTheDocument(); } finally { await mounted.cleanup(); } @@ -1653,13 +1662,16 @@ describe("ChatView timeline estimator parity (full app)", () => { const threadId = threadPath.slice(1) as ThreadId; expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ - modelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - fastMode: true, + modelSelectionByProvider: { + codex: { + provider: "codex", + model: "gpt-5.3-codex", + options: { + fastMode: true, + }, }, }, + activeProvider: null, }); useComposerDraftStore.getState().setModelSelection(threadId, { @@ -1679,14 +1691,17 @@ describe("ChatView timeline estimator parity (full app)", () => { "New-thread should reuse the existing project draft thread.", ); expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ - modelSelection: { - provider: "codex", - model: "gpt-5.4", - options: { - reasoningEffort: "low", - fastMode: true, + modelSelectionByProvider: { + codex: { + provider: "codex", + model: "gpt-5.4", + options: { + reasoningEffort: "low", + fastMode: true, + }, }, }, + activeProvider: "codex", }); } finally { await mounted.cleanup(); diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 815ef3a8c4..01a5d32d64 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -1,4 +1,4 @@ -import { DEFAULT_MODEL_BY_PROVIDER, type ProviderModelOptions, ThreadId } from "@t3tools/contracts"; +import { DEFAULT_MODEL_BY_PROVIDER, ModelSelection, ThreadId } from "@t3tools/contracts"; import "../../index.css"; import { page } from "vitest/browser"; @@ -9,20 +9,14 @@ import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; import { TraitsMenuContent } from "./TraitsPicker"; import { useComposerDraftStore } from "../../composerDraftStore"; -async function mountMenu(props?: { - model?: string; - prompt?: string; - provider?: "codex" | "claudeAgent"; - modelOptions?: ProviderModelOptions | null; -}) { +async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: string }) { const threadId = ThreadId.makeUnsafe("thread-compact-menu"); - const provider = props?.provider ?? "claudeAgent"; + const provider = props?.modelSelection?.provider ?? "claudeAgent"; const draftsByThreadId = {} as ReturnType< typeof useComposerDraftStore.getState >["draftsByThreadId"]; - const model = DEFAULT_MODEL_BY_PROVIDER["claudeAgent"]; - const providerOpts = - provider === "codex" ? props?.modelOptions?.codex : props?.modelOptions?.claudeAgent; + const model = props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider]; + draftsByThreadId[threadId] = { prompt: props?.prompt ?? "", images: [], @@ -33,7 +27,7 @@ async function mountMenu(props?: { [provider]: { provider, model, - ...(providerOpts ? { options: providerOpts } : {}), + ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}), }, }, activeProvider: provider, @@ -48,8 +42,7 @@ async function mountMenu(props?: { const host = document.createElement("div"); document.body.append(host); const onPromptChange = vi.fn(); - const providerOptions = - provider === "codex" ? props?.modelOptions?.codex : props?.modelOptions?.claudeAgent; + const providerOptions = props?.modelSelection?.options; const screen = await render( { + await screen.unmount(); + host.remove(); + }; + return { - cleanup: async () => { - await screen.unmount(); - host.remove(); - }, + [Symbol.asyncDispose]: cleanup, + cleanup, }; } @@ -93,103 +89,85 @@ describe("CompactComposerControlsMenu", () => { }); it("shows fast mode controls for Opus", async () => { - const mounted = await mountMenu(); - - try { - await page.getByLabelText("More composer controls").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Fast Mode"); - expect(text).toContain("off"); - expect(text).toContain("on"); - }); - } finally { - await mounted.cleanup(); - } + await using _ = await mountMenu({ + modelSelection: { provider: "claudeAgent", model: "claude-opus-4-6" }, + }); + + await page.getByLabelText("More composer controls").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Fast Mode"); + expect(text).toContain("off"); + expect(text).toContain("on"); + }); }); it("hides fast mode controls for non-Opus Claude models", async () => { - const mounted = await mountMenu({ model: "claude-sonnet-4-6" }); + await using _ = await mountMenu({ + modelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" }, + }); - try { - await page.getByLabelText("More composer controls").click(); + await page.getByLabelText("More composer controls").click(); - await vi.waitFor(() => { - expect(document.body.textContent ?? "").not.toContain("Fast Mode"); - }); - } finally { - await mounted.cleanup(); - } + await vi.waitFor(() => { + expect(document.body.textContent ?? "").not.toContain("Fast Mode"); + }); }); it("shows only the provided effort options", async () => { - const mounted = await mountMenu({ - model: "claude-sonnet-4-6", + await using _ = await mountMenu({ + modelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" }, }); - try { - await page.getByLabelText("More composer controls").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Low"); - expect(text).toContain("Medium"); - expect(text).toContain("High"); - expect(text).not.toContain("Max"); - expect(text).toContain("Ultrathink"); - }); - } finally { - await mounted.cleanup(); - } + await page.getByLabelText("More composer controls").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Low"); + expect(text).toContain("Medium"); + expect(text).toContain("High"); + expect(text).not.toContain("Max"); + expect(text).toContain("Ultrathink"); + }); }); it("shows a Claude thinking on/off section for Haiku", async () => { - const mounted = await mountMenu({ - model: "claude-haiku-4-5", - modelOptions: { - claudeAgent: { - thinking: true, - }, + await using _ = await mountMenu({ + modelSelection: { + provider: "claudeAgent", + model: "claude-haiku-4-5", + options: { thinking: true }, }, }); - try { - await page.getByLabelText("More composer controls").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Thinking"); - expect(text).toContain("On (default)"); - expect(text).toContain("Off"); - }); - } finally { - await mounted.cleanup(); - } + await page.getByLabelText("More composer controls").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Thinking"); + expect(text).toContain("On (default)"); + expect(text).toContain("Off"); + }); }); it("shows prompt-controlled Ultrathink messaging with disabled effort controls", async () => { - const mounted = await mountMenu({ - model: "claude-opus-4-6", - prompt: "Ultrathink:\nInvestigate this", - modelOptions: { - claudeAgent: { - effort: "high", - }, + await using _ = await mountMenu({ + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { effort: "high" }, }, + prompt: "Ultrathink:\nInvestigate this", }); - try { - await page.getByLabelText("More composer controls").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Effort"); - expect(text).toContain("Remove Ultrathink from the prompt to change effort."); - expect(text).not.toContain("Fallback Effort"); - }); - } finally { - await mounted.cleanup(); - } + await page.getByLabelText("More composer controls").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Effort"); + expect(text).toContain("Remove Ultrathink from the prompt to change effort."); + expect(text).not.toContain("Fallback Effort"); + }); }); }); diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index f3e92752a9..811ad5bb35 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -2,6 +2,8 @@ import "../../index.css"; import { type ModelSelection, + ClaudeModelOptions, + CodexModelOptions, DEFAULT_MODEL_BY_PROVIDER, ProjectId, ThreadId, @@ -14,6 +16,7 @@ import { render } from "vitest-browser-react"; import { TraitsPicker } from "./TraitsPicker"; import { COMPOSER_DRAFT_STORAGE_KEY, + ComposerThreadDraftState, useComposerDraftStore, useComposerThreadDraft, useEffectiveComposerModelState, @@ -58,9 +61,7 @@ function ClaudeTraitsPickerHarness(props: { async function mountClaudePicker(props?: { model?: string; prompt?: string; - effort?: "low" | "medium" | "high" | "max" | "ultrathink" | null; - thinkingEnabled?: boolean | null; - fastModeEnabled?: boolean; + options?: ClaudeModelOptions; fallbackModelOptions?: { effort?: "low" | "medium" | "high" | "max" | "ultrathink"; thinking?: boolean; @@ -68,37 +69,30 @@ async function mountClaudePicker(props?: { } | null; skipDraftModelOptions?: boolean; }) { - const draftsByThreadId = {} as ReturnType< - typeof useComposerDraftStore.getState - >["draftsByThreadId"]; const model = props?.model ?? "claude-opus-4-6"; - const claudeOptions = !props?.skipDraftModelOptions - ? { - ...(props?.effort ? { effort: props.effort } : {}), - ...(props?.thinkingEnabled === false ? { thinking: false } : {}), - ...(props?.fastModeEnabled ? { fastMode: true } : {}), - } - : undefined; - draftsByThreadId[CLAUDE_THREAD_ID] = { - prompt: props?.prompt ?? "", - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - terminalContexts: [], - modelSelectionByProvider: props?.skipDraftModelOptions - ? {} - : { - claudeAgent: { - provider: "claudeAgent", - model, - ...(claudeOptions && Object.keys(claudeOptions).length > 0 - ? { options: claudeOptions } - : {}), + const claudeOptions = !props?.skipDraftModelOptions ? props?.options : undefined; + const draftsByThreadId: Record = { + [CLAUDE_THREAD_ID]: { + prompt: props?.prompt ?? "", + images: [], + nonPersistedImageIds: [], + persistedAttachments: [], + terminalContexts: [], + modelSelectionByProvider: props?.skipDraftModelOptions + ? {} + : { + claudeAgent: { + provider: "claudeAgent", + model, + ...(claudeOptions && Object.keys(claudeOptions).length > 0 + ? { options: claudeOptions } + : {}), + }, }, - }, - activeProvider: "claudeAgent", - runtimeMode: null, - interactionMode: null, + activeProvider: "claudeAgent", + runtimeMode: null, + interactionMode: null, + }, }; useComposerDraftStore.setState({ draftsByThreadId, @@ -120,11 +114,14 @@ async function mountClaudePicker(props?: { { container: host }, ); + const cleanup = async () => { + await screen.unmount(); + host.remove(); + }; + return { - cleanup: async () => { - await screen.unmount(); - host.remove(); - }, + [Symbol.asyncDispose]: cleanup, + cleanup, }; } @@ -140,195 +137,130 @@ describe("TraitsPicker (Claude)", () => { }); it("shows fast mode controls for Opus", async () => { - const mounted = await mountClaudePicker(); - - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Fast Mode"); - expect(text).toContain("off"); - expect(text).toContain("on"); - }); - } finally { - await mounted.cleanup(); - } + await using _ = await mountClaudePicker(); + + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Fast Mode"); + expect(text).toContain("off"); + expect(text).toContain("on"); + }); }); it("hides fast mode controls for non-Opus models", async () => { - const mounted = await mountClaudePicker({ model: "claude-sonnet-4-6" }); + await using _ = await mountClaudePicker({ model: "claude-sonnet-4-6" }); - try { - await page.getByRole("button").click(); + await page.getByRole("button").click(); - await vi.waitFor(() => { - expect(document.body.textContent ?? "").not.toContain("Fast Mode"); - }); - } finally { - await mounted.cleanup(); - } + await vi.waitFor(() => { + expect(document.body.textContent ?? "").not.toContain("Fast Mode"); + }); }); it("shows only the provided effort options", async () => { - const mounted = await mountClaudePicker({ + await using _ = await mountClaudePicker({ model: "claude-sonnet-4-6", }); - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Low"); - expect(text).toContain("Medium"); - expect(text).toContain("High"); - expect(text).not.toContain("Max"); - expect(text).toContain("Ultrathink"); - }); - } finally { - await mounted.cleanup(); - } + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Low"); + expect(text).toContain("Medium"); + expect(text).toContain("High"); + expect(text).not.toContain("Max"); + expect(text).toContain("Ultrathink"); + }); }); - it("shows a thinking on/off dropdown for Haiku", async () => { - const mounted = await mountClaudePicker({ + it("shows a th inking on/off dropdown for Haiku", async () => { + await using _ = await mountClaudePicker({ model: "claude-haiku-4-5", - thinkingEnabled: true, + options: { thinking: true }, + }); + + await vi.waitFor(() => { + expect(document.body.textContent ?? "").toContain("Thinking On"); }); + await page.getByRole("button").click(); - try { - await vi.waitFor(() => { - expect(document.body.textContent ?? "").toContain("Thinking On"); - }); - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Thinking"); - expect(text).toContain("On (default)"); - expect(text).toContain("Off"); - }); - } finally { - await mounted.cleanup(); - } + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Thinking"); + expect(text).toContain("On (default)"); + expect(text).toContain("Off"); + }); }); it("shows prompt-controlled Ultrathink state with disabled effort controls", async () => { - const mounted = await mountClaudePicker({ - effort: "high", + await using _ = await mountClaudePicker({ model: "claude-opus-4-6", + options: { effort: "high" }, prompt: "Ultrathink:\nInvestigate this", - fastModeEnabled: false, }); - try { - await vi.waitFor(() => { - expect(document.body.textContent ?? "").toContain("Ultrathink"); - expect(document.body.textContent ?? "").not.toContain("Ultrathink · Prompt"); - }); - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Effort"); - expect(text).toContain("Remove Ultrathink from the prompt to change effort."); - expect(text).not.toContain("Fallback Effort"); - }); - } finally { - await mounted.cleanup(); - } + await vi.waitFor(() => { + expect(document.body.textContent ?? "").toContain("Ultrathink"); + expect(document.body.textContent ?? "").not.toContain("Ultrathink · Prompt"); + }); + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Effort"); + expect(text).toContain("Remove Ultrathink from the prompt to change effort."); + expect(text).not.toContain("Fallback Effort"); + }); }); it("persists sticky claude model options when traits change", async () => { - const mounted = await mountClaudePicker({ + await using _ = await mountClaudePicker({ model: "claude-opus-4-6", - effort: "medium", - fastModeEnabled: false, + options: { effort: "medium", fastMode: false }, }); - try { - await page.getByRole("button").click(); - await page.getByRole("menuitemradio", { name: "Max" }).click(); + await page.getByRole("button").click(); + await page.getByRole("menuitemradio", { name: "Max" }).click(); - expect( - useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent, - ).toMatchObject({ - provider: "claudeAgent", - options: { - effort: "max", - }, - }); - } finally { - await mounted.cleanup(); - } - }); - - it("can turn inherited fast mode off without snapping back", async () => { - const mounted = await mountClaudePicker({ - model: "claude-opus-4-6", - skipDraftModelOptions: true, - fallbackModelOptions: { - effort: "high", - fastMode: true, + expect( + useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent, + ).toMatchObject({ + provider: "claudeAgent", + options: { + effort: "max", }, }); - - try { - const trigger = page.getByRole("button"); - - await expect.element(trigger).toHaveTextContent("High · Fast"); - await trigger.click(); - await page.getByRole("menuitemradio", { name: "off" }).click(); - - await vi.waitFor(() => { - expect( - useComposerDraftStore.getState().draftsByThreadId[CLAUDE_THREAD_ID] - ?.modelSelectionByProvider.claudeAgent?.options, - ).toEqual({ - effort: "high", - fastMode: false, - }); - }); - await expect.element(trigger).toHaveTextContent("High"); - await expect.element(trigger).not.toHaveTextContent("High · Fast"); - } finally { - await mounted.cleanup(); - } }); }); // ── Codex TraitsPicker tests ────────────────────────────────────────── -async function mountCodexPicker(props: { - reasoningEffort?: "low" | "medium" | "high" | "xhigh"; - fastModeEnabled: boolean; -}) { +async function mountCodexPicker(props: { model?: string; options?: CodexModelOptions }) { const threadId = ThreadId.makeUnsafe("thread-codex-traits"); - const draftsByThreadId = {} as ReturnType< - typeof useComposerDraftStore.getState - >["draftsByThreadId"]; - const codexOptions = { - ...(props.reasoningEffort ? { reasoningEffort: props.reasoningEffort } : {}), - ...(props.fastModeEnabled ? { fastMode: true } : {}), - }; - draftsByThreadId[threadId] = { - prompt: "", - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - terminalContexts: [], - modelSelectionByProvider: { - codex: { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER["codex"], - ...(Object.keys(codexOptions).length > 0 ? { options: codexOptions } : {}), + const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex; + const draftsByThreadId: Record = { + [threadId]: { + prompt: "", + images: [], + nonPersistedImageIds: [], + persistedAttachments: [], + terminalContexts: [], + modelSelectionByProvider: { + codex: { + provider: "codex", + model, + ...(props.options ? { options: props.options } : {}), + }, }, + activeProvider: "codex", + runtimeMode: null, + interactionMode: null, }, - activeProvider: "codex", - runtimeMode: null, - interactionMode: null, }; + useComposerDraftStore.setState({ draftsByThreadId, draftThreadsByThreadId: {}, @@ -342,22 +274,22 @@ async function mountCodexPicker(props: { {}} />, { container: host }, ); + const cleanup = async () => { + await screen.unmount(); + host.remove(); + }; + return { - cleanup: async () => { - await screen.unmount(); - host.remove(); - }, + [Symbol.asyncDispose]: cleanup, + cleanup, }; } @@ -374,75 +306,57 @@ describe("TraitsPicker (Codex)", () => { }); it("shows fast mode controls", async () => { - const mounted = await mountCodexPicker({ - fastModeEnabled: false, + await using _ = await mountCodexPicker({ + options: { fastMode: false }, }); - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Fast Mode"); - expect(text).toContain("off"); - expect(text).toContain("on"); - }); - } finally { - await mounted.cleanup(); - } + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Fast Mode"); + expect(text).toContain("off"); + expect(text).toContain("on"); + }); }); it("shows Fast in the trigger label when fast mode is active", async () => { - const mounted = await mountCodexPicker({ - fastModeEnabled: true, + await using _ = await mountCodexPicker({ + options: { fastMode: true }, }); - try { - await vi.waitFor(() => { - expect(document.body.textContent ?? "").toContain("High · Fast"); - }); - } finally { - await mounted.cleanup(); - } + await vi.waitFor(() => { + expect(document.body.textContent ?? "").toContain("High · Fast"); + }); }); it("shows only the provided effort options", async () => { - const mounted = await mountCodexPicker({ - fastModeEnabled: false, + await using _ = await mountCodexPicker({ + options: { fastMode: false }, }); - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Low"); - expect(text).toContain("Medium"); - expect(text).toContain("High"); - expect(text).toContain("Extra High"); - }); - } finally { - await mounted.cleanup(); - } + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Low"); + expect(text).toContain("Medium"); + expect(text).toContain("High"); + expect(text).toContain("Extra High"); + }); }); it("persists sticky codex model options when traits change", async () => { - const mounted = await mountCodexPicker({ - fastModeEnabled: false, + await using _ = await mountCodexPicker({ + options: { fastMode: false }, }); - try { - await page.getByRole("button").click(); - await page.getByRole("menuitemradio", { name: "on" }).click(); + await page.getByRole("button").click(); + await page.getByRole("menuitemradio", { name: "on" }).click(); - expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toMatchObject({ - provider: "codex", - options: { - fastMode: true, - }, - }); - } finally { - await mounted.cleanup(); - } + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toMatchObject({ + provider: "codex", + options: { fastMode: true }, + }); }); }); diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 5fa858029d..efc731c6c7 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -767,6 +767,26 @@ describe("composerDraftStore modelSelection", () => { ); }); + it("does not clear other provider options when setting options for a single provider", () => { + const store = useComposerDraftStore.getState(); + + // Set options for both providers + store.setModelOptions( + threadId, + providerModelOptions({ + codex: { fastMode: true }, + claudeAgent: { effort: "max" }, + }), + ); + + // Now set options for only codex — claudeAgent should be untouched + store.setModelOptions(threadId, providerModelOptions({ codex: { reasoningEffort: "xhigh" } })); + + const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ reasoningEffort: "xhigh" }); + expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" }); + }); + it("preserves other provider options when switching the active model selection", () => { const store = useComposerDraftStore.getState(); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 5ce1ff86ed..f9e5fb31db 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -155,7 +155,7 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({ state: PersistedComposerDraftStoreState, }); -interface ComposerThreadDraftState { +export interface ComposerThreadDraftState { prompt: string; images: ComposerImageAttachment[]; nonPersistedImageIds: string[]; @@ -1648,7 +1648,9 @@ export const useComposerDraftStore = create()( const base = existing ?? createEmptyThreadDraft(); const nextMap = { ...base.modelSelectionByProvider }; for (const provider of ["codex", "claudeAgent"] as const) { - const opts = normalizedOpts?.[provider]; + // Only touch providers explicitly present in the input + if (!normalizedOpts || !(provider in normalizedOpts)) continue; + const opts = normalizedOpts[provider]; const current = nextMap[provider]; if (opts) { nextMap[provider] = { From 1f19213bb283e97a6917df88e283822b5b63ae6b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 21:35:40 -0700 Subject: [PATCH 37/38] Handle full migration runs and project defaults in tests - Log when all migrations run - Preserve project default model selection in sidebar test helper --- apps/server/src/persistence/Migrations.ts | 6 +++++- apps/web/src/components/Sidebar.logic.test.ts | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 43c4e49f6f..7fd2690462 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -90,7 +90,11 @@ export interface RunMigrationsOptions { */ export const runMigrations = ({ toMigrationInclusive }: RunMigrationsOptions = {}) => Effect.gen(function* () { - yield* Effect.log(`Running migrations 1 through ${toMigrationInclusive}...`); + yield* Effect.log( + toMigrationInclusive === undefined + ? "Running all migrations..." + : `Running migrations 1 through ${toMigrationInclusive}...`, + ); const executedMigrations = yield* run({ loader: makeMigrationLoader(toMigrationInclusive) }); yield* Effect.log("Migrations ran successfully"); return executedMigrations; diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 61c894deba..9eebee3666 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -343,6 +343,7 @@ describe("getVisibleThreadsForProject", () => { }); function makeProject(overrides: Partial = {}): Project { + const { defaultModelSelection, ...rest } = overrides; return { id: ProjectId.makeUnsafe("project-1"), name: "Project", @@ -350,13 +351,13 @@ function makeProject(overrides: Partial = {}): Project { defaultModelSelection: { provider: "codex", model: "gpt-5.4", - ...overrides?.defaultModelSelection, + ...defaultModelSelection, }, expanded: true, createdAt: "2026-03-09T10:00:00.000Z", updatedAt: "2026-03-09T10:00:00.000Z", scripts: [], - ...overrides, + ...rest, }; } From 3701c8beff2d99f1ce27ca193d56436518c0a1c7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 22:22:39 -0700 Subject: [PATCH 38/38] fix migration --- apps/server/src/persistence/Migrations.ts | 4 +- .../016_CanonicalizeModelSelections.test.ts | 30 ++++++++++++++ .../016_CanonicalizeModelSelections.ts | 13 ++++++ apps/web/src/components/ChatView.browser.tsx | 10 +++-- apps/web/src/composerDraftStore.test.ts | 20 ++++++++++ apps/web/src/composerDraftStore.ts | 40 ++++++++++++++++--- 6 files changed, 107 insertions(+), 10 deletions(-) diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 7fd2690462..7ee45514e5 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -96,7 +96,9 @@ export const runMigrations = ({ toMigrationInclusive }: RunMigrationsOptions = { : `Running migrations 1 through ${toMigrationInclusive}...`, ); const executedMigrations = yield* run({ loader: makeMigrationLoader(toMigrationInclusive) }); - yield* Effect.log("Migrations ran successfully"); + yield* Effect.log("Migrations ran successfully").pipe( + Effect.annotateLogs({ migrations: executedMigrations.map(([id, name]) => `${id}_${name}`) }), + ); return executedMigrations; }); diff --git a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts index 06134be6db..039a63d60b 100644 --- a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts +++ b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts @@ -169,6 +169,20 @@ layer("016_CanonicalizeModelSelections", (it) => { 'user', '{"threadId":"thread-1","turnId":"turn-1","input":"hi","model":"gpt-5.4","modelOptions":{"codex":{"fastMode":true},"claudeAgent":{"effort":"max"}},"deliveryMode":"buffered"}', '{}' + ), + ( + 'event-thread-created-no-model', + 'thread', + 'thread-3', + 1, + 'thread.created', + '2026-01-01T00:00:00.000Z', + 'command-thread-created-no-model', + NULL, + 'correlation-thread-created-no-model', + 'user', + '{"threadId":"thread-3","projectId":"project-1","title":"Ancient Thread","runtimeMode":"full-access","interactionMode":"default","branch":null,"worktreePath":null,"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' ) `; } @@ -321,6 +335,22 @@ layer("016_CanonicalizeModelSelections", (it) => { }, deliveryMode: "buffered", }); + + assert.deepStrictEqual(JSON.parse(eventRows[5]!.payloadJson), { + threadId: "thread-3", + projectId: "project-1", + title: "Ancient Thread", + modelSelection: { + provider: "codex", + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); } }), ); diff --git a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.ts b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.ts index 46adf2fd8b..68d7baf83f 100644 --- a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.ts +++ b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.ts @@ -219,4 +219,17 @@ export default Effect.gen(function* () { AND json_type(payload_json, '$.modelSelection') IS NULL AND json_type(payload_json, '$.model') IS NOT NULL `; + + // Backfill thread.created events that predate the model field entirely + yield* sql` + UPDATE orchestration_events + SET payload_json = json_set( + payload_json, + '$.modelSelection', + json(json_object('provider', 'codex', 'model', 'gpt-5.4')) + ) + WHERE event_type = 'thread.created' + AND json_type(payload_json, '$.modelSelection') IS NULL + AND json_type(payload_json, '$.model') IS NULL + `; }); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index c2671b074f..4e18092463 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -831,6 +831,7 @@ describe("ChatView timeline estimator parity (full app)", () => { draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, stickyModelSelectionByProvider: {}, + stickyActiveProvider: null, }); useStore.setState({ projects: [], @@ -1505,6 +1506,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }, }, }, + stickyActiveProvider: "codex", }); const mounted = await mountChatView({ @@ -1538,7 +1540,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }, }, }, - activeProvider: null, + activeProvider: "codex", }); } finally { await mounted.cleanup(); @@ -1557,6 +1559,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }, }, }, + stickyActiveProvider: "claudeAgent", }); const mounted = await mountChatView({ @@ -1591,7 +1594,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }, }, }, - activeProvider: null, + activeProvider: "claudeAgent", }); } finally { await mounted.cleanup(); @@ -1638,6 +1641,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }, }, }, + stickyActiveProvider: "codex", }); const mounted = await mountChatView({ @@ -1671,7 +1675,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }, }, }, - activeProvider: null, + activeProvider: "codex", }); useComposerDraftStore.getState().setModelSelection(threadId, { diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index efc731c6c7..b68663a890 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -73,6 +73,7 @@ function resetComposerDraftStore() { draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, stickyModelSelectionByProvider: {}, + stickyActiveProvider: null, }); } @@ -218,6 +219,7 @@ describe("composerDraftStore syncPersistedAttachments", () => { draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, stickyModelSelectionByProvider: {}, + stickyActiveProvider: null, }); }); @@ -275,6 +277,7 @@ describe("composerDraftStore terminal contexts", () => { draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, stickyModelSelectionByProvider: {}, + stickyActiveProvider: null, }); }); @@ -902,6 +905,7 @@ describe("composerDraftStore sticky composer settings", () => { fastMode: true, }), ); + expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("codex"); }); it("normalizes empty sticky model options by dropping selection options", () => { @@ -912,6 +916,22 @@ describe("composerDraftStore sticky composer settings", () => { expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toEqual( modelSelection("codex", "gpt-5.4"), ); + expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("codex"); + }); + + it("applies sticky activeProvider to new drafts", () => { + const store = useComposerDraftStore.getState(); + const threadId = ThreadId.makeUnsafe("thread-sticky-active-provider"); + + store.setStickyModelSelection(modelSelection("claudeAgent", "claude-opus-4-6")); + store.applyStickyState(threadId); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + modelSelectionByProvider: { + claudeAgent: modelSelection("claudeAgent", "claude-opus-4-6"), + }, + activeProvider: "claudeAgent", + }); }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index f9e5fb31db..fb9c0d5150 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -147,6 +147,7 @@ const PersistedComposerDraftStoreState = Schema.Struct({ stickyModelSelectionByProvider: Schema.optionalKey( Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)), ), + stickyActiveProvider: Schema.optionalKey(Schema.NullOr(ProviderKind)), }); type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type; @@ -186,6 +187,7 @@ interface ComposerDraftStoreState { draftThreadsByThreadId: Record; projectDraftThreadIdByProjectId: Record; stickyModelSelectionByProvider: Partial>; + stickyActiveProvider: ProviderKind | null; getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null; getDraftThread: (threadId: ThreadId) => DraftThreadState | null; setProjectDraftThreadId: ( @@ -296,6 +298,7 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze> = {}; + let stickyActiveProvider: ProviderKind | null = null; if ( normalizedPersistedState.stickyModelSelectionByProvider && typeof normalizedPersistedState.stickyModelSelectionByProvider === "object" @@ -1061,6 +1068,7 @@ function normalizeCurrentPersistedComposerDraftStoreState( normalizedPersistedState.stickyModelSelectionByProvider as Partial< Record >; + stickyActiveProvider = normalizeProviderKind(normalizedPersistedState.stickyActiveProvider); } else { // Legacy migration path const stickyModelOptions = @@ -1085,6 +1093,7 @@ function normalizeCurrentPersistedComposerDraftStoreState( stickyModelSelection, nextStickyModelOptions, ); + stickyActiveProvider = normalizeProviderKind(normalizedPersistedState.stickyProvider); } return { @@ -1092,6 +1101,7 @@ function normalizeCurrentPersistedComposerDraftStoreState( draftThreadsByThreadId, projectDraftThreadIdByProjectId, stickyModelSelectionByProvider, + stickyActiveProvider, }; } @@ -1247,6 +1257,7 @@ export const useComposerDraftStore = create()( draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, stickyModelSelectionByProvider: {}, + stickyActiveProvider: null, getDraftThreadByProjectId: (projectId) => { if (projectId.length === 0) { return null; @@ -1503,9 +1514,14 @@ export const useComposerDraftStore = create()( [normalized.provider]: normalized, }; if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) { - return state; + return state.stickyActiveProvider === normalized.provider + ? state + : { stickyActiveProvider: normalized.provider }; } - return { stickyModelSelectionByProvider: nextMap }; + return { + stickyModelSelectionByProvider: nextMap, + stickyActiveProvider: normalized.provider, + }; }); }, applyStickyState: (threadId) => { @@ -1514,7 +1530,8 @@ export const useComposerDraftStore = create()( } set((state) => { const stickyMap = state.stickyModelSelectionByProvider; - if (Object.keys(stickyMap).length === 0) { + const stickyActiveProvider = state.stickyActiveProvider; + if (Object.keys(stickyMap).length === 0 && stickyActiveProvider === null) { return state; } const existing = state.draftsByThreadId[threadId]; @@ -1529,12 +1546,16 @@ export const useComposerDraftStore = create()( }; } } - if (Equal.equals(base.modelSelectionByProvider, nextMap)) { + if ( + Equal.equals(base.modelSelectionByProvider, nextMap) && + base.activeProvider === stickyActiveProvider + ) { return state; } const nextDraft: ComposerThreadDraftState = { ...base, modelSelectionByProvider: nextMap, + activeProvider: stickyActiveProvider, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; if (shouldRemoveDraft(nextDraft)) { @@ -1715,6 +1736,7 @@ export const useComposerDraftStore = create()( // Handle sticky persistence let nextStickyMap = state.stickyModelSelectionByProvider; + let nextStickyActiveProvider = state.stickyActiveProvider; if (options?.persistSticky === true) { nextStickyMap = { ...state.stickyModelSelectionByProvider }; const stickyBase = @@ -1734,11 +1756,13 @@ export const useComposerDraftStore = create()( const { options: _, ...rest } = stickyBase; nextStickyMap[normalizedProvider] = rest as ModelSelection; } + nextStickyActiveProvider = base.activeProvider ?? normalizedProvider; } if ( Equal.equals(base.modelSelectionByProvider, nextMap) && - Equal.equals(state.stickyModelSelectionByProvider, nextStickyMap) + Equal.equals(state.stickyModelSelectionByProvider, nextStickyMap) && + state.stickyActiveProvider === nextStickyActiveProvider ) { return state; } @@ -1757,7 +1781,10 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId, ...(options?.persistSticky === true - ? { stickyModelSelectionByProvider: nextStickyMap } + ? { + stickyModelSelectionByProvider: nextStickyMap, + stickyActiveProvider: nextStickyActiveProvider, + } : {}), }; }); @@ -2117,6 +2144,7 @@ export const useComposerDraftStore = create()( draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId, projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId, stickyModelSelectionByProvider: normalizedPersisted.stickyModelSelectionByProvider ?? {}, + stickyActiveProvider: normalizedPersisted.stickyActiveProvider ?? null, }; }, },