diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index 10fb32a5ed..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"; @@ -115,7 +116,10 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => projectId: PROJECT_ID, title: "Integration Project", workspaceRoot: harness.workspaceDir, - defaultModel, + defaultModelSelection: { + provider, + model: defaultModel, + }, createdAt, }); @@ -125,7 +129,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, @@ -139,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", @@ -151,7 +158,11 @@ const startTurn = (input: { text: input.text, attachments: [], }, - ...(input.provider !== undefined ? { provider: input.provider } : {}), + ...(input.modelSelection !== undefined + ? { + modelSelection: input.modelSelection, + } + : {}), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: nowIso(), @@ -254,7 +265,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 +278,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, @@ -922,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( @@ -976,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( @@ -1083,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) => @@ -1152,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( @@ -1222,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( 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..77b5d4d619 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,16 +1925,19 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { runOnWorktreeCreate: false, }, ], - defaultModel: "gpt-5", + defaultModelSelection: { + provider: "codex", + model: "gpt-5", + }, }); 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' `; @@ -1906,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/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..5080ea8c48 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -34,7 +34,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { project_id, title, workspace_root, - default_model, + default_model_selection_json, scripts_json, created_at, updated_at, @@ -44,7 +44,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 'project-1', 'Project 1', '/tmp/project-1', - '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', @@ -57,7 +57,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { thread_id, project_id, title, - model, + model_selection_json, branch, worktree_path, latest_turn_id, @@ -69,7 +69,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 'thread-1', 'project-1', 'Thread 1', - 'gpt-5-codex', + '{"provider":"codex","model":"gpt-5-codex"}', NULL, NULL, 'turn-1', @@ -234,7 +234,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 +257,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..cc2f4f87e7 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,6 +46,7 @@ import { const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ + defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }), ); @@ -55,7 +57,11 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( }), ); const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; -const ProjectionThreadDbRowSchema = ProjectionThread; +const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( + Struct.assign({ + modelSelection: Schema.fromJsonString(ModelSelection), + }), +); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( Struct.assign({ payload: Schema.fromJsonString(Schema.Unknown), @@ -141,7 +147,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { project_id AS "projectId", title, workspace_root AS "workspaceRoot", - default_model AS "defaultModel", + default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -160,7 +166,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, - model, + model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", branch, @@ -531,22 +537,22 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); } - const projects: Array = projectRows.map((row) => ({ + const projects: ReadonlyArray = projectRows.map((row) => ({ id: row.projectId, title: row.title, workspaceRoot: row.workspaceRoot, - defaultModel: row.defaultModel, + defaultModelSelection: row.defaultModelSelection, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, deletedAt: row.deletedAt, })); - const threads: Array = threadRows.map((row) => ({ + const threads: ReadonlyArray = threadRows.map((row) => ({ id: row.threadId, projectId: row.projectId, title: row.title, - model: row.model, + modelSelection: row.modelSelection, runtimeMode: row.runtimeMode, interactionMode: row.interactionMode, branch: row.branch, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 5a7084a61b..b58c2522cb 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, @@ -93,37 +93,27 @@ describe("ProviderCommandReactor", () => { async function createHarness(input?: { readonly baseDir?: string; - readonly threadModel?: 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-")); createdBaseDirs.add(baseDir); const { stateDir } = deriveServerPathsSync(baseDir, undefined); createdStateDirs.add(stateDir); - const threadModel = input?.threadModel ?? "gpt-5-codex"; 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 && @@ -132,7 +122,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" && @@ -141,7 +131,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, @@ -203,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), @@ -242,7 +232,7 @@ describe("ProviderCommandReactor", () => { projectId: asProjectId("project-1"), title: "Provider Project", workspaceRoot: "/tmp/provider-project", - defaultModel: threadModel, + defaultModelSelection: modelSelection, createdAt: now, }), ); @@ -253,7 +243,7 @@ describe("ProviderCommandReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", - model: threadModel, + modelSelection: modelSelection, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, @@ -303,7 +293,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", }); @@ -328,10 +321,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, }, @@ -345,9 +338,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, }, @@ -355,9 +349,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, }, @@ -366,7 +361,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( @@ -380,10 +377,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", }, }, @@ -396,19 +393,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", }, }, @@ -416,7 +414,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( @@ -430,10 +430,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, }, }, @@ -446,19 +446,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, }, }, @@ -504,7 +505,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( @@ -518,7 +521,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,49 +558,53 @@ describe("ProviderCommandReactor", () => { }); }); - it("rejects a turn when the requested model belongs to a different provider", async () => { - const harness = await createHarness(); + 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-model-provider-mismatch"), + commandId: CommandId.makeUnsafe("cmd-turn-start-unsupported-1"), threadId: ThreadId.makeUnsafe("thread-1"), message: { - messageId: asMessageId("user-message-model-provider-mismatch"), + messageId: asMessageId("user-message-unsupported-1"), role: "user", - text: "hello", + text: "first", attachments: [], }, - 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 - ); - }); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); - expect(harness.startSession).not.toHaveBeenCalled(); - expect(harness.sendTurn).not.toHaveBeenCalled(); + 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, + }), + ); - 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'"), + 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", }, }); }); @@ -646,7 +656,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( @@ -660,10 +672,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 +699,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", }, }, @@ -703,10 +715,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", }, }, @@ -800,6 +813,51 @@ 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({ + threadModelSelection: { provider: "claudeAgent", model: "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({ + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + }, + runtimeMode: "approval-required", + }); + }); + it("rejects provider changes after a thread is already bound to a session provider", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -835,7 +893,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..9399bcc280 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -3,8 +3,8 @@ import { CommandId, DEFAULT_GIT_TEXT_GENERATION_MODEL, EventId, + type ModelSelection, type OrchestrationEvent, - type ProviderModelOptions, ProviderKind, type ProviderStartOptions, type OrchestrationSession, @@ -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"; @@ -26,7 +26,6 @@ import { ProviderCommandReactor, type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; -import { inferProviderForModel } from "@t3tools/shared/model"; type ProviderIntentEvent = Extract< OrchestrationEvent, @@ -76,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 sameModelOptions = ( - left: ProviderModelOptions | undefined, - right: ProviderModelOptions | 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)) { @@ -158,7 +152,7 @@ const make = Effect.gen(function* () { ); const threadProviderOptions = new Map(); - const threadModelOptions = new Map(); + const threadModelSelections = new Map(); const appendProviderFailureActivity = (input: { readonly threadId: ThreadId; @@ -215,9 +209,7 @@ const make = Effect.gen(function* () { threadId: ThreadId, createdAt: string, options?: { - readonly provider?: ProviderKind; - readonly model?: string; - readonly modelOptions?: ProviderModelOptions; + readonly modelSelection?: ModelSelection; readonly providerOptions?: ProviderStartOptions; }, ) { @@ -233,26 +225,20 @@ 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 effectiveCwd = resolveThreadWorkspaceCwd({ thread, projects: readModel.projects, @@ -269,12 +255,9 @@ 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 } : {}), + modelSelection: desiredModelSelection, ...(options?.providerOptions !== undefined ? { providerOptions: options.providerOptions } : {}), @@ -303,25 +286,28 @@ 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 = + const previousModelSelection = threadModelSelections.get(threadId); + const shouldRestartForModelSelectionChange = currentProvider === "claudeAgent" && - options?.modelOptions !== undefined && - !sameModelOptions(previousModelOptions, options.modelOptions); + requestedModelSelection !== undefined && + !Equal.equals(previousModelSelection, requestedModelSelection); if ( !runtimeModeChanged && !providerChanged && !shouldRestartForModelChange && - !shouldRestartForModelOptionsChange + !shouldRestartForModelSelectionChange ) { return existingSessionThreadId; } @@ -334,20 +320,19 @@ const make = Effect.gen(function* () { threadId, existingSessionThreadId, currentProvider, - desiredProvider: options?.provider ?? currentProvider, + desiredProvider: desiredModelSelection.provider, currentRuntimeMode: thread.session?.runtimeMode, desiredRuntimeMode: thread.runtimeMode, runtimeModeChanged, providerChanged, modelChanged, shouldRestartForModelChange, - shouldRestartForModelOptionsChange, + shouldRestartForModelSelectionChange, 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 +344,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,9 +353,7 @@ const make = Effect.gen(function* () { readonly threadId: ThreadId; readonly messageText: string; readonly attachments?: ReadonlyArray; - readonly provider?: ProviderKind; - readonly model?: string; - readonly modelOptions?: ProviderModelOptions; + readonly modelSelection?: ModelSelection; readonly providerOptions?: ProviderStartOptions; readonly interactionMode?: "default" | "plan"; readonly createdAt: string; @@ -382,16 +363,14 @@ 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.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), + ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), ...(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 ?? []; @@ -404,14 +383,23 @@ const make = Effect.gen(function* () { activeSession === undefined ? "in-session" : (yield* providerService.getCapabilities(activeSession.provider)).sessionModelSwitch; - const modelForTurn = sessionModelSwitch === "unsupported" ? activeSession?.model : input.model; + const requestedModelSelection = + input.modelSelection ?? threadModelSelections.get(input.threadId) ?? thread.modelSelection; + const modelForTurn = + sessionModelSwitch === "unsupported" + ? activeSession?.model !== undefined + ? { + ...requestedModelSelection, + model: activeSession.model, + } + : requestedModelSelection + : 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 } : {}), }); }); @@ -524,10 +512,8 @@ const make = Effect.gen(function* () { 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 } : {}), ...(event.payload.providerOptions !== undefined ? { providerOptions: event.payload.providerOptions } @@ -698,12 +684,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/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 4f7a28c401..b29df5c8fe 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..7ff19f55ae 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -1,9 +1,9 @@ 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, Schema, Struct } from "effect"; +import { ModelSelection, ProjectScript } from "@t3tools/contracts"; +import { toPersistenceSqlError } from "../Errors.ts"; import { DeleteProjectionProjectInput, GetProjectionProjectInput, @@ -11,69 +11,64 @@ 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 = ProjectionProject.mapFields( + Struct.assign({ + defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), + scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), + }), ); - -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { - return (cause: unknown) => - Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); -} +type ProjectionProjectDbRow = typeof ProjectionProjectDbRow.Type; 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_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + ${row.projectId}, + ${row.title}, + ${row.workspaceRoot}, + ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : 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_model_selection_json = excluded.default_model_selection_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_model AS "defaultModel", + default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -85,14 +80,14 @@ 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_model AS "defaultModel", + default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -113,40 +108,17 @@ 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, { - onNone: () => Effect.succeed(Option.none()), - onSome: (row) => - Effect.succeed(Option.some(row as Schema.Schema.Type)), - }), - ), + Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.getById:query")), ); const listAll: ProjectionProjectRepositoryShape["listAll"] = () => listProjectionProjectRows().pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionProjectRepository.listAll:query", - "ProjectionProjectRepository.listAll:decodeRows", - ), - ), - Effect.map((rows) => rows as ReadonlyArray>), + Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.listAll:query")), ); const deleteById: ProjectionProjectRepositoryShape["deleteById"] = (input) => 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..0ca13f2e97 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -0,0 +1,122 @@ +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 defaultModelSelection: string | null; + }>` + SELECT default_model_selection_json AS "defaultModelSelection" + 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.defaultModelSelection, + JSON.stringify({ + provider: "codex", + model: "gpt-5.4", + }), + ); + + 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 JSON for 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 modelSelection: string | null; + }>` + SELECT model_selection_json AS "modelSelection" + 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.modelSelection, + JSON.stringify({ + provider: "claudeAgent", + model: "claude-opus-4-6", + }), + ); + + 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 10192697d0..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 } from "effect"; +import { Effect, Layer, Schema, Struct } from "effect"; import { toPersistenceSqlError } from "../Errors.ts"; import { @@ -11,6 +11,14 @@ import { ProjectionThreadRepository, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; +import { ModelSelection } from "@t3tools/contracts"; + +const ProjectionThreadDbRow = ProjectionThread.mapFields( + Struct.assign({ + modelSelection: Schema.fromJsonString(ModelSelection), + }), +); +type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; const makeProjectionThreadRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -23,7 +31,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id, project_id, title, - model, + model_selection_json, runtime_mode, interaction_mode, branch, @@ -37,7 +45,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.threadId}, ${row.projectId}, ${row.title}, - ${row.model}, + ${JSON.stringify(row.modelSelection)}, ${row.runtimeMode}, ${row.interactionMode}, ${row.branch}, @@ -51,7 +59,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { DO UPDATE SET project_id = excluded.project_id, title = excluded.title, - model = excluded.model, + model_selection_json = excluded.model_selection_json, runtime_mode = excluded.runtime_mode, interaction_mode = excluded.interaction_mode, branch = excluded.branch, @@ -65,14 +73,14 @@ 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, - model, + model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", branch, @@ -88,14 +96,14 @@ 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, - model, + model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", branch, 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 ea1821014a..7ee45514e5 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"; @@ -27,7 +28,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 { Effect } from "effect"; +import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts"; /** * Migration loader with all migrations defined inline. @@ -39,23 +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, -}); +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 @@ -63,6 +74,10 @@ const loader = Migrator.fromRecord({ */ const run = Migrator.make({}); +export interface RunMigrationsOptions { + readonly toMigrationInclusive?: number | undefined; +} + /** * Run all pending migrations. * @@ -73,11 +88,19 @@ 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( + toMigrationInclusive === undefined + ? "Running all migrations..." + : `Running migrations 1 through ${toMigrationInclusive}...`, + ); + const executedMigrations = yield* run({ loader: makeMigrationLoader(toMigrationInclusive) }); + yield* Effect.log("Migrations ran successfully").pipe( + Effect.annotateLogs({ migrations: executedMigrations.map(([id, name]) => `${id}_${name}`) }), + ); + return executedMigrations; + }); /** * Layer that runs migrations when the layer is built. @@ -96,4 +119,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 new file mode 100644 index 0000000000..039a63d60b --- /dev/null +++ b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts @@ -0,0 +1,357 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("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; + + // Setup base state + { + yield* runMigrations({ toMigrationInclusive: 15 }); + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES + ('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` + UPDATE projection_projects + SET default_model = 'claude-opus-4-6' + WHERE project_id = 'project-claude' + `; + 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', '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, + 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` + INSERT INTO orchestration_events ( + event_id, + aggregate_kind, + stream_id, + stream_version, + event_type, + occurred_at, + command_id, + causation_event_id, + correlation_id, + actor_kind, + payload_json, + metadata_json + ) + VALUES + ( + 'event-project-created', + 'project', + 'project-1', + 1, + 'project.created', + '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-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', + 'thread-1', + 1, + 'thread.created', + '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-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', + 'thread-1', + 2, + 'thread.turn-start-requested', + '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"}', + '{}' + ), + ( + '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"}', + '{}' + ) + `; + } + + // Execute migration under test + yield* runMigrations({ toMigrationInclusive: 16 }); + + // Assert expected state + { + const projectRows = yield* sql<{ + readonly projectId: string; + readonly defaultModelSelection: string | null; + }>` + SELECT + project_id AS "projectId", + default_model_selection_json AS "defaultModelSelection" + FROM projection_projects + ORDER BY project_id + `; + assert.deepStrictEqual(projectRows, [ + { + 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 modelSelection: string | null; + }>` + SELECT + thread_id AS "threadId", + model_selection_json AS "modelSelection" + FROM projection_threads + ORDER BY thread_id + `; + assert.deepStrictEqual(threadRows, [ + { + 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<{ + 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), { + 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", + 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[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", + modelSelection: { + provider: "codex", + model: "gpt-5.4", + options: { + fastMode: true, + }, + }, + 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 new file mode 100644 index 0000000000..68d7baf83f --- /dev/null +++ b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.ts @@ -0,0 +1,235 @@ +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_selection_json TEXT + `; + + yield* sql` + UPDATE projection_projects + SET default_model_selection_json = CASE + WHEN default_model IS NULL THEN NULL + ELSE json_object( + 'provider', + CASE + WHEN lower(default_model) LIKE '%claude%' THEN 'claudeAgent' + ELSE 'codex' + END, + 'model', + default_model + ) + END + WHERE default_model_selection_json IS NULL + `; + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN model_selection_json TEXT + `; + + yield* sql` + UPDATE projection_threads + 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' + ), + 'model', + model + ) + WHERE model_selection_json IS NULL + `; + + yield* sql` + ALTER TABLE projection_projects + DROP COLUMN default_model + `; + + yield* sql` + ALTER TABLE projection_threads + DROP COLUMN model + `; + + 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 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 + 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')) + ) + 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 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 + ) + ), + '$.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 + `; + + // 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/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/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 48055c88a5..d4ed6fba19 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", }); @@ -2360,7 +2372,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 bddda8895e..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,15 +520,16 @@ const CLAUDE_SETTING_SOURCES = [ ] as const satisfies ReadonlyArray; function buildPromptText(input: ProviderSendTurnInput): string { - const requestedEffort = resolveReasoningEffortForProvider( - "claudeAgent", - input.modelOptions?.claudeAgent?.effort ?? null, - ); - const supportedEffortOptions = getReasoningEffortOptions("claudeAgent", input.model); + 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.model) + requestedEffort === "ultrathink" && caps.reasoningEffortLevels.length > 0 ? "ultrathink" - : requestedEffort && supportedEffortOptions.includes(requestedEffort) + : requestedEffort && hasEffortLevel(caps, requestedEffort) ? requestedEffort : null; return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); @@ -1368,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) { @@ -2051,6 +2079,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { context.lastKnownContextWindow, ); if (normalizedUsage) { + context.lastKnownTokenUsage = normalizedUsage; const usageStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ ...base, @@ -2082,6 +2111,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { context.lastKnownContextWindow, ); if (normalizedUsage) { + context.lastKnownTokenUsage = normalizedUsage; const usageStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ ...base, @@ -2698,21 +2728,16 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { ); const providerOptions = input.providerOptions?.claudeAgent; - const requestedEffort = resolveReasoningEffortForProvider( - "claudeAgent", - input.modelOptions?.claudeAgent?.effort ?? null, - ); - const supportedEffortOptions = getReasoningEffortOptions("claudeAgent", input.model); + const modelSelection = + input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; + const requestedEffort = trimOrNull(modelSelection?.options?.effort ?? null); + const caps = getModelCapabilities("claudeAgent", modelSelection?.model); const effort = - requestedEffort && supportedEffortOptions.includes(requestedEffort) - ? requestedEffort - : null; - const fastMode = - input.modelOptions?.claudeAgent?.fastMode === true && supportsClaudeFastMode(input.model); + requestedEffort && hasEffortLevel(caps, requestedEffort) ? requestedEffort : null; + const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode; const thinking = - typeof input.modelOptions?.claudeAgent?.thinking === "boolean" && - supportsClaudeThinkingToggle(input.model) - ? input.modelOptions.claudeAgent.thinking + typeof modelSelection?.options?.thinking === "boolean" && caps.supportsThinkingToggle + ? modelSelection.options.thinking : undefined; const effectiveEffort = getEffectiveClaudeCodeEffort(effort); const permissionMode = @@ -2725,7 +2750,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 } : {}), @@ -2766,7 +2791,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 } : {}), @@ -2794,6 +2819,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { inFlightTools, turnState: undefined, lastKnownContextWindow: undefined, + lastKnownTokenUsage: undefined, lastAssistantUuid: resumeState?.resumeSessionAt, lastThreadStartedId: undefined, stopped: false, @@ -2821,7 +2847,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 } : {}), @@ -2867,6 +2893,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 @@ -2874,9 +2902,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), }); } @@ -2926,7 +2954,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 14c7c6dd42..3017235f1e 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 d34505582e..ca9c52cf8e 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1361,8 +1361,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({ @@ -1418,11 +1422,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/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index babe6fe9db..ff95b54112 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 24965fd608..bcb3850e7a 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 d4ff054672..4e18092463 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; @@ -221,7 +222,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, @@ -233,7 +237,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", @@ -287,7 +294,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", @@ -756,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); @@ -817,8 +830,8 @@ describe("ChatView timeline estimator parity (full app)", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, - stickyModel: null, - stickyModelOptions: {}, + stickyModelSelectionByProvider: {}, + stickyActiveProvider: null, }); useStore.setState({ projects: [], @@ -1483,13 +1496,17 @@ 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: { + stickyModelSelectionByProvider: { codex: { - reasoningEffort: "medium", - fastMode: true, + provider: "codex", + model: "gpt-5.3-codex", + options: { + reasoningEffort: "medium", + fastMode: true, + }, }, }, + stickyActiveProvider: "codex", }); const mounted = await mountChatView({ @@ -1514,13 +1531,16 @@ 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: { + modelSelectionByProvider: { codex: { - fastMode: true, + provider: "codex", + model: "gpt-5.3-codex", + options: { + fastMode: true, + }, }, }, + activeProvider: "codex", }); } finally { await mounted.cleanup(); @@ -1529,13 +1549,17 @@ 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: { + stickyModelSelectionByProvider: { claudeAgent: { - effort: "max", - fastMode: true, + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { + effort: "max", + fastMode: true, + }, }, }, + stickyActiveProvider: "claudeAgent", }); const mounted = await mountChatView({ @@ -1560,16 +1584,18 @@ 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: { + modelSelectionByProvider: { claudeAgent: { - effort: "max", - fastMode: true, + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { + effort: "max", + fastMode: true, + }, }, }, + activeProvider: "claudeAgent", }); - await expect.element(page.getByText("Claude Opus 4.6")).toBeInTheDocument(); } finally { await mounted.cleanup(); } @@ -1605,13 +1631,17 @@ 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: { + stickyModelSelectionByProvider: { codex: { - reasoningEffort: "medium", - fastMode: true, + provider: "codex", + model: "gpt-5.3-codex", + options: { + reasoningEffort: "medium", + fastMode: true, + }, }, }, + stickyActiveProvider: "codex", }); const mounted = await mountChatView({ @@ -1636,17 +1666,22 @@ 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: { + modelSelectionByProvider: { codex: { - fastMode: true, + provider: "codex", + model: "gpt-5.3-codex", + options: { + fastMode: true, + }, }, }, + activeProvider: "codex", }); - 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, }, @@ -1660,13 +1695,17 @@ 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: { + modelSelectionByProvider: { codex: { - reasoningEffort: "low", - fastMode: true, + provider: "codex", + model: "gpt-5.4", + options: { + reasoningEffort: "low", + fastMode: true, + }, }, }, + activeProvider: "codex", }); } finally { await mounted.cleanup(); 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 59ff4f73c8..fbc887bf62 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3,6 +3,7 @@ import { DEFAULT_MODEL_BY_PROVIDER, type ClaudeCodeEffort, type MessageId, + type ModelSelection, type ProjectScript, type ModelSlug, type ProviderKind, @@ -23,9 +24,8 @@ import { } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, - getDefaultModel, + getModelCapabilities, normalizeModelSlug, - resolveModelSlugForProvider, } from "@t3tools/shared/model"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -133,6 +133,7 @@ import { type DraftThreadEnvMode, type PersistedComposerImageAttachment, useComposerDraftStore, + useEffectiveComposerModelState, useComposerThreadDraft, } from "../composerDraftStore"; import { @@ -196,10 +197,12 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record 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({ @@ -273,8 +278,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, @@ -467,11 +471,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 = @@ -590,7 +597,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ]); const sessionProvider = activeThread?.session?.provider ?? null; - const selectedProviderByThreadId = composerDraft.provider; + const selectedProviderByThreadId = composerDraft.activeProvider ?? null; + const threadProvider = + activeThread?.modelSelection.provider ?? activeProject?.defaultModelSelection?.provider ?? null; const hasThreadStarted = Boolean( activeThread && (activeThread.latestTurn !== null || @@ -598,34 +607,38 @@ 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 baseThreadModel = resolveModelSlugForProvider( - selectedProvider, - activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), - ); + const selectedProvider: ProviderKind = + lockedProvider ?? selectedProviderByThreadId ?? threadProvider ?? "codex"; const customModelsByProvider = useMemo(() => getCustomModelsByProvider(settings), [settings]); - const selectedModel = useMemo(() => { - const draftModel = composerDraft.model; - if (!draftModel) { - return baseThreadModel; - } - return resolveAppModelSelection(selectedProvider, customModelsByProvider, draftModel); - }, [baseThreadModel, composerDraft.model, customModelsByProvider, selectedProvider]); - const draftModelOptions = composerDraft.modelOptions; + const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ + threadId, + selectedProvider, + threadModelSelection: activeThread?.modelSelection, + projectModelSelection: activeProject?.defaultModelSelection, + customModelsByProvider, + }); const composerProviderState = useMemo( () => getComposerProviderState({ provider: selectedProvider, model: selectedModel, prompt, - modelOptions: draftModelOptions, + modelOptions: composerModelOptions, }), - [draftModelOptions, prompt, selectedModel, selectedProvider], + [composerModelOptions, prompt, selectedModel, selectedProvider], ); const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; + const selectedModelSelection = useMemo( + () => ({ + provider: selectedProvider, + model: selectedModel, + ...(selectedModelOptionsForDispatch ? { options: selectedModelOptionsForDispatch } : {}), + }), + [selectedModel, selectedModelOptionsForDispatch, selectedProvider], + ); const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]); const selectedModelForPicker = selectedModel; const modelOptionsByProvider = useMemo( @@ -1594,7 +1607,7 @@ export default function ChatView({ threadId }: ChatViewProps) { async (input: { threadId: ThreadId; createdAt: string; - model?: string; + modelSelection?: ModelSelection; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; }) => { @@ -1606,12 +1619,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, }); } @@ -2441,6 +2460,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ provider: selectedProvider, + model: selectedModel, effort: selectedPromptEffort, text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, }); @@ -2542,8 +2562,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({ @@ -2552,7 +2578,7 @@ export default function ChatView({ threadId }: ChatViewProps) { threadId: threadIdForSend, projectId: activeProject.id, title, - model: threadCreateModel, + modelSelection: threadCreateModelSelection, runtimeMode, interactionMode, branch: nextThreadBranch, @@ -2601,7 +2627,7 @@ export default function ChatView({ threadId }: ChatViewProps) { await persistThreadSettingsForNextTurn({ threadId: threadIdForSend, createdAt: messageCreatedAt, - ...(selectedModel ? { model: selectedModel } : {}), + ...(selectedModel ? { modelSelection: selectedModelSelection } : {}), runtimeMode, interactionMode, }); @@ -2619,12 +2645,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, @@ -2861,6 +2883,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ provider: selectedProvider, + model: selectedModel, effort: selectedPromptEffort, text: trimmed, }); @@ -2885,7 +2908,7 @@ export default function ChatView({ threadId }: ChatViewProps) { await persistThreadSettingsForNextTurn({ threadId: threadIdForSend, createdAt: messageCreatedAt, - ...(selectedModel ? { model: selectedModel } : {}), + modelSelection: selectedModelSelection, runtimeMode, interactionMode: nextInteractionMode, }); @@ -2904,11 +2927,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, @@ -2955,13 +2974,13 @@ export default function ChatView({ threadId }: ChatViewProps) { resetSendPhase, runtimeMode, selectedPromptEffort, - selectedModel, - selectedModelOptionsForDispatch, + selectedModelSelection, providerOptionsForDispatch, selectedProvider, setComposerDraftInteractionMode, setThreadError, settings.enableAssistantStreaming, + selectedModel, ], ); @@ -2986,15 +3005,12 @@ export default function ChatView({ threadId }: ChatViewProps) { const implementationPrompt = buildPlanImplementationPrompt(planMarkdown); const outgoingImplementationPrompt = formatOutgoingPrompt({ provider: selectedProvider, + model: selectedModel, effort: selectedPromptEffort, 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; sendInFlightRef.current = true; beginSendPhase("sending-turn"); @@ -3010,7 +3026,7 @@ export default function ChatView({ threadId }: ChatViewProps) { threadId: nextThreadId, projectId: activeProject.id, title: nextThreadTitle, - model: nextThreadModel, + modelSelection: nextThreadModelSelection, runtimeMode, interactionMode: "default", branch: activeThread.branch, @@ -3028,11 +3044,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, @@ -3084,12 +3096,12 @@ export default function ChatView({ threadId }: ChatViewProps) { resetSendPhase, runtimeMode, selectedPromptEffort, - selectedModel, - selectedModelOptionsForDispatch, + selectedModelSelection, providerOptionsForDispatch, selectedProvider, settings.enableAssistantStreaming, syncServerReadModel, + selectedModel, ]); const onProviderModelSelect = useCallback( @@ -3100,18 +3112,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, ], ); @@ -3135,12 +3149,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/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.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 6925b5391c..9eebee3666 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -343,16 +343,21 @@ describe("getVisibleThreadsForProject", () => { }); function makeProject(overrides: Partial = {}): Project { + const { defaultModelSelection, ...rest } = overrides; return { id: ProjectId.makeUnsafe("project-1"), name: "Project", cwd: "/tmp/project", - model: "gpt-5.4", + defaultModelSelection: { + provider: "codex", + model: "gpt-5.4", + ...defaultModelSelection, + }, expanded: true, createdAt: "2026-03-09T10:00:00.000Z", updatedAt: "2026-03-09T10:00:00.000Z", scripts: [], - ...overrides, + ...rest, }; } @@ -362,7 +367,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, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a8a58a13b2..120b7c4759 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -539,7 +539,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 deleted file mode 100644 index a675a82d89..0000000000 --- a/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import "../../index.css"; - -import { ThreadId } from "@t3tools/contracts"; -import { page } from "vitest/browser"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { render } from "vitest-browser-react"; - -import { ClaudeTraitsPicker } from "./ClaudeTraitsPicker"; -import { useComposerDraftStore } from "../../composerDraftStore"; - -async function mountPicker(props?: { - model?: string; - prompt?: string; - effort?: "low" | "medium" | "high" | "max" | "ultrathink" | null; - thinkingEnabled?: boolean | null; - fastModeEnabled?: boolean; -}) { - const threadId = ThreadId.makeUnsafe("thread-claude-traits"); - const draftsByThreadId = {} as ReturnType< - typeof useComposerDraftStore.getState - >["draftsByThreadId"]; - draftsByThreadId[threadId] = { - prompt: props?.prompt ?? "", - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - terminalContexts: [], - provider: "claudeAgent", - model: props?.model ?? "claude-opus-4-6", - modelOptions: { - claudeAgent: { - ...(props?.effort ? { effort: props.effort } : {}), - ...(props?.thinkingEnabled === false ? { thinking: false } : {}), - ...(props?.fastModeEnabled ? { fastMode: true } : {}), - }, - }, - runtimeMode: null, - interactionMode: null, - }; - useComposerDraftStore.setState({ - draftsByThreadId, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - }); - const host = document.createElement("div"); - document.body.append(host); - const onPromptChange = vi.fn(); - const screen = await render( - , - { container: host }, - ); - - return { - onPromptChange, - cleanup: async () => { - await screen.unmount(); - host.remove(); - }, - }; -} - -describe("ClaudeTraitsPicker", () => { - afterEach(() => { - document.body.innerHTML = ""; - useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - }); - }); - - it("shows fast mode controls for Opus", async () => { - const mounted = await mountPicker(); - - 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("hides fast mode controls for non-Opus models", async () => { - const mounted = await mountPicker({ model: "claude-sonnet-4-6" }); - - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - expect(document.body.textContent ?? "").not.toContain("Fast Mode"); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("shows only the provided effort options", async () => { - const mounted = await mountPicker({ - 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(); - } - }); - - it("shows a thinking on/off dropdown for Haiku", async () => { - const mounted = await mountPicker({ - model: "claude-haiku-4-5", - thinkingEnabled: true, - }); - - 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(); - } - }); - - it("shows prompt-controlled Ultrathink state with disabled effort controls", async () => { - const mounted = await mountPicker({ - effort: "high", - model: "claude-opus-4-6", - 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(); - } - }); - - it("persists sticky claude model options when traits change", async () => { - const mounted = await mountPicker({ - model: "claude-opus-4-6", - effort: "medium", - fastModeEnabled: false, - }); - - try { - await page.getByRole("button").click(); - await page.getByRole("menuitemradio", { name: "Max" }).click(); - - expect(useComposerDraftStore.getState().stickyModelOptions).toMatchObject({ - claudeAgent: { - effort: "max", - }, - }); - } finally { - await mounted.cleanup(); - } - }); -}); diff --git a/apps/web/src/components/chat/ClaudeTraitsPicker.tsx b/apps/web/src/components/chat/ClaudeTraitsPicker.tsx deleted file mode 100644 index d6585d43d8..0000000000 --- a/apps/web/src/components/chat/ClaudeTraitsPicker.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import { - ProviderKind, - type ClaudeCodeEffort, - type ClaudeModelOptions, - type ThreadId, -} from "@t3tools/contracts"; -import { - applyClaudePromptEffortPrefix, - getDefaultReasoningEffort, - getReasoningEffortOptions, - normalizeClaudeModelOptions, - resolveReasoningEffortForProvider, - supportsClaudeFastMode, - supportsClaudeThinkingToggle, - supportsClaudeUltrathinkKeyword, - isClaudeUltrathinkPrompt, -} 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, useComposerThreadDraft } from "../../composerDraftStore"; - -const PROVIDER = "claudeAgent" as const satisfies ProviderKind; - -const CLAUDE_EFFORT_LABELS: 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; - onPromptChange: (prompt: string) => void; -} - -export const ClaudeTraitsMenuContent = memo(function ClaudeTraitsMenuContentImpl({ - threadId, - model, - onPromptChange, -}: ClaudeTraitsMenuContentProps) { - const draft = useComposerThreadDraft(threadId); - const prompt = draft.prompt; - const modelOptions = draft.modelOptions?.[PROVIDER]; - 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, - normalizeClaudeModelOptions(model, { - ...modelOptions, - effort: nextEffort, - }), - { persistSticky: true }, - ); - }, - [ - ultrathinkPromptControlled, - model, - 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, - normalizeClaudeModelOptions(model, { - ...modelOptions, - thinking: value === "on", - }), - { persistSticky: true }, - ); - }} - > - On (default) - Off - -
- ) : null} - {supportsFastMode ? ( - <> - - -
Fast Mode
- { - setProviderModelOptions( - threadId, - PROVIDER, - normalizeClaudeModelOptions(model, { - ...modelOptions, - fastMode: value === "on", - }), - { persistSticky: true }, - ); - }} - > - off - on - -
- - ) : null} - - ); -}); - -export const ClaudeTraitsPicker = memo(function ClaudeTraitsPicker({ - threadId, - model, - onPromptChange, -}: 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 = [ - 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 9d2b73989d..0000000000 --- a/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import "../../index.css"; - -import { 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: [], - provider: "codex", - model: null, - 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: {}, - }); - }); - - 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().stickyModelOptions).toMatchObject({ - codex: { - fastMode: true, - }, - }); - } finally { - 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: [], - 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]?.modelOptions).toEqual({ - codex: { - 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 deleted file mode 100644 index 7b37063bff..0000000000 --- a/apps/web/src/components/chat/CodexTraitsPicker.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import type { - CodexModelOptions, - CodexReasoningEffort, - ProviderKind, - ThreadId, -} from "@t3tools/contracts"; -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 { 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 }) { - const draft = useComposerThreadDraft(props.threadId); - const modelOptions = draft.modelOptions?.[PROVIDER]; - 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, - normalizeCodexModelOptions({ - ...modelOptions, - reasoningEffort: nextEffort, - }), - { persistSticky: true }, - ); - }} - > - {options.map((option) => ( - - {CODEX_REASONING_LABELS[option]} - {option === defaultReasoningEffort ? " (default)" : ""} - - ))} - -
- - -
Fast Mode
- { - setProviderModelOptions( - props.threadId, - PROVIDER, - normalizeCodexModelOptions({ - ...modelOptions, - fastMode: value === "on", - }), - { persistSticky: true }, - ); - }} - > - off - on - -
- - ); -} - -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 { 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 83716d619a..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 { type ProviderModelOptions, ThreadId } from "@t3tools/contracts"; +import { DEFAULT_MODEL_BY_PROVIDER, ModelSelection, ThreadId } from "@t3tools/contracts"; import "../../index.css"; import { page } from "vitest/browser"; @@ -6,30 +6,31 @@ 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?: { - 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 = props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider]; + draftsByThreadId[threadId] = { prompt: props?.prompt ?? "", images: [], nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], - provider, - model: props?.model ?? "claude-opus-4-6", - modelOptions: props?.modelOptions ?? null, + modelSelectionByProvider: { + [provider]: { + provider, + model, + ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}), + }, + }, + activeProvider: provider, runtimeMode: null, interactionMode: null, }; @@ -41,6 +42,7 @@ async function mountMenu(props?: { const host = document.createElement("div"); document.body.append(host); const onPromptChange = vi.fn(); + const providerOptions = props?.modelSelection?.options; const screen = await render( - ) : ( - - ) + } onToggleInteractionMode={vi.fn()} onTogglePlanSidebar={vi.fn()} @@ -65,11 +66,14 @@ async function mountMenu(props?: { { container: host }, ); + const cleanup = async () => { + await screen.unmount(); + host.remove(); + }; + return { - cleanup: async () => { - await screen.unmount(); - host.remove(); - }, + [Symbol.asyncDispose]: cleanup, + cleanup, }; } @@ -80,107 +84,90 @@ describe("CompactComposerControlsMenu", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + stickyModelSelectionByProvider: {}, }); }); 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/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/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx new file mode 100644 index 0000000000..811ad5bb35 --- /dev/null +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -0,0 +1,362 @@ +import "../../index.css"; + +import { + type ModelSelection, + ClaudeModelOptions, + CodexModelOptions, + 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 { TraitsPicker } from "./TraitsPicker"; +import { + COMPOSER_DRAFT_STORAGE_KEY, + ComposerThreadDraftState, + useComposerDraftStore, + useComposerThreadDraft, + useEffectiveComposerModelState, +} from "../../composerDraftStore"; + +// ── Claude TraitsPicker tests ───────────────────────────────────────── + +const CLAUDE_THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits"); + +function ClaudeTraitsPickerHarness(props: { + model: string; + fallbackModelSelection: ModelSelection | null; +}) { + const prompt = useComposerThreadDraft(CLAUDE_THREAD_ID).prompt; + const setPrompt = useComposerDraftStore((store) => store.setPrompt); + const { modelOptions, selectedModel } = useEffectiveComposerModelState({ + threadId: CLAUDE_THREAD_ID, + selectedProvider: "claudeAgent", + threadModelSelection: props.fallbackModelSelection, + projectModelSelection: null, + customModelsByProvider: { codex: [], claudeAgent: [] }, + }); + const handlePromptChange = useCallback( + (nextPrompt: string) => { + setPrompt(CLAUDE_THREAD_ID, nextPrompt); + }, + [setPrompt], + ); + + return ( + + ); +} + +async function mountClaudePicker(props?: { + model?: string; + prompt?: string; + options?: ClaudeModelOptions; + fallbackModelOptions?: { + effort?: "low" | "medium" | "high" | "max" | "ultrathink"; + thinking?: boolean; + fastMode?: boolean; + } | null; + skipDraftModelOptions?: boolean; +}) { + const model = props?.model ?? "claude-opus-4-6"; + 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, + }, + }; + useComposerDraftStore.setState({ + draftsByThreadId, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); + const host = document.createElement("div"); + document.body.append(host); + const fallbackModelSelection = + props?.fallbackModelOptions !== undefined + ? ({ + provider: "claudeAgent", + model, + options: props.fallbackModelOptions ?? undefined, + } satisfies ModelSelection) + : null; + const screen = await render( + , + { container: host }, + ); + + const cleanup = async () => { + await screen.unmount(); + host.remove(); + }; + + return { + [Symbol.asyncDispose]: cleanup, + cleanup, + }; +} + +describe("TraitsPicker (Claude)", () => { + afterEach(() => { + document.body.innerHTML = ""; + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + stickyModelSelectionByProvider: {}, + }); + }); + + it("shows fast mode controls for Opus", async () => { + await using _ = await mountClaudePicker(); + + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Fast Mode"); + expect(text).toContain("off"); + expect(text).toContain("on"); + }); + }); + + it("hides fast mode controls for non-Opus models", async () => { + await using _ = await mountClaudePicker({ model: "claude-sonnet-4-6" }); + + await page.getByRole("button").click(); + + await vi.waitFor(() => { + expect(document.body.textContent ?? "").not.toContain("Fast Mode"); + }); + }); + + it("shows only the provided effort options", async () => { + await using _ = await mountClaudePicker({ + model: "claude-sonnet-4-6", + }); + + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Low"); + expect(text).toContain("Medium"); + expect(text).toContain("High"); + expect(text).not.toContain("Max"); + expect(text).toContain("Ultrathink"); + }); + }); + + it("shows a th inking on/off dropdown for Haiku", async () => { + await using _ = await mountClaudePicker({ + model: "claude-haiku-4-5", + options: { thinking: true }, + }); + + await vi.waitFor(() => { + expect(document.body.textContent ?? "").toContain("Thinking On"); + }); + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Thinking"); + expect(text).toContain("On (default)"); + expect(text).toContain("Off"); + }); + }); + + it("shows prompt-controlled Ultrathink state with disabled effort controls", async () => { + await using _ = await mountClaudePicker({ + model: "claude-opus-4-6", + options: { effort: "high" }, + prompt: "Ultrathink:\nInvestigate this", + }); + + await vi.waitFor(() => { + expect(document.body.textContent ?? "").toContain("Ultrathink"); + expect(document.body.textContent ?? "").not.toContain("Ultrathink · Prompt"); + }); + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Effort"); + expect(text).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 () => { + await using _ = await mountClaudePicker({ + model: "claude-opus-4-6", + options: { effort: "medium", fastMode: false }, + }); + + await page.getByRole("button").click(); + await page.getByRole("menuitemradio", { name: "Max" }).click(); + + expect( + useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent, + ).toMatchObject({ + provider: "claudeAgent", + options: { + effort: "max", + }, + }); + }); +}); + +// ── Codex TraitsPicker tests ────────────────────────────────────────── + +async function mountCodexPicker(props: { model?: string; options?: CodexModelOptions }) { + const threadId = ThreadId.makeUnsafe("thread-codex-traits"); + 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, + }, + }; + + 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 }, + ); + + const cleanup = async () => { + await screen.unmount(); + host.remove(); + }; + + return { + [Symbol.asyncDispose]: cleanup, + cleanup, + }; +} + +describe("TraitsPicker (Codex)", () => { + afterEach(() => { + document.body.innerHTML = ""; + localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + stickyModelSelectionByProvider: {}, + }); + }); + + it("shows fast mode controls", async () => { + await using _ = await mountCodexPicker({ + options: { fastMode: false }, + }); + + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Fast Mode"); + expect(text).toContain("off"); + expect(text).toContain("on"); + }); + }); + + it("shows Fast in the trigger label when fast mode is active", async () => { + await using _ = await mountCodexPicker({ + options: { fastMode: true }, + }); + + await vi.waitFor(() => { + expect(document.body.textContent ?? "").toContain("High · Fast"); + }); + }); + + it("shows only the provided effort options", async () => { + await using _ = await mountCodexPicker({ + options: { fastMode: false }, + }); + + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Low"); + expect(text).toContain("Medium"); + expect(text).toContain("High"); + expect(text).toContain("Extra High"); + }); + }); + + it("persists sticky codex model options when traits change", async () => { + await using _ = await mountCodexPicker({ + options: { fastMode: false }, + }); + + await page.getByRole("button").click(); + await page.getByRole("menuitemradio", { name: "on" }).click(); + + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toMatchObject({ + provider: "codex", + options: { fastMode: true }, + }); + }); +}); 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.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index 139876d6fa..5912d32e6c 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -34,12 +34,51 @@ describe("getComposerProviderState", () => { provider: "codex", promptEffort: "low", modelOptionsForDispatch: { + reasoningEffort: "low", + fastMode: true, + }, + }); + }); + + it("preserves codex fast mode when it is the only active option", () => { + const state = getComposerProviderState({ + provider: "codex", + model: "gpt-5.4", + prompt: "", + modelOptions: { codex: { - reasoningEffort: "low", fastMode: true, }, }, }); + + expect(state).toEqual({ + provider: "codex", + promptEffort: "high", + modelOptionsForDispatch: { + fastMode: true, + }, + }); + }); + + 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", () => { @@ -73,9 +112,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]", @@ -100,21 +137,18 @@ describe("getComposerProviderState", () => { provider: "claudeAgent", promptEffort: null, modelOptionsForDispatch: { - claudeAgent: { - thinking: false, - }, + thinking: false, }, }); }); - it("ignores codex options while resolving Claude state", () => { + it("preserves Claude fast mode when it is the only active option", () => { const state = getComposerProviderState({ provider: "claudeAgent", model: "claude-opus-4-6", prompt: "", modelOptions: { - codex: { - reasoningEffort: "low", + claudeAgent: { fastMode: true, }, }, @@ -123,25 +157,27 @@ describe("getComposerProviderState", () => { expect(state).toEqual({ provider: "claudeAgent", promptEffort: "high", - modelOptionsForDispatch: undefined, + modelOptionsForDispatch: { + fastMode: true, + }, }); }); - it("ignores Claude options while resolving codex state", () => { + it("drops explicit Claude default/off overrides from dispatch while keeping the selected effort label", () => { const state = getComposerProviderState({ - provider: "codex", - model: "gpt-5.4", - prompt: "Ultrathink:\nThis should not matter", + provider: "claudeAgent", + model: "claude-opus-4-6", + prompt: "", modelOptions: { claudeAgent: { - effort: "max", - fastMode: true, + effort: "high", + fastMode: false, }, }, }); expect(state).toEqual({ - provider: "codex", + 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 c1ad0156ad..088a2a47be 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -5,17 +5,16 @@ import { 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; @@ -27,7 +26,7 @@ export type ComposerProviderStateInput = { export type ComposerProviderState = { provider: ProviderKind; promptEffort: string | null; - modelOptionsForDispatch: ProviderModelOptions | undefined; + modelOptionsForDispatch: ProviderModelOptions[ProviderKind] | undefined; composerFrameClassName?: string; composerSurfaceClassName?: string; modelPickerIconClassName?: string; @@ -38,70 +37,114 @@ 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; }; +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 - ? { codex: normalizedCodexOptions } - : undefined, - }; - }, - renderTraitsMenuContent: ({ threadId }) => , - renderTraitsPicker: ({ threadId }) => , + getState: (input) => getProviderStateFromCapabilities(input), + renderTraitsMenuContent: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( + + ), + renderTraitsPicker: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( + + ), }, 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 - ? { claudeAgent: normalizedClaudeOptions } - : undefined, - ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame" } : {}), - ...(ultrathinkActive - ? { composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]" } - : {}), - ...(ultrathinkActive ? { modelPickerIconClassName: "ultrathink-chroma" } : {}), - }; - }, - renderTraitsMenuContent: ({ threadId, model, onPromptChange }) => ( - + getState: (input) => getProviderStateFromCapabilities(input), + renderTraitsMenuContent: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( + ), - renderTraitsPicker: ({ threadId, model, onPromptChange }) => ( - + renderTraitsPicker: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( + ), }, }; @@ -114,11 +157,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, }); } @@ -127,11 +174,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.test.ts b/apps/web/src/composerDraftStore.test.ts index e449998d13..b68663a890 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 } from "@t3tools/contracts"; +import { + ProjectId, + ThreadId, + type ModelSelection, + type ProviderModelOptions, +} from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -67,11 +72,27 @@ function resetComposerDraftStore() { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, - stickyModel: null, - stickyModelOptions: {}, + stickyModelSelectionByProvider: {}, + stickyActiveProvider: null, }); } +function modelSelection( + provider: "codex" | "claudeAgent", + model: string, + options?: ModelSelection["options"], +): ModelSelection { + return { + provider, + model, + ...(options ? { options } : {}), + } as ModelSelection; +} + +function providerModelOptions(options: ProviderModelOptions): ProviderModelOptions { + return options; +} + describe("composerDraftStore addImages", () => { const threadId = ThreadId.makeUnsafe("thread-dedupe"); let originalRevokeObjectUrl: typeof URL.revokeObjectURL; @@ -197,6 +218,8 @@ describe("composerDraftStore syncPersistedAttachments", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + stickyModelSelectionByProvider: {}, + stickyActiveProvider: null, }); }); @@ -253,6 +276,8 @@ describe("composerDraftStore terminal contexts", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + stickyModelSelectionByProvider: {}, + stickyActiveProvider: null, }); }); @@ -595,62 +620,58 @@ 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]?.modelSelectionByProvider.codex, + ).toEqual( + modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "xhigh", fastMode: true, - }, - claudeAgent: { - thinking: false, - }, - }); + }), + ); }); - it("drops default-only model options from the draft", () => { + it("keeps default-only model selections on 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(); + expect( + useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex, + ).toEqual(modelSelection("codex", "gpt-5.4")); }); - 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, @@ -661,114 +682,166 @@ describe("composerDraftStore modelOptions", () => { { persistSticky: true }, ); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ - codex: { - reasoningEffort: "xhigh", - }, - claudeAgent: { + expect( + useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider + .claudeAgent, + ).toEqual( + modelSelection("claudeAgent", "claude-opus-4-6", { thinking: false, - }, - }); - expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({ - codex: { - reasoningEffort: "xhigh", - }, - claudeAgent: { + }), + ); + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent).toEqual( + modelSelection("claudeAgent", "claude-opus-4-6", { thinking: false, - }, - }); + }), + ); }); - it("removes only the targeted provider entry when next options normalize empty", () => { + it("keeps explicit default-state overrides on the selection", () => { 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]?.modelSelectionByProvider + .claudeAgent, + ).toEqual( + modelSelection("claudeAgent", "claude-opus-4-6", { + thinking: true, + }), + ); + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider).toEqual({}); }); - 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.setModelOptions(threadId, { - codex: { - fastMode: true, - }, - }); + store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4", { fastMode: true })); store.setProviderModelOptions(threadId, "codex", { reasoningEffort: "high", fastMode: false, }); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + expect( + useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex, + ).toEqual( + modelSelection("codex", "gpt-5.4", { + reasoningEffort: "high", + fastMode: false, + }), + ); }); 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]?.modelSelectionByProvider + .claudeAgent, + ).toEqual( + modelSelection("claudeAgent", "claude-opus-4-6", { thinking: false, - }, - }); - expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({ - codex: { + }), + ); + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent).toEqual( + modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), + ); + }); + + 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(); + + store.setModelOptions( + threadId, + providerModelOptions({ + codex: { fastMode: true }, + claudeAgent: { effort: "max" }, + }), + ); + + store.setModelSelection(threadId, modelSelection("claudeAgent", "claude-opus-4-6")); + + 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", () => { + const store = useComposerDraftStore.getState(); + + store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4")); + + store.setProviderModelOptions( + threadId, + "codex", + { fastMode: true, }, - }); + { persistSticky: true }, + ); + + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toEqual( + modelSelection("codex", "gpt-5.4", { + fastMode: true, + }), + ); }); 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, @@ -779,34 +852,35 @@ describe("composerDraftStore modelOptions", () => { { persistSticky: false }, ); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ - claudeAgent: { + expect( + useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider + .claudeAgent, + ).toEqual( + modelSelection("claudeAgent", "claude-opus-4-6", { thinking: false, - }, - }); - expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({ - claudeAgent: { - effort: "max", - }, - }); + }), + ); + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent).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]?.modelSelectionByProvider.codex, + ).toEqual(modelSelection("codex", "gpt-5.3-codex")); }); }); @@ -815,63 +889,74 @@ 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: { - reasoningEffort: "medium", - fastMode: true, - }, - }, - }); + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toEqual( + modelSelection("codex", "gpt-5.3-codex", { + reasoningEffort: "medium", + fastMode: true, + }), + ); + expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("codex"); }); - 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().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", }); - - expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({}); }); }); -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("retains off-provider option memory without changing the active selection", () => { const store = useComposerDraftStore.getState(); - - store.setProvider(threadId, "codex"); - store.setProvider(threadId, null); - - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + store.setModelSelection( + threadId, + modelSelection("codex", "gpt-5.3-codex", { + reasoningEffort: "medium", + }), + ); + store.setProviderModelOptions(threadId, "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 696d6855b2..fb9c0d5150 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -2,7 +2,8 @@ import { CODEX_REASONING_EFFORT_OPTIONS, type ClaudeCodeEffort, type CodexReasoningEffort, - DEFAULT_REASONING_EFFORT_BY_PROVIDER, + type ModelSlug, + ModelSelection, ProjectId, ProviderInteractionMode, ProviderKind, @@ -13,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, @@ -26,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; @@ -73,9 +80,10 @@ 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), + modelSelectionByProvider: Schema.optionalKey( + Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)), + ), + activeProvider: Schema.optionalKey(Schema.NullOr(ProviderKind)), runtimeMode: Schema.optionalKey(RuntimeMode), interactionMode: Schema.optionalKey(ProviderInteractionMode), }); @@ -88,7 +96,38 @@ const LegacyCodexFields = Schema.Struct({ }); type LegacyCodexFields = typeof LegacyCodexFields.Type; -type LegacyPersistedCodexThreadDraftState = PersistedComposerThreadDraftState & LegacyCodexFields; +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 LegacyV2ThreadDraftFields = { + modelSelection?: ModelSelection | null; + modelOptions?: ProviderModelOptions | null; +}; + +type LegacyPersistedComposerThreadDraftState = PersistedComposerThreadDraftState & + LegacyCodexFields & + LegacyThreadModelFields & + LegacyV2ThreadDraftFields; + +const LegacyStickyModelFields = Schema.Struct({ + stickyProvider: Schema.optionalKey(ProviderKind), + stickyModel: Schema.optionalKey(Schema.String), + stickyModelOptions: Schema.optionalKey(Schema.NullOr(ProviderModelOptions)), +}); +type LegacyStickyModelFields = typeof LegacyStickyModelFields.Type; + +type LegacyV2StoreFields = { + stickyModelSelection?: ModelSelection | null; + stickyModelOptions?: ProviderModelOptions | null; +}; + +type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState & + LegacyStickyModelFields & + LegacyV2StoreFields; const PersistedDraftThreadState = Schema.Struct({ projectId: ProjectId, @@ -105,8 +144,10 @@ 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, + stickyModelSelectionByProvider: Schema.optionalKey( + Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)), + ), + stickyActiveProvider: Schema.optionalKey(Schema.NullOr(ProviderKind)), }); type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type; @@ -115,15 +156,14 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({ state: PersistedComposerDraftStoreState, }); -interface ComposerThreadDraftState { +export interface ComposerThreadDraftState { prompt: string; images: ComposerImageAttachment[]; nonPersistedImageIds: string[]; persistedAttachments: PersistedComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; - provider: ProviderKind | null; - model: string | null; - modelOptions: ProviderModelOptions | null; + modelSelectionByProvider: Partial>; + activeProvider: ProviderKind | null; runtimeMode: RuntimeMode | null; interactionMode: ProviderInteractionMode | null; } @@ -146,8 +186,8 @@ interface ComposerDraftStoreState { draftsByThreadId: Record; draftThreadsByThreadId: Record; projectDraftThreadIdByProjectId: Record; - stickyModel: string | null; - stickyModelOptions: ProviderModelOptions; + stickyModelSelectionByProvider: Partial>; + stickyActiveProvider: ProviderKind | null; getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null; getDraftThread: (threadId: ThreadId) => DraftThreadState | null; setProjectDraftThreadId: ( @@ -177,16 +217,18 @@ 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; + setModelSelection: ( + threadId: ThreadId, + modelSelection: ModelSelection | null | undefined, + ) => void; setModelOptions: ( threadId: ThreadId, modelOptions: ProviderModelOptions | null | undefined, ) => void; + applyStickyState: (threadId: ThreadId) => void; setProviderModelOptions: ( threadId: ThreadId, provider: ProviderKind, @@ -221,14 +263,42 @@ interface ComposerDraftStoreState { clearComposerContent: (threadId: ThreadId) => void; } -const EMPTY_PROVIDER_MODEL_OPTIONS = Object.freeze({}); +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, + }; +} + +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: {}, - stickyModel: null, - stickyModelOptions: EMPTY_PROVIDER_MODEL_OPTIONS, + stickyModelSelectionByProvider: {}, + stickyActiveProvider: null, }); const EMPTY_IMAGES: ComposerImageAttachment[] = []; @@ -238,15 +308,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, - provider: null, - model: null, - modelOptions: null, + modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER, + activeProvider: null, runtimeMode: null, interactionMode: null, }); @@ -258,9 +330,8 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], - provider: null, - model: null, - modelOptions: null, + modelSelectionByProvider: {}, + activeProvider: null, runtimeMode: null, interactionMode: null, }; @@ -329,9 +400,8 @@ 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 && + Object.keys(draft.modelSelectionByProvider).length === 0 && + draft.activeProvider === null && draft.runtimeMode === null && draft.interactionMode === null ); @@ -370,20 +440,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" || @@ -392,17 +470,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; @@ -415,7 +494,73 @@ function normalizeProviderModelOptions( }; } -function replaceProviderModelOptions( +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 } : {}), + }; +} + +// ── Legacy sync helpers (used only during migration from v2 storage) ── + +function legacySyncModelSelectionOptions( + 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 legacyMergeModelSelectionIntoProviderModelOptions( + modelSelection: ModelSelection | null, + currentModelOptions: ProviderModelOptions | null | undefined, +): ProviderModelOptions | null { + if (modelSelection?.options === undefined) { + return normalizeProviderModelOptions(currentModelOptions); + } + return legacyReplaceProviderModelOptions( + normalizeProviderModelOptions(currentModelOptions), + modelSelection.provider, + modelSelection.options, + ); +} + +function legacyReplaceProviderModelOptions( currentModelOptions: ProviderModelOptions | null | undefined, provider: ProviderKind, nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined, @@ -433,6 +578,72 @@ 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; + 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 activeSelection = input.draft?.modelSelectionByProvider?.[input.selectedProvider]; + const selectedModel = activeSelection?.model + ? resolveAppModelSelection( + input.selectedProvider, + input.customModelsByProvider, + activeSelection.model, + ) + : baseModel; + const modelOptions = + modelSelectionByProviderToOptions(input.draft?.modelSelectionByProvider) ?? + providerModelOptionsFromSelection(input.threadModelSelection) ?? + providerModelOptionsFromSelection(input.projectModelSelection) ?? + null; + + return { + selectedModel, + modelOptions, + }; +} + function revokeObjectPreviewUrl(previewUrl: string): void { if (typeof URL === "undefined") { return; @@ -619,10 +830,6 @@ function normalizePersistedDraftThreads( function normalizePersistedDraftsByThreadId( rawDraftMap: unknown, - resolveModelOptions: ( - draftCandidate: PersistedComposerThreadDraftState | LegacyPersistedCodexThreadDraftState, - provider: ProviderKind | null, - ) => ProviderModelOptions | null, ): PersistedComposerDraftStoreState["draftsByThreadId"] { if (!rawDraftMap || typeof rawDraftMap !== "object") { return {}; @@ -637,9 +844,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) => { @@ -653,11 +858,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" @@ -667,18 +867,63 @@ function normalizePersistedDraftsByThreadId( draftCandidate.interactionMode === "plan" || draftCandidate.interactionMode === "default" ? draftCandidate.interactionMode : null; - const modelOptions = resolveModelOptions(draftCandidate, provider); const prompt = ensureInlineTerminalContextPlaceholders( promptCandidate, terminalContexts.length, ); + // If the draft already has the v3 shape, use it directly + const legacyDraftCandidate = draftValue as LegacyPersistedComposerThreadDraftState; + 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 && - !provider && - !model && - modelOptions === null && + !hasModelData && !runtimeMode && !interactionMode ) { @@ -688,9 +933,7 @@ function normalizePersistedDraftsByThreadId( prompt, attachments, ...(terminalContexts.length > 0 ? { terminalContexts } : {}), - ...(provider ? { provider } : {}), - ...(model ? { model } : {}), - ...(modelOptions ? { modelOptions } : {}), + ...(hasModelData ? { modelSelectionByProvider, activeProvider } : {}), ...(runtimeMode ? { runtimeMode } : {}), ...(interactionMode ? { interactionMode } : {}), }; @@ -701,40 +944,45 @@ function normalizePersistedDraftsByThreadId( function migratePersistedComposerDraftStoreState( persistedState: unknown, - persistedVersion: number, ): PersistedComposerDraftStoreState { 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; - const stickyModel = - typeof candidate.stickyModel === "string" - ? (normalizeModelSlug(candidate.stickyModel, "codex") ?? null) - : null; - const stickyModelOptions = - normalizeProviderModelOptions(candidate.stickyModelOptions) ?? EMPTY_PROVIDER_MODEL_OPTIONS; + + // 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 = legacyMergeModelSelectionIntoProviderModelOptions( + normalizedStickyModelSelection, + stickyModelOptions, + ); + const stickyModelSelection = legacySyncModelSelectionOptions( + normalizedStickyModelSelection, + nextStickyModelOptions, + ); + const stickyModelSelectionByProvider = legacyToModelSelectionByProvider( + stickyModelSelection, + nextStickyModelOptions, + ); + const stickyActiveProvider = normalizeProviderKind(candidate.stickyProvider) ?? null; + 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, + stickyModelSelectionByProvider, + stickyActiveProvider, }; } @@ -748,13 +996,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.provider === null && - draft.model === null && - draft.modelOptions === null && + !hasModelData && draft.runtimeMode === null && draft.interactionMode === null ) { @@ -776,9 +1024,12 @@ function partializeComposerDraftStoreState( })), } : {}), - ...(draft.model ? { model: draft.model } : {}), - ...(draft.modelOptions ? { modelOptions: draft.modelOptions } : {}), - ...(draft.provider ? { provider: draft.provider } : {}), + ...(hasModelData + ? { + modelSelectionByProvider: draft.modelSelectionByProvider, + activeProvider: draft.activeProvider, + } + : {}), ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}), ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}), }; @@ -788,8 +1039,8 @@ function partializeComposerDraftStoreState( draftsByThreadId: persistedDraftsByThreadId, draftThreadsByThreadId: state.draftThreadsByThreadId, projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId, - stickyModel: state.stickyModel, - stickyModelOptions: state.stickyModelOptions, + stickyModelSelectionByProvider: state.stickyModelSelectionByProvider, + stickyActiveProvider: state.stickyActiveProvider, }; } @@ -799,29 +1050,58 @@ 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, normalizedPersistedState.projectDraftThreadIdByProjectId, ); - const stickyModel = - typeof normalizedPersistedState.stickyModel === "string" - ? (normalizeModelSlug(normalizedPersistedState.stickyModel, "codex") ?? null) - : null; - const stickyModelOptions = - normalizeProviderModelOptions(normalizedPersistedState.stickyModelOptions) ?? - EMPTY_PROVIDER_MODEL_OPTIONS; + + // Handle both v3 (modelSelectionByProvider) and v2/legacy formats + let stickyModelSelectionByProvider: Partial> = {}; + let stickyActiveProvider: ProviderKind | null = null; + if ( + normalizedPersistedState.stickyModelSelectionByProvider && + typeof normalizedPersistedState.stickyModelSelectionByProvider === "object" + ) { + stickyModelSelectionByProvider = + normalizedPersistedState.stickyModelSelectionByProvider as Partial< + Record + >; + stickyActiveProvider = normalizeProviderKind(normalizedPersistedState.stickyActiveProvider); + } 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, + ); + stickyActiveProvider = normalizeProviderKind(normalizedPersistedState.stickyProvider); + } + return { - draftsByThreadId: normalizePersistedDraftsByThreadId( - normalizedPersistedState.draftsByThreadId, - (draftCandidate, provider) => - normalizeProviderModelOptions(draftCandidate.modelOptions, provider), - ), + draftsByThreadId: normalizePersistedDraftsByThreadId(normalizedPersistedState.draftsByThreadId), draftThreadsByThreadId, projectDraftThreadIdByProjectId, - stickyModel, - stickyModelOptions, + stickyModelSelectionByProvider, + stickyActiveProvider, }; } @@ -948,6 +1228,11 @@ function hydrateImagesFromPersisted( function toHydratedThreadDraft( persistedDraft: PersistedComposerThreadDraftState, ): ComposerThreadDraftState { + // 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), @@ -958,9 +1243,8 @@ function toHydratedThreadDraft( ...context, text: "", })) ?? [], - provider: persistedDraft.provider ?? null, - model: persistedDraft.model ?? null, - modelOptions: persistedDraft.modelOptions ?? null, + modelSelectionByProvider, + activeProvider, runtimeMode: persistedDraft.runtimeMode ?? null, interactionMode: persistedDraft.interactionMode ?? null, }; @@ -972,8 +1256,8 @@ export const useComposerDraftStore = create()( draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, - stickyModel: null, - stickyModelOptions: EMPTY_PROVIDER_MODEL_OPTIONS, + stickyModelSelectionByProvider: {}, + stickyActiveProvider: null, getDraftThreadByProjectId: (projectId) => { if (projectId.length === 0) { return null; @@ -1219,27 +1503,67 @@ export const useComposerDraftStore = create()( }; }); }, - setStickyModel: (model) => { - const normalizedModel = normalizeModelSlug(model, "codex") ?? null; + setStickyModelSelection: (modelSelection) => { + const normalized = normalizeModelSelection(modelSelection); set((state) => { - if (state.stickyModel === normalizedModel) { + if (!normalized) { return state; } + const nextMap: Partial> = { + ...state.stickyModelSelectionByProvider, + [normalized.provider]: normalized, + }; + if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) { + return state.stickyActiveProvider === normalized.provider + ? state + : { stickyActiveProvider: normalized.provider }; + } return { - stickyModel: normalizedModel, + stickyModelSelectionByProvider: nextMap, + stickyActiveProvider: normalized.provider, }; }); }, - setStickyModelOptions: (modelOptions) => { - const normalizedModelOptions = - normalizeProviderModelOptions(modelOptions) ?? EMPTY_PROVIDER_MODEL_OPTIONS; + applyStickyState: (threadId) => { + if (threadId.length === 0) { + return; + } set((state) => { - if (Equal.equals(state.stickyModelOptions, normalizedModelOptions)) { + const stickyMap = state.stickyModelSelectionByProvider; + const stickyActiveProvider = state.stickyActiveProvider; + if (Object.keys(stickyMap).length === 0 && stickyActiveProvider === null) { return state; } - return { - 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) && + base.activeProvider === stickyActiveProvider + ) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...base, + modelSelectionByProvider: nextMap, + activeProvider: stickyActiveProvider, }; + const nextDraftsByThreadId = { ...state.draftsByThreadId }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadId[threadId]; + } else { + nextDraftsByThreadId[threadId] = nextDraft; + } + return { draftsByThreadId: nextDraftsByThreadId }; }); }, setPrompt: (threadId, prompt) => { @@ -1285,50 +1609,43 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId }; }); }, - setProvider: (threadId, provider) => { + setModelSelection: (threadId, modelSelection) => { if (threadId.length === 0) { return; } - const normalizedProvider = normalizeProviderKind(provider); + const normalized = normalizeModelSelection(modelSelection); set((state) => { const existing = state.draftsByThreadId[threadId]; - if (!existing && normalizedProvider === null) { + if (!existing && normalized === 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) => { - if (threadId.length === 0) { - return; - } - set((state) => { - const existing = state.draftsByThreadId[threadId]; - const normalizedModel = normalizeModelSlug(model, existing?.provider ?? "codex") ?? null; - if (!existing && normalizedModel === null) { - return state; + 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 base = existing ?? createEmptyThreadDraft(); - if (base.model === normalizedModel) { + const nextActiveProvider = normalized?.provider ?? base.activeProvider; + if ( + Equal.equals(base.modelSelectionByProvider, nextMap) && + base.activeProvider === nextActiveProvider + ) { return state; } const nextDraft: ComposerThreadDraftState = { ...base, - model: normalizedModel, + modelSelectionByProvider: nextMap, + activeProvider: nextActiveProvider, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; if (shouldRemoveDraft(nextDraft)) { @@ -1343,20 +1660,37 @@ export const useComposerDraftStore = create()( if (threadId.length === 0) { return; } + const normalizedOpts = normalizeProviderModelOptions(modelOptions); set((state) => { const existing = state.draftsByThreadId[threadId]; - const provider = existing?.provider ?? null; - const nextModelOptions = normalizeProviderModelOptions(modelOptions, provider); - if (!existing && nextModelOptions === null) { + if (!existing && normalizedOpts === null) { return state; } const base = existing ?? createEmptyThreadDraft(); - if (Equal.equals(base.modelOptions, nextModelOptions)) { + const nextMap = { ...base.modelSelectionByProvider }; + for (const provider of ["codex", "claudeAgent"] as const) { + // 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] = { + 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, - modelOptions: nextModelOptions, + modelSelectionByProvider: nextMap, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; if (shouldRemoveDraft(nextDraft)) { @@ -1375,29 +1709,67 @@ 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 nextStickyModelOptions = - options?.persistSticky === true - ? (nextModelOptions ?? EMPTY_PROVIDER_MODEL_OPTIONS) - : state.stickyModelOptions; + + // 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; + let nextStickyActiveProvider = state.stickyActiveProvider; + 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; + } + nextStickyActiveProvider = base.activeProvider ?? normalizedProvider; + } if ( - Equal.equals(base.modelOptions, nextModelOptions) && - Equal.equals(state.stickyModelOptions, nextStickyModelOptions) + Equal.equals(base.modelSelectionByProvider, nextMap) && + Equal.equals(state.stickyModelSelectionByProvider, nextStickyMap) && + state.stickyActiveProvider === nextStickyActiveProvider ) { return state; } const nextDraft: ComposerThreadDraftState = { ...base, - modelOptions: nextModelOptions, + modelSelectionByProvider: nextMap, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; if (shouldRemoveDraft(nextDraft)) { @@ -1409,7 +1781,10 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId, ...(options?.persistSticky === true - ? { stickyModelOptions: nextStickyModelOptions } + ? { + stickyModelSelectionByProvider: nextStickyMap, + stickyActiveProvider: nextStickyActiveProvider, + } : {}), }; }); @@ -1768,8 +2143,8 @@ export const useComposerDraftStore = create()( draftsByThreadId, draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId, projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId, - stickyModel: normalizedPersisted.stickyModel, - stickyModelOptions: normalizedPersisted.stickyModelOptions, + stickyModelSelectionByProvider: normalizedPersisted.stickyModelSelectionByProvider ?? {}, + stickyActiveProvider: normalizedPersisted.stickyActiveProvider ?? null, }; }, }, @@ -1780,6 +2155,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. * diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index e31809cdd2..ffb4b5cf67 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,6 @@ 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 navigate = useNavigate(); const routeThreadId = useParams({ strict: false, @@ -41,9 +38,7 @@ export function useHandleNewThread() { clearProjectDraftThreadId, getDraftThread, getDraftThreadByProjectId, - setModel, - setModelOptions, - setProvider, + applyStickyState, setDraftThreadContext, setProjectDraftThreadId, } = useComposerDraftStore.getState(); @@ -102,13 +97,7 @@ 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); - } + applyStickyState(threadId); await navigate({ to: "/$threadId", @@ -116,7 +105,7 @@ export function useHandleNewThread() { }); })(); }, - [navigate, routeThreadId, stickyModel, stickyModelOptions], + [navigate, routeThreadId], ); return { diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index f2d5a5be4c..4da4f23c8c 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 project and thread updatedAt timestamps from the read model", () => { @@ -250,7 +280,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: [], }, @@ -258,7 +291,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 9784aa972b..4590b2886d 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,16 +1,11 @@ import { Fragment, type ReactNode, createElement, useEffect } from "react"; import { - DEFAULT_MODEL_BY_PROVIDER, 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 +132,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 @@ -196,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?.(); @@ -255,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 diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 03e6665c0c..de06f95538 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; createdAt?: string | undefined; updatedAt?: string | undefined; @@ -92,7 +93,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 62ea0d101b..e500b57791 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -269,7 +269,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", @@ -311,7 +311,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/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/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 6fdff4e886..19cef5a392 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,52 @@ 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 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"); }), ); @@ -116,7 +160,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 +178,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 +197,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 +209,21 @@ 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 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"); }), ); @@ -174,19 +239,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 +289,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); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 3208adc8bb..333d5ca1eb 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 { ClaudeModelOptions, CodexModelOptions } from "./model"; import { ApprovalRequestId, CheckpointRef, @@ -44,6 +44,23 @@ 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; + export const CodexProviderStartOptions = Schema.Struct({ binaryPath: Schema.optional(TrimmedNonEmptyString), homePath: Schema.optional(TrimmedNonEmptyString), @@ -144,7 +161,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 +290,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 +323,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 +333,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 +349,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 +370,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 +401,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 +422,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, @@ -610,7 +623,7 @@ export const ProjectCreatedPayload = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, - defaultModel: Schema.NullOr(TrimmedNonEmptyString), + defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -620,7 +633,7 @@ export const ProjectMetaUpdatedPayload = 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, }); @@ -634,7 +647,7 @@ export const ThreadCreatedPayload = 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), @@ -653,7 +666,7 @@ export const ThreadDeletedPayload = Schema.Struct({ export const ThreadMetaUpdatedPayload = 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, @@ -688,9 +701,7 @@ export const ThreadMessageSentPayload = Schema.Struct({ export const ThreadTurnStartRequestedPayload = 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)), 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 2c8aaf1986..d62a273c2c 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -2,33 +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, - inferProviderForModel, isClaudeUltrathinkPrompt, normalizeClaudeModelOptions, normalizeCodexModelOptions, normalizeModelSlug, - resolveReasoningEffortForProvider, resolveSelectableModel, resolveModelSlug, resolveModelSlugForProvider, - supportsClaudeAdaptiveReasoning, - supportsClaudeFastMode, - supportsClaudeMaxEffort, - supportsClaudeThinkingToggle, - supportsClaudeUltrathinkKeyword, + getDefaultEffort, + hasEffortLevel, } from "./model"; describe("normalizeModelSlug", () => { @@ -159,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", @@ -175,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", @@ -184,45 +179,37 @@ describe("getReasoningEffortOptions", () => { }); it("returns no claude effort options for Haiku 4.5", () => { - expect(getReasoningEffortOptions("claudeAgent", "claude-haiku-4-5")).toEqual([]); - }); -}); - -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"); + expect(values("claudeAgent", "claude-haiku-4-5")).toEqual([]); }); - it("treats claude-prefixed custom slugs as claude", () => { - expect(inferProviderForModel("claude-custom-internal")).toBe("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("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, - ); +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("resolveReasoningEffortForProvider", () => { - it("accepts provider-scoped effort values", () => { - expect(resolveReasoningEffortForProvider("codex", "xhigh")).toBe("xhigh"); - expect(resolveReasoningEffortForProvider("claudeAgent", "ultrathink")).toBe("ultrathink"); - }); +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); }); }); @@ -241,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, }); @@ -290,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 2d46320753..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,102 +138,20 @@ 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", - 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 } : {}), @@ -244,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 } : {}), @@ -282,5 +202,3 @@ export function applyClaudePromptEffortPrefix( } return `Ultrathink:\n${trimmed}`; } - -export { CLAUDE_CODE_EFFORT_OPTIONS, CODEX_REASONING_EFFORT_OPTIONS };