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..d45b5cf951 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -2,8 +2,8 @@ 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 { 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,41 @@ 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")), + Effect.map( + Option.map((row) => ({ + projectId: row.projectId, + title: row.title, + workspaceRoot: row.workspaceRoot, + defaultModelSelection: row.defaultModelSelection, + scripts: row.scripts, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + })), ), ); const listAll: ProjectionProjectRepositoryShape["listAll"] = () => listProjectionProjectRows().pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionProjectRepository.listAll:query", - "ProjectionProjectRepository.listAll:decodeRows", - ), + Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.listAll:query")), + Effect.map((rows) => + rows.map((row) => ({ + projectId: row.projectId, + title: row.title, + workspaceRoot: row.workspaceRoot, + defaultModelSelection: row.defaultModelSelection, + scripts: row.scripts, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + })), ), - Effect.map((rows) => rows as ReadonlyArray>), ); 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..b44846d136 --- /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 SQL NULL for missing thread model options", () => + Effect.gen(function* () { + const threads = yield* ProjectionThreadRepository; + const sql = yield* SqlClient.SqlClient; + + yield* threads.upsert({ + threadId: ThreadId.makeUnsafe("thread-null-options"), + projectId: ProjectId.makeUnsafe("project-null-options"), + title: "Null options thread", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurnId: null, + createdAt: "2026-03-24T00:00:00.000Z", + updatedAt: "2026-03-24T00:00:00.000Z", + deletedAt: null, + }); + + const rows = yield* sql<{ + readonly 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..43c4e49f6f 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -10,6 +10,7 @@ import * as Migrator from "effect/unstable/sql/Migrator"; import * as Layer from "effect/Layer"; +import * as Effect from "effect/Effect"; // Import all migrations statically import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; @@ -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,13 @@ const run = Migrator.make({}); * * @returns Effect containing array of executed migrations */ -export const runMigrations = Effect.gen(function* () { - yield* Effect.log("Running migrations..."); - yield* run({ loader }); - yield* Effect.log("Migrations ran successfully"); -}); +export const runMigrations = ({ toMigrationInclusive }: RunMigrationsOptions = {}) => + Effect.gen(function* () { + yield* Effect.log(`Running migrations 1 through ${toMigrationInclusive}...`); + const executedMigrations = yield* run({ loader: makeMigrationLoader(toMigrationInclusive) }); + yield* Effect.log("Migrations ran successfully"); + return executedMigrations; + }); /** * Layer that runs migrations when the layer is built. @@ -96,4 +113,4 @@ export const runMigrations = Effect.gen(function* () { * ) * ``` */ -export const MigrationsLive = Layer.effectDiscard(runMigrations); +export const MigrationsLive = Layer.effectDiscard(runMigrations()); diff --git a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts new file mode 100644 index 0000000000..954e4f014c --- /dev/null +++ b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts @@ -0,0 +1,264 @@ +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-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-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"}', + '{}' + ) + `; + } + + // 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), { + threadId: "thread-1", + projectId: "project-1", + title: "Thread", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { + effort: "max", + thinking: false, + }, + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + + assert.deepStrictEqual(JSON.parse(eventRows[2]!.payloadJson), { + threadId: "thread-1", + turnId: "turn-1", + input: "hi", + modelSelection: { + provider: "codex", + model: "gpt-5.4", + options: { + fastMode: true, + }, + }, + deliveryMode: "buffered", + }); + } + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.ts b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.ts new file mode 100644 index 0000000000..8154bfde9b --- /dev/null +++ b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.ts @@ -0,0 +1,186 @@ +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 json_object( + 'options', + json(json_extract(payload_json, '$.defaultModelOptions.claudeAgent')) + ) + ELSE json_object( + 'options', + json(json_extract(payload_json, '$.defaultModelOptions.codex')) + ) + END + ELSE json_object( + 'options', + json(json_extract(payload_json, '$.defaultModelOptions')) + ) + END + ) + ), + '$.defaultProvider', + '$.defaultModel', + '$.defaultModelOptions' + ) + END + WHERE event_type IN ('project.created', 'project.meta-updated') + AND json_type(payload_json, '$.defaultModelSelection') IS NULL + AND json_type(payload_json, '$.defaultModel') IS NOT NULL + `; + + yield* sql` + UPDATE orchestration_events + SET payload_json = json_remove( + json_set( + payload_json, + '$.modelSelection', + json_patch( + json_object( + 'provider', + CASE + WHEN json_extract(payload_json, '$.provider') IS NOT NULL + THEN json_extract(payload_json, '$.provider') + WHEN lower(json_extract(payload_json, '$.model')) LIKE '%claude%' + THEN 'claudeAgent' + ELSE 'codex' + END, + 'model', + json_extract(payload_json, '$.model') + ), + CASE + WHEN json_type(payload_json, '$.modelOptions') IS NULL THEN '{}' + WHEN json_type(payload_json, '$.modelOptions.codex') IS NOT NULL + OR json_type(payload_json, '$.modelOptions.claudeAgent') IS NOT NULL + THEN CASE + WHEN ( + CASE + WHEN json_extract(payload_json, '$.provider') IS NOT NULL + THEN json_extract(payload_json, '$.provider') + WHEN lower(json_extract(payload_json, '$.model')) LIKE '%claude%' + THEN 'claudeAgent' + ELSE 'codex' + END + ) = 'claudeAgent' + THEN json_object( + 'options', + json(json_extract(payload_json, '$.modelOptions.claudeAgent')) + ) + ELSE json_object( + 'options', + json(json_extract(payload_json, '$.modelOptions.codex')) + ) + END + ELSE json_object('options', json(json_extract(payload_json, '$.modelOptions'))) + END + ) + ), + '$.provider', + '$.model', + '$.modelOptions' + ) + WHERE event_type IN ('thread.created', 'thread.meta-updated', 'thread.turn-start-requested') + AND json_type(payload_json, '$.modelSelection') IS NULL + AND json_type(payload_json, '$.model') IS NOT NULL + `; +}); 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..4c5ee29e51 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -514,11 +514,17 @@ const CLAUDE_SETTING_SOURCES = [ function buildPromptText(input: ProviderSendTurnInput): string { const requestedEffort = resolveReasoningEffortForProvider( "claudeAgent", - input.modelOptions?.claudeAgent?.effort ?? null, + input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null, + ); + const supportedEffortOptions = getReasoningEffortOptions( + "claudeAgent", + input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined, ); - const supportedEffortOptions = getReasoningEffortOptions("claudeAgent", input.model); const promptEffort = - requestedEffort === "ultrathink" && supportsClaudeUltrathinkKeyword(input.model) + requestedEffort === "ultrathink" && + supportsClaudeUltrathinkKeyword( + input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined, + ) ? "ultrathink" : requestedEffort && supportedEffortOptions.includes(requestedEffort) ? requestedEffort @@ -2698,21 +2704,27 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { ); const providerOptions = input.providerOptions?.claudeAgent; + const modelSelection = + input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; const requestedEffort = resolveReasoningEffortForProvider( "claudeAgent", - input.modelOptions?.claudeAgent?.effort ?? null, + modelSelection?.options?.effort ?? null, + ); + const supportedEffortOptions = getReasoningEffortOptions( + "claudeAgent", + modelSelection?.model, ); - const supportedEffortOptions = getReasoningEffortOptions("claudeAgent", input.model); const effort = requestedEffort && supportedEffortOptions.includes(requestedEffort) ? requestedEffort : null; const fastMode = - input.modelOptions?.claudeAgent?.fastMode === true && supportsClaudeFastMode(input.model); + modelSelection?.options?.fastMode === true && + supportsClaudeFastMode(modelSelection?.model); const thinking = - typeof input.modelOptions?.claudeAgent?.thinking === "boolean" && - supportsClaudeThinkingToggle(input.model) - ? input.modelOptions.claudeAgent.thinking + typeof modelSelection?.options?.thinking === "boolean" && + supportsClaudeThinkingToggle(modelSelection?.model) + ? modelSelection.options.thinking : undefined; const effectiveEffort = getEffectiveClaudeCodeEffort(effort); const permissionMode = @@ -2725,7 +2737,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 +2778,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 } : {}), @@ -2821,7 +2833,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 +2879,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 +2888,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 +2940,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..13671016c5 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -221,7 +221,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 +236,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 +293,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", @@ -817,7 +826,7 @@ describe("ChatView timeline estimator parity (full app)", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, - stickyModel: null, + stickyModelSelection: null, stickyModelOptions: {}, }); useStore.setState({ @@ -1483,7 +1492,14 @@ 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", + stickyModelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + options: { + reasoningEffort: "medium", + fastMode: true, + }, + }, stickyModelOptions: { codex: { reasoningEffort: "medium", @@ -1514,10 +1530,10 @@ describe("ChatView timeline estimator parity (full app)", () => { const newThreadId = newThreadPath.slice(1) as ThreadId; expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ - model: "gpt-5.3-codex", - provider: "codex", - modelOptions: { - codex: { + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + options: { fastMode: true, }, }, @@ -1529,7 +1545,14 @@ describe("ChatView timeline estimator parity (full app)", () => { it("hydrates the provider alongside a sticky claude model", async () => { useComposerDraftStore.setState({ - stickyModel: "claude-opus-4-6", + stickyModelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { + effort: "max", + fastMode: true, + }, + }, stickyModelOptions: { claudeAgent: { effort: "max", @@ -1560,10 +1583,10 @@ describe("ChatView timeline estimator parity (full app)", () => { const newThreadId = newThreadPath.slice(1) as ThreadId; expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ - provider: "claudeAgent", - model: "claude-opus-4-6", - modelOptions: { - claudeAgent: { + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { effort: "max", fastMode: true, }, @@ -1605,7 +1628,14 @@ 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", + stickyModelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + options: { + reasoningEffort: "medium", + fastMode: true, + }, + }, stickyModelOptions: { codex: { reasoningEffort: "medium", @@ -1636,17 +1666,19 @@ describe("ChatView timeline estimator parity (full app)", () => { const threadId = threadPath.slice(1) as ThreadId; expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ - model: "gpt-5.3-codex", - modelOptions: { - codex: { + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + options: { fastMode: true, }, }, }); - useComposerDraftStore.getState().setModel(threadId, "gpt-5.4"); - useComposerDraftStore.getState().setModelOptions(threadId, { - codex: { + useComposerDraftStore.getState().setModelSelection(threadId, { + provider: "codex", + model: "gpt-5.4", + options: { reasoningEffort: "low", fastMode: true, }, @@ -1660,9 +1692,10 @@ describe("ChatView timeline estimator parity (full app)", () => { "New-thread should reuse the existing project draft thread.", ); expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ - model: "gpt-5.4", - modelOptions: { - codex: { + modelSelection: { + provider: "codex", + model: "gpt-5.4", + options: { reasoningEffort: "low", fastMode: true, }, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index ddc84718e6..0a9f242ed0 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,4 +1,4 @@ -import { ProjectId, type ThreadId } from "@t3tools/contracts"; +import { ProjectId, type ModelSelection, type ThreadId } from "@t3tools/contracts"; import { type ChatMessage, type Thread } from "../types"; import { randomUUID } from "~/lib/utils"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; @@ -17,7 +17,7 @@ export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema. export function buildLocalDraftThread( threadId: ThreadId, draftThread: DraftThreadState, - fallbackModel: string, + fallbackModelSelection: ModelSelection, error: string | null, ): Thread { return { @@ -25,7 +25,7 @@ export function buildLocalDraftThread( codexThreadId: null, projectId: draftThread.projectId, title: "New thread", - model: fallbackModel, + modelSelection: fallbackModelSelection, runtimeMode: draftThread.runtimeMode, interactionMode: draftThread.interactionMode, session: null, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 59ff4f73c8..f045a8c6ff 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, @@ -21,12 +22,7 @@ import { ProviderInteractionMode, RuntimeMode, } from "@t3tools/contracts"; -import { - applyClaudePromptEffortPrefix, - getDefaultModel, - normalizeModelSlug, - resolveModelSlugForProvider, -} from "@t3tools/shared/model"; +import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; @@ -133,6 +129,7 @@ import { type DraftThreadEnvMode, type PersistedComposerImageAttachment, useComposerDraftStore, + useEffectiveComposerModelState, useComposerThreadDraft, } from "../composerDraftStore"; import { @@ -248,7 +245,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const { settings } = useAppSettings(); - const setStickyComposerModel = useComposerDraftStore((store) => store.setStickyModel); + const setStickyComposerModelSelection = useComposerDraftStore( + (store) => store.setStickyModelSelection, + ); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); const rawSearch = useSearch({ @@ -273,8 +272,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 +465,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 +591,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ]); const sessionProvider = activeThread?.session?.provider ?? null; - const selectedProviderByThreadId = composerDraft.provider; + const selectedProviderByThreadId = composerDraft.modelSelection?.provider ?? null; + const threadProvider = + activeThread?.modelSelection.provider ?? activeProject?.defaultModelSelection?.provider ?? null; const hasThreadStarted = Boolean( activeThread && (activeThread.latestTurn !== null || @@ -598,34 +601,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 +1601,7 @@ export default function ChatView({ threadId }: ChatViewProps) { async (input: { threadId: ThreadId; createdAt: string; - model?: string; + modelSelection?: ModelSelection; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; }) => { @@ -1606,12 +1613,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, }); } @@ -2542,8 +2555,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 +2571,7 @@ export default function ChatView({ threadId }: ChatViewProps) { threadId: threadIdForSend, projectId: activeProject.id, title, - model: threadCreateModel, + modelSelection: threadCreateModelSelection, runtimeMode, interactionMode, branch: nextThreadBranch, @@ -2601,7 +2620,7 @@ export default function ChatView({ threadId }: ChatViewProps) { await persistThreadSettingsForNextTurn({ threadId: threadIdForSend, createdAt: messageCreatedAt, - ...(selectedModel ? { model: selectedModel } : {}), + ...(selectedModel ? { modelSelection: selectedModelSelection } : {}), runtimeMode, interactionMode, }); @@ -2619,12 +2638,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, @@ -2885,7 +2900,7 @@ export default function ChatView({ threadId }: ChatViewProps) { await persistThreadSettingsForNextTurn({ threadId: threadIdForSend, createdAt: messageCreatedAt, - ...(selectedModel ? { model: selectedModel } : {}), + modelSelection: selectedModelSelection, runtimeMode, interactionMode: nextInteractionMode, }); @@ -2904,11 +2919,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,8 +2966,7 @@ export default function ChatView({ threadId }: ChatViewProps) { resetSendPhase, runtimeMode, selectedPromptEffort, - selectedModel, - selectedModelOptionsForDispatch, + selectedModelSelection, providerOptionsForDispatch, selectedProvider, setComposerDraftInteractionMode, @@ -2990,11 +3000,7 @@ export default function ChatView({ threadId }: ChatViewProps) { text: implementationPrompt, }); const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown)); - const nextThreadModel: ModelSlug = - selectedModel || - (activeThread.model as ModelSlug) || - (activeProject.model as ModelSlug) || - DEFAULT_MODEL_BY_PROVIDER.codex; + const nextThreadModelSelection: ModelSelection = selectedModelSelection; sendInFlightRef.current = true; beginSendPhase("sending-turn"); @@ -3010,7 +3016,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 +3034,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,8 +3086,7 @@ export default function ChatView({ threadId }: ChatViewProps) { resetSendPhase, runtimeMode, selectedPromptEffort, - selectedModel, - selectedModelOptionsForDispatch, + selectedModelSelection, providerOptionsForDispatch, selectedProvider, settings.enableAssistantStreaming, @@ -3100,18 +3101,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 +3138,16 @@ export default function ChatView({ threadId }: ChatViewProps) { provider: selectedProvider, threadId, model: selectedModel, + modelOptions: composerModelOptions?.[selectedProvider], + prompt, onPromptChange: setPromptFromTraits, }); const providerTraitsPicker = renderProviderTraitsPicker({ provider: selectedProvider, threadId, model: selectedModel, + modelOptions: composerModelOptions?.[selectedProvider], + prompt, onPromptChange: setPromptFromTraits, }); const onEnvModeChange = useCallback( diff --git a/apps/web/src/components/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..61c894deba 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -347,7 +347,11 @@ function makeProject(overrides: Partial = {}): Project { id: ProjectId.makeUnsafe("project-1"), name: "Project", cwd: "/tmp/project", - model: "gpt-5.4", + defaultModelSelection: { + provider: "codex", + model: "gpt-5.4", + ...overrides?.defaultModelSelection, + }, expanded: true, createdAt: "2026-03-09T10:00:00.000Z", updatedAt: "2026-03-09T10:00:00.000Z", @@ -362,7 +366,11 @@ function makeThread(overrides: Partial = {}): Thread { codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", - model: "gpt-5.4", + modelSelection: { + provider: "codex", + model: "gpt-5.4", + ...overrides?.modelSelection, + }, runtimeMode: DEFAULT_RUNTIME_MODE, interactionMode: DEFAULT_INTERACTION_MODE, session: null, 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 index a675a82d89..934dfbb2b8 100644 --- a/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx @@ -1,12 +1,50 @@ import "../../index.css"; -import { ThreadId } from "@t3tools/contracts"; +import { type ModelSelection, ThreadId } from "@t3tools/contracts"; import { page } from "vitest/browser"; +import { useCallback } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; import { ClaudeTraitsPicker } from "./ClaudeTraitsPicker"; -import { useComposerDraftStore } from "../../composerDraftStore"; +import { + useComposerDraftStore, + useComposerThreadDraft, + useEffectiveComposerModelState, +} from "../../composerDraftStore"; + +const THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits"); + +function ClaudeTraitsPickerHarness(props: { + model: string; + fallbackModelSelection: ModelSelection | null; +}) { + const prompt = useComposerThreadDraft(THREAD_ID).prompt; + const setPrompt = useComposerDraftStore((store) => store.setPrompt); + const { modelOptions, selectedModel } = useEffectiveComposerModelState({ + threadId: THREAD_ID, + selectedProvider: "claudeAgent", + threadModelSelection: props.fallbackModelSelection, + projectModelSelection: null, + customModelsByProvider: { codex: [], claudeAgent: [] }, + }); + const handlePromptChange = useCallback( + (nextPrompt: string) => { + setPrompt(THREAD_ID, nextPrompt); + }, + [setPrompt], + ); + + return ( + + ); +} async function mountPicker(props?: { model?: string; @@ -14,26 +52,45 @@ async function mountPicker(props?: { effort?: "low" | "medium" | "high" | "max" | "ultrathink" | null; thinkingEnabled?: boolean | null; fastModeEnabled?: boolean; + fallbackModelOptions?: { + effort?: "low" | "medium" | "high" | "max" | "ultrathink"; + thinking?: boolean; + fastMode?: boolean; + } | null; + skipDraftModelOptions?: boolean; }) { - const threadId = ThreadId.makeUnsafe("thread-claude-traits"); const draftsByThreadId = {} as ReturnType< typeof useComposerDraftStore.getState >["draftsByThreadId"]; - draftsByThreadId[threadId] = { + const model = props?.model ?? "claude-opus-4-6"; + draftsByThreadId[THREAD_ID] = { 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 } : {}), - }, + modelSelection: { + provider: "claudeAgent", + model, + ...(!props?.skipDraftModelOptions + ? { + options: { + ...(props?.effort ? { effort: props.effort } : {}), + ...(props?.thinkingEnabled === false ? { thinking: false } : {}), + ...(props?.fastModeEnabled ? { fastMode: true } : {}), + }, + } + : {}), }, + modelOptions: props?.skipDraftModelOptions + ? null + : { + claudeAgent: { + ...(props?.effort ? { effort: props.effort } : {}), + ...(props?.thinkingEnabled === false ? { thinking: false } : {}), + ...(props?.fastModeEnabled ? { fastMode: true } : {}), + }, + }, runtimeMode: null, interactionMode: null, }; @@ -44,18 +101,20 @@ async function mountPicker(props?: { }); const host = document.createElement("div"); document.body.append(host); - const onPromptChange = vi.fn(); + const fallbackModelSelection = + props?.fallbackModelOptions !== undefined + ? ({ + provider: "claudeAgent", + model, + options: props.fallbackModelOptions ?? undefined, + } satisfies ModelSelection) + : null; const screen = await render( - , + , { container: host }, ); return { - onPromptChange, cleanup: async () => { await screen.unmount(); host.remove(); @@ -70,6 +129,8 @@ describe("ClaudeTraitsPicker", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + stickyModelSelection: null, + stickyModelOptions: {}, }); }); @@ -185,8 +246,9 @@ describe("ClaudeTraitsPicker", () => { await page.getByRole("button").click(); await page.getByRole("menuitemradio", { name: "Max" }).click(); - expect(useComposerDraftStore.getState().stickyModelOptions).toMatchObject({ - claudeAgent: { + expect(useComposerDraftStore.getState().stickyModelSelection).toMatchObject({ + provider: "claudeAgent", + options: { effort: "max", }, }); @@ -194,4 +256,36 @@ describe("ClaudeTraitsPicker", () => { await mounted.cleanup(); } }); + + it("can turn inherited fast mode off without snapping back", async () => { + const mounted = await mountPicker({ + model: "claude-opus-4-6", + skipDraftModelOptions: true, + fallbackModelOptions: { + effort: "high", + fastMode: true, + }, + }); + + try { + const trigger = page.getByRole("button"); + + await expect.element(trigger).toHaveTextContent("High · Fast"); + await trigger.click(); + await page.getByRole("menuitemradio", { name: "off" }).click(); + + await vi.waitFor(() => { + expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]?.modelOptions).toEqual({ + claudeAgent: { + effort: "high", + fastMode: false, + }, + }); + }); + await expect.element(trigger).toHaveTextContent("High"); + await expect.element(trigger).not.toHaveTextContent("High · Fast"); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/chat/ClaudeTraitsPicker.tsx b/apps/web/src/components/chat/ClaudeTraitsPicker.tsx index d6585d43d8..55566e4fb0 100644 --- a/apps/web/src/components/chat/ClaudeTraitsPicker.tsx +++ b/apps/web/src/components/chat/ClaudeTraitsPicker.tsx @@ -8,7 +8,6 @@ import { applyClaudePromptEffortPrefix, getDefaultReasoningEffort, getReasoningEffortOptions, - normalizeClaudeModelOptions, resolveReasoningEffortForProvider, supportsClaudeFastMode, supportsClaudeThinkingToggle, @@ -27,7 +26,7 @@ import { MenuSeparator as MenuDivider, MenuTrigger, } from "../ui/menu"; -import { useComposerDraftStore, useComposerThreadDraft } from "../../composerDraftStore"; +import { useComposerDraftStore } from "../../composerDraftStore"; const PROVIDER = "claudeAgent" as const satisfies ProviderKind; @@ -83,17 +82,18 @@ function getSelectedClaudeTraits( interface ClaudeTraitsMenuContentProps { threadId: ThreadId; model: string | null | undefined; + prompt: string; onPromptChange: (prompt: string) => void; + modelOptions?: ClaudeModelOptions | null | undefined; } export const ClaudeTraitsMenuContent = memo(function ClaudeTraitsMenuContentImpl({ threadId, model, + prompt, onPromptChange, + modelOptions, }: ClaudeTraitsMenuContentProps) { - const draft = useComposerThreadDraft(threadId); - const prompt = draft.prompt; - const modelOptions = draft.modelOptions?.[PROVIDER]; const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); const { effort, @@ -122,16 +122,15 @@ export const ClaudeTraitsMenuContent = memo(function ClaudeTraitsMenuContentImpl setProviderModelOptions( threadId, PROVIDER, - normalizeClaudeModelOptions(model, { + { ...modelOptions, effort: nextEffort, - }), + }, { persistSticky: true }, ); }, [ ultrathinkPromptControlled, - model, modelOptions, onPromptChange, threadId, @@ -175,10 +174,10 @@ export const ClaudeTraitsMenuContent = memo(function ClaudeTraitsMenuContentImpl setProviderModelOptions( threadId, PROVIDER, - normalizeClaudeModelOptions(model, { + { ...modelOptions, thinking: value === "on", - }), + }, { persistSticky: true }, ); }} @@ -199,10 +198,10 @@ export const ClaudeTraitsMenuContent = memo(function ClaudeTraitsMenuContentImpl setProviderModelOptions( threadId, PROVIDER, - normalizeClaudeModelOptions(model, { + { ...modelOptions, fastMode: value === "on", - }), + }, { persistSticky: true }, ); }} @@ -220,12 +219,11 @@ export const ClaudeTraitsMenuContent = memo(function ClaudeTraitsMenuContentImpl export const ClaudeTraitsPicker = memo(function ClaudeTraitsPicker({ threadId, model, + prompt, onPromptChange, + modelOptions, }: ClaudeTraitsMenuContentProps) { const [isMenuOpen, setIsMenuOpen] = useState(false); - const draft = useComposerThreadDraft(threadId); - const prompt = draft.prompt; - const modelOptions = draft.modelOptions?.[PROVIDER]; const { effort, thinkingEnabled, fastModeEnabled, ultrathinkPromptControlled, supportsFastMode } = getSelectedClaudeTraits(model, prompt, modelOptions); const triggerLabel = [ @@ -264,7 +262,9 @@ export const ClaudeTraitsPicker = memo(function ClaudeTraitsPicker({ diff --git a/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx b/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx index 9d2b73989d..fc786b0f58 100644 --- a/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx @@ -1,6 +1,6 @@ import "../../index.css"; -import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { DEFAULT_MODEL_BY_PROVIDER, ProjectId, ThreadId } from "@t3tools/contracts"; import { page } from "vitest/browser"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -22,8 +22,14 @@ async function mountPicker(props: { nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], - provider: "codex", - model: null, + modelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER["codex"], + options: { + ...(props.reasoningEffort ? { reasoningEffort: props.reasoningEffort } : {}), + ...(props.fastModeEnabled ? { fastMode: true } : {}), + }, + }, modelOptions: { codex: { ...(props.reasoningEffort ? { reasoningEffort: props.reasoningEffort } : {}), @@ -42,7 +48,16 @@ async function mountPicker(props: { }); const host = document.createElement("div"); document.body.append(host); - const screen = await render(, { container: host }); + const screen = await render( + , + { container: host }, + ); return { cleanup: async () => { @@ -60,6 +75,7 @@ describe("CodexTraitsPicker", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + stickyModelOptions: {}, }); }); @@ -125,8 +141,9 @@ describe("CodexTraitsPicker", () => { await page.getByRole("button").click(); await page.getByRole("menuitemradio", { name: "on" }).click(); - expect(useComposerDraftStore.getState().stickyModelOptions).toMatchObject({ - codex: { + expect(useComposerDraftStore.getState().stickyModelSelection).toMatchObject({ + provider: "codex", + options: { fastMode: true, }, }); @@ -134,55 +151,4 @@ describe("CodexTraitsPicker", () => { await mounted.cleanup(); } }); - - it("hydrates legacy codex persisted state into modelOptions through the picker", async () => { - const threadId = ThreadId.makeUnsafe("thread-codex-legacy"); - localStorage.setItem( - COMPOSER_DRAFT_STORAGE_KEY, - JSON.stringify({ - state: { - draftsByThreadId: { - [threadId]: { - prompt: "", - attachments: [], - 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 index 7b37063bff..e9dfbf07ea 100644 --- a/apps/web/src/components/chat/CodexTraitsPicker.tsx +++ b/apps/web/src/components/chat/CodexTraitsPicker.tsx @@ -7,12 +7,11 @@ import type { import { getDefaultReasoningEffort, getReasoningEffortOptions, - normalizeCodexModelOptions, resolveReasoningEffortForProvider, } from "@t3tools/shared/model"; import { memo, useState } from "react"; import { ChevronDownIcon } from "lucide-react"; -import { useComposerDraftStore, useComposerThreadDraft } from "../../composerDraftStore"; +import { useComposerDraftStore } from "../../composerDraftStore"; import { Button } from "../ui/button"; import { Menu, @@ -46,9 +45,11 @@ function getSelectedCodexTraits(modelOptions: CodexModelOptions | null | undefin }; } -function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) { - const draft = useComposerThreadDraft(props.threadId); - const modelOptions = draft.modelOptions?.[PROVIDER]; +function CodexTraitsMenuContentImpl(props: { + threadId: ThreadId; + modelOptions?: CodexModelOptions | null | undefined; +}) { + const modelOptions = props.modelOptions; const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); const options = getReasoningEffortOptions(PROVIDER); const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER); @@ -67,10 +68,10 @@ function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) { setProviderModelOptions( props.threadId, PROVIDER, - normalizeCodexModelOptions({ + { ...modelOptions, reasoningEffort: nextEffort, - }), + }, { persistSticky: true }, ); }} @@ -92,10 +93,10 @@ function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) { setProviderModelOptions( props.threadId, PROVIDER, - normalizeCodexModelOptions({ + { ...modelOptions, fastMode: value === "on", - }), + }, { persistSticky: true }, ); }} @@ -110,9 +111,12 @@ function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) { export const CodexTraitsMenuContent = memo(CodexTraitsMenuContentImpl); -export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { threadId: ThreadId }) { +export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { + threadId: ThreadId; + modelOptions?: CodexModelOptions | null | undefined; +}) { const [isMenuOpen, setIsMenuOpen] = useState(false); - const modelOptions = useComposerThreadDraft(props.threadId).modelOptions?.codex; + const modelOptions = props.modelOptions; const { effort, fastModeEnabled } = getSelectedCodexTraits(modelOptions); const triggerLabel = [CODEX_REASONING_LABELS[effort], ...(fastModeEnabled ? ["Fast"] : [])] .filter(Boolean) @@ -140,7 +144,7 @@ export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { thread - + ); diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 83716d619a..eb961b5c4d 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -1,4 +1,4 @@ -import { type ProviderModelOptions, ThreadId } from "@t3tools/contracts"; +import { DEFAULT_MODEL_BY_PROVIDER, type ProviderModelOptions, ThreadId } from "@t3tools/contracts"; import "../../index.css"; import { page } from "vitest/browser"; @@ -27,8 +27,16 @@ async function mountMenu(props?: { nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], - provider, - model: props?.model ?? "claude-opus-4-6", + modelSelection: { + provider, + model: DEFAULT_MODEL_BY_PROVIDER["claudeAgent"], + ...(props?.modelOptions + ? { + options: + provider === "codex" ? props.modelOptions.codex : props.modelOptions.claudeAgent, + } + : {}), + }, modelOptions: props?.modelOptions ?? null, runtimeMode: null, interactionMode: null, @@ -49,11 +57,13 @@ async function mountMenu(props?: { runtimeMode="approval-required" traitsMenuContent={ provider === "codex" ? ( - + ) : ( ) @@ -80,6 +90,7 @@ describe("CompactComposerControlsMenu", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + stickyModelOptions: {}, }); }); diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index 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..1d6e5439ea 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -1,5 +1,7 @@ import { type ModelSlug, + type ClaudeModelOptions, + type CodexModelOptions, type ProviderKind, type ProviderModelOptions, type ThreadId, @@ -27,7 +29,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,11 +40,15 @@ type ProviderRegistryEntry = { renderTraitsMenuContent: (input: { threadId: ThreadId; model: ModelSlug; + modelOptions: ProviderModelOptions[ProviderKind] | undefined; + prompt: string; onPromptChange: (prompt: string) => void; }) => ReactNode; renderTraitsPicker: (input: { threadId: ThreadId; model: ModelSlug; + modelOptions: ProviderModelOptions[ProviderKind] | undefined; + prompt: string; onPromptChange: (prompt: string) => void; }) => ReactNode; }; @@ -58,13 +64,21 @@ const composerProviderRegistry: Record = { return { provider: "codex", promptEffort, - modelOptionsForDispatch: normalizedCodexOptions - ? { codex: normalizedCodexOptions } - : undefined, + modelOptionsForDispatch: normalizedCodexOptions, }; }, - renderTraitsMenuContent: ({ threadId }) => , - renderTraitsPicker: ({ threadId }) => , + renderTraitsMenuContent: ({ threadId, modelOptions }) => ( + + ), + renderTraitsPicker: ({ threadId, modelOptions }) => ( + + ), }, claudeAgent: { getState: ({ model, prompt, modelOptions }) => { @@ -87,9 +101,7 @@ const composerProviderRegistry: Record = { return { provider: "claudeAgent", promptEffort, - modelOptionsForDispatch: normalizedClaudeOptions - ? { claudeAgent: normalizedClaudeOptions } - : undefined, + modelOptionsForDispatch: normalizedClaudeOptions, ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame" } : {}), ...(ultrathinkActive ? { composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]" } @@ -97,11 +109,23 @@ const composerProviderRegistry: Record = { ...(ultrathinkActive ? { modelPickerIconClassName: "ultrathink-chroma" } : {}), }; }, - renderTraitsMenuContent: ({ threadId, model, onPromptChange }) => ( - + renderTraitsMenuContent: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( + ), - renderTraitsPicker: ({ threadId, model, onPromptChange }) => ( - + renderTraitsPicker: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( + ), }, }; @@ -114,11 +138,15 @@ export function renderProviderTraitsMenuContent(input: { provider: ProviderKind; threadId: ThreadId; model: ModelSlug; + modelOptions: ProviderModelOptions[ProviderKind] | undefined; + prompt: string; onPromptChange: (prompt: string) => void; }): ReactNode { return composerProviderRegistry[input.provider].renderTraitsMenuContent({ threadId: input.threadId, model: input.model, + modelOptions: input.modelOptions, + prompt: input.prompt, onPromptChange: input.onPromptChange, }); } @@ -127,11 +155,15 @@ export function renderProviderTraitsPicker(input: { provider: ProviderKind; threadId: ThreadId; model: ModelSlug; + modelOptions: ProviderModelOptions[ProviderKind] | undefined; + prompt: string; onPromptChange: (prompt: string) => void; }): ReactNode { return composerProviderRegistry[input.provider].renderTraitsPicker({ threadId: input.threadId, model: input.model, + modelOptions: input.modelOptions, + prompt: input.prompt, onPromptChange: input.onPromptChange, }); } diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index e449998d13..f3068d37a9 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, + stickyModelSelection: null, stickyModelOptions: {}, }); } +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,7 @@ describe("composerDraftStore syncPersistedAttachments", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + stickyModelOptions: {}, }); }); @@ -253,6 +275,7 @@ describe("composerDraftStore terminal contexts", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + stickyModelOptions: {}, }); }); @@ -595,62 +618,56 @@ describe("composerDraftStore project draft thread mapping", () => { }); }); -describe("composerDraftStore modelOptions", () => { +describe("composerDraftStore modelSelection", () => { const threadId = ThreadId.makeUnsafe("thread-model-options"); beforeEach(() => { resetComposerDraftStore(); }); - it("stores provider-scoped model options in the draft", () => { + it("stores a model selection in the draft", () => { const store = useComposerDraftStore.getState(); - store.setModelOptions(threadId, { - codex: { + store.setModelSelection( + threadId, + modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "xhigh", fastMode: true, - }, - claudeAgent: { - thinking: false, - }, - }); + }), + ); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ - codex: { + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( + modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "xhigh", fastMode: true, - }, - claudeAgent: { - thinking: false, - }, - }); + }), + ); }); - it("drops default-only model options from the draft", () => { + it("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]?.modelSelection).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 +678,158 @@ describe("composerDraftStore modelOptions", () => { { persistSticky: true }, ); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ - codex: { - reasoningEffort: "xhigh", - }, - claudeAgent: { + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( + modelSelection("claudeAgent", "claude-opus-4-6", { thinking: false, - }, - }); - expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({ - codex: { - reasoningEffort: "xhigh", - }, - claudeAgent: { + }), + ); + expect(useComposerDraftStore.getState().stickyModelSelection).toEqual( + modelSelection("claudeAgent", "claude-opus-4-6", { thinking: false, - }, - }); + }), + ); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual( + providerModelOptions({ + claudeAgent: { + 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]?.modelSelection).toEqual( + modelSelection("claudeAgent", "claude-opus-4-6", { + thinking: true, + }), + ); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual( + providerModelOptions({ + claudeAgent: { + thinking: true, + }, + }), + ); + expect(useComposerDraftStore.getState().stickyModelSelection).toBeNull(); }); - 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]?.modelSelection).toEqual( + modelSelection("codex", "gpt-5.4", { + reasoningEffort: "high", + fastMode: false, + }), + ); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual( + providerModelOptions({ + codex: { + reasoningEffort: "high", + fastMode: false, + }, + }), + ); }); it("updates only the draft when sticky persistence is omitted", () => { const store = useComposerDraftStore.getState(); - store.setStickyModelOptions({ - codex: { - fastMode: true, - }, - }); - store.setModelOptions(threadId, { - codex: { - fastMode: true, - }, - claudeAgent: { - effort: "max", - }, - }); + store.setStickyModelSelection( + modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), + ); + store.setModelSelection( + threadId, + modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), + ); store.setProviderModelOptions(threadId, "claudeAgent", { thinking: false, }); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ - codex: { - fastMode: true, - }, - claudeAgent: { + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( + modelSelection("claudeAgent", "claude-opus-4-6", { thinking: false, - }, + }), + ); + expect(useComposerDraftStore.getState().stickyModelSelection).toEqual( + modelSelection("claudeAgent", "claude-opus-4-6", { 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")); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + modelSelection: modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), + modelOptions: providerModelOptions({ + codex: { fastMode: true }, + claudeAgent: { effort: "max" }, + }), }); - expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({ - codex: { + }); + + it("creates the first sticky snapshot from provider option changes", () => { + const store = useComposerDraftStore.getState(); + + store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4")); + + store.setProviderModelOptions( + threadId, + "codex", + { fastMode: true, }, - }); + { persistSticky: true }, + ); + + expect(useComposerDraftStore.getState().stickyModelSelection).toEqual( + modelSelection("codex", "gpt-5.4", { + fastMode: true, + }), + ); }); it("updates only the draft when sticky persistence is disabled", () => { const store = useComposerDraftStore.getState(); - 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,33 +840,31 @@ describe("composerDraftStore modelOptions", () => { { persistSticky: false }, ); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ - claudeAgent: { + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( + modelSelection("claudeAgent", "claude-opus-4-6", { thinking: false, - }, - }); - expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({ - claudeAgent: { - effort: "max", - }, - }); + }), + ); + expect(useComposerDraftStore.getState().stickyModelSelection).toEqual( + modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), + ); }); }); -describe("composerDraftStore setModel", () => { +describe("composerDraftStore setModelSelection", () => { const threadId = ThreadId.makeUnsafe("thread-model"); beforeEach(() => { resetComposerDraftStore(); }); - it("keeps explicit DEFAULT_MODEL overrides instead of coercing to null", () => { + it("keeps explicit model overrides instead of coercing to null", () => { const store = useComposerDraftStore.getState(); - store.setModel(threadId, "gpt-5.3-codex"); + store.setModelSelection(threadId, modelSelection("codex", "gpt-5.3-codex")); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.model).toBe( - "gpt-5.3-codex", + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelection).toEqual( + modelSelection("codex", "gpt-5.3-codex"), ); }); }); @@ -815,19 +874,25 @@ 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", + stickyModelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + options: { + reasoningEffort: "medium", + fastMode: true, + }, + }, stickyModelOptions: { codex: { reasoningEffort: "medium", @@ -837,41 +902,47 @@ describe("composerDraftStore sticky composer settings", () => { }); }); - it("normalizes empty sticky model options", () => { + it("normalizes empty sticky model options by dropping selection options", () => { const store = useComposerDraftStore.getState(); - store.setStickyModelOptions({ - codex: { - fastMode: false, - }, - }); + store.setStickyModelSelection(modelSelection("codex", "gpt-5.4")); + expect(useComposerDraftStore.getState().stickyModelSelection).toEqual( + modelSelection("codex", "gpt-5.4"), + ); 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", () => { + it("retains off-provider option memory without changing the active selection", () => { 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", () => { - 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" }); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + modelSelection: modelSelection("codex", "gpt-5.3-codex", { + reasoningEffort: "medium", + }), + modelOptions: providerModelOptions({ + codex: { + reasoningEffort: "medium", + }, + claudeAgent: { + effort: "max", + }, + }), + }); }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 696d6855b2..fc9ca412f1 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, @@ -73,9 +80,8 @@ const PersistedComposerThreadDraftState = Schema.Struct({ prompt: Schema.String, attachments: Schema.Array(PersistedComposerImageAttachment), terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)), - provider: Schema.optionalKey(ProviderKind), - model: Schema.optionalKey(Schema.String), - modelOptions: Schema.optionalKey(ProviderModelOptions), + modelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), + modelOptions: Schema.optionalKey(Schema.NullOr(ProviderModelOptions)), runtimeMode: Schema.optionalKey(RuntimeMode), interactionMode: Schema.optionalKey(ProviderInteractionMode), }); @@ -88,7 +94,26 @@ 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 LegacyPersistedComposerThreadDraftState = PersistedComposerThreadDraftState & + LegacyCodexFields & + LegacyThreadModelFields; + +const LegacyStickyModelFields = Schema.Struct({ + stickyProvider: Schema.optionalKey(ProviderKind), + stickyModel: Schema.optionalKey(Schema.String), + stickyModelOptions: Schema.optionalKey(Schema.NullOr(ProviderModelOptions)), +}); +type LegacyStickyModelFields = typeof LegacyStickyModelFields.Type; + +type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState & + LegacyStickyModelFields; const PersistedDraftThreadState = Schema.Struct({ projectId: ProjectId, @@ -105,8 +130,8 @@ const PersistedComposerDraftStoreState = Schema.Struct({ draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState), draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState), projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId), - stickyModel: Schema.NullOr(Schema.String), - stickyModelOptions: ProviderModelOptions, + stickyModelSelection: Schema.NullOr(ModelSelection), + stickyModelOptions: Schema.optionalKey(Schema.NullOr(ProviderModelOptions)), }); type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type; @@ -121,8 +146,7 @@ interface ComposerThreadDraftState { nonPersistedImageIds: string[]; persistedAttachments: PersistedComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; - provider: ProviderKind | null; - model: string | null; + modelSelection: ModelSelection | null; modelOptions: ProviderModelOptions | null; runtimeMode: RuntimeMode | null; interactionMode: ProviderInteractionMode | null; @@ -146,7 +170,7 @@ interface ComposerDraftStoreState { draftsByThreadId: Record; draftThreadsByThreadId: Record; projectDraftThreadIdByProjectId: Record; - stickyModel: string | null; + stickyModelSelection: ModelSelection | null; stickyModelOptions: ProviderModelOptions; getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null; getDraftThread: (threadId: ThreadId) => DraftThreadState | null; @@ -177,12 +201,14 @@ interface ComposerDraftStoreState { clearProjectDraftThreadId: (projectId: ProjectId) => void; clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; clearDraftThread: (threadId: ThreadId) => void; - setStickyModel: (model: string | null | undefined) => void; + setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void; setStickyModelOptions: (modelOptions: ProviderModelOptions | null | undefined) => void; setPrompt: (threadId: ThreadId, prompt: string) => void; setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void; - 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, @@ -221,14 +247,29 @@ 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, + }; +} const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze({ draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, - stickyModel: null, - stickyModelOptions: EMPTY_PROVIDER_MODEL_OPTIONS, + stickyModelSelection: null, + stickyModelOptions: {}, }); const EMPTY_IMAGES: ComposerImageAttachment[] = []; @@ -244,8 +285,7 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ nonPersistedImageIds: EMPTY_IDS, persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, terminalContexts: EMPTY_TERMINAL_CONTEXTS, - provider: null, - model: null, + modelSelection: null, modelOptions: null, runtimeMode: null, interactionMode: null, @@ -258,8 +298,7 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], - provider: null, - model: null, + modelSelection: null, modelOptions: null, runtimeMode: null, interactionMode: null, @@ -329,8 +368,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { draft.images.length === 0 && draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && - draft.provider === null && - draft.model === null && + draft.modelSelection === null && draft.modelOptions === null && draft.runtimeMode === null && draft.interactionMode === null @@ -370,20 +408,28 @@ function normalizeProviderModelOptions( ? legacy.effort : undefined; const codexFastMode = - codexCandidate?.fastMode === true || - (provider === "codex" && legacy?.codexFastMode === true) || - (typeof legacy?.serviceTier === "string" && legacy.serviceTier === "fast"); + codexCandidate?.fastMode === true + ? true + : codexCandidate?.fastMode === false + ? false + : (provider === "codex" && legacy?.codexFastMode === true) || + (typeof legacy?.serviceTier === "string" && legacy.serviceTier === "fast") + ? true + : undefined; const codex = - codexReasoningEffort && codexReasoningEffort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex + codexReasoningEffort !== undefined || codexFastMode !== undefined ? { - reasoningEffort: codexReasoningEffort, - ...(codexFastMode ? { fastMode: true } : {}), + ...(codexReasoningEffort !== undefined ? { reasoningEffort: codexReasoningEffort } : {}), + ...(codexFastMode !== undefined ? { fastMode: codexFastMode } : {}), } - : codexFastMode - ? { fastMode: true } - : undefined; + : undefined; - const claudeThinking = claudeCandidate?.thinking === false ? false : undefined; + const claudeThinking = + claudeCandidate?.thinking === true + ? true + : claudeCandidate?.thinking === false + ? false + : undefined; const claudeEffort: ClaudeCodeEffort | undefined = claudeCandidate?.effort === "low" || claudeCandidate?.effort === "medium" || @@ -392,17 +438,18 @@ function normalizeProviderModelOptions( claudeCandidate?.effort === "ultrathink" ? claudeCandidate.effort : undefined; - const claudeFastMode = claudeCandidate?.fastMode === true; + const claudeFastMode = + claudeCandidate?.fastMode === true + ? true + : claudeCandidate?.fastMode === false + ? false + : undefined; const claude = - claudeThinking === false || - (claudeEffort && claudeEffort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeAgent) || - claudeFastMode + claudeThinking !== undefined || claudeEffort !== undefined || claudeFastMode !== undefined ? { - ...(claudeThinking === false ? { thinking: false } : {}), - ...(claudeEffort && claudeEffort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeAgent - ? { effort: claudeEffort } - : {}), - ...(claudeFastMode ? { fastMode: true } : {}), + ...(claudeThinking !== undefined ? { thinking: claudeThinking } : {}), + ...(claudeEffort !== undefined ? { effort: claudeEffort } : {}), + ...(claudeFastMode !== undefined ? { fastMode: claudeFastMode } : {}), } : undefined; @@ -415,6 +462,70 @@ function normalizeProviderModelOptions( }; } +function normalizeModelSelection( + value: unknown, + legacy?: { + provider?: unknown; + model?: unknown; + modelOptions?: unknown; + legacyCodex?: LegacyCodexFields; + }, +): ModelSelection | null { + const candidate = value && typeof value === "object" ? (value as Record) : null; + const provider = normalizeProviderKind(candidate?.provider ?? legacy?.provider); + if (provider === null) { + return null; + } + const rawModel = candidate?.model ?? legacy?.model; + if (typeof rawModel !== "string") { + return null; + } + const model = normalizeModelSlug(rawModel, provider); + if (!model) { + return null; + } + const modelOptions = normalizeProviderModelOptions( + candidate?.options ? { [provider]: candidate.options } : legacy?.modelOptions, + provider, + provider === "codex" ? legacy?.legacyCodex : undefined, + ); + const options = provider === "codex" ? modelOptions?.codex : modelOptions?.claudeAgent; + return { + provider, + model, + ...(options ? { options } : {}), + }; +} + +function syncModelSelectionOptions( + modelSelection: ModelSelection | null, + modelOptions: ProviderModelOptions | null | undefined, +): ModelSelection | null { + if (modelSelection === null) { + return null; + } + const options = modelOptions?.[modelSelection.provider]; + return { + provider: modelSelection.provider, + model: modelSelection.model, + ...(options ? { options } : {}), + }; +} + +function mergeModelSelectionIntoProviderModelOptions( + modelSelection: ModelSelection | null, + currentModelOptions: ProviderModelOptions | null | undefined, +): ProviderModelOptions | null { + if (modelSelection?.options === undefined) { + return normalizeProviderModelOptions(currentModelOptions); + } + return replaceProviderModelOptions( + normalizeProviderModelOptions(currentModelOptions), + modelSelection.provider, + modelSelection.options, + ); +} + function replaceProviderModelOptions( currentModelOptions: ProviderModelOptions | null | undefined, provider: ProviderKind, @@ -433,6 +544,38 @@ function replaceProviderModelOptions( }); } +export function deriveEffectiveComposerModelState(input: { + draft: Pick | null | undefined; + selectedProvider: ProviderKind; + threadModelSelection: ModelSelection | null | undefined; + projectModelSelection: ModelSelection | null | undefined; + customModelsByProvider: Record; +}): EffectiveComposerModelState { + const baseModel = resolveModelSlugForProvider( + input.selectedProvider, + input.threadModelSelection?.model ?? + input.projectModelSelection?.model ?? + getDefaultModel(input.selectedProvider), + ); + const selectedModel = input.draft?.modelSelection?.model + ? resolveAppModelSelection( + input.selectedProvider, + input.customModelsByProvider, + input.draft.modelSelection.model, + ) + : baseModel; + const modelOptions = + input.draft?.modelOptions ?? + providerModelOptionsFromSelection(input.threadModelSelection) ?? + providerModelOptionsFromSelection(input.projectModelSelection) ?? + null; + + return { + selectedModel, + modelOptions, + }; +} + function revokeObjectPreviewUrl(previewUrl: string): void { if (typeof URL === "undefined") { return; @@ -619,10 +762,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 +776,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 +790,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,17 +799,33 @@ function normalizePersistedDraftsByThreadId( draftCandidate.interactionMode === "plan" || draftCandidate.interactionMode === "default" ? draftCandidate.interactionMode : null; - const modelOptions = resolveModelOptions(draftCandidate, provider); const prompt = ensureInlineTerminalContextPlaceholders( promptCandidate, terminalContexts.length, ); + const legacyDraftCandidate = draftValue as LegacyPersistedComposerThreadDraftState; + const normalizedModelOptions = + normalizeProviderModelOptions( + draftCandidate.modelOptions ?? legacyDraftCandidate.modelOptions, + undefined, + legacyDraftCandidate, + ) ?? null; + const normalizedModelSelection = normalizeModelSelection(draftCandidate.modelSelection, { + provider: legacyDraftCandidate.provider, + model: legacyDraftCandidate.model, + modelOptions: normalizedModelOptions ?? legacyDraftCandidate.modelOptions, + legacyCodex: legacyDraftCandidate, + }); + const modelOptions = mergeModelSelectionIntoProviderModelOptions( + normalizedModelSelection, + normalizedModelOptions, + ); + const modelSelection = syncModelSelectionOptions(normalizedModelSelection, modelOptions); if ( promptCandidate.length === 0 && attachments.length === 0 && terminalContexts.length === 0 && - !provider && - !model && + modelSelection === null && modelOptions === null && !runtimeMode && !interactionMode @@ -688,8 +836,7 @@ function normalizePersistedDraftsByThreadId( prompt, attachments, ...(terminalContexts.length > 0 ? { terminalContexts } : {}), - ...(provider ? { provider } : {}), - ...(model ? { model } : {}), + ...(modelSelection ? { modelSelection } : {}), ...(modelOptions ? { modelOptions } : {}), ...(runtimeMode ? { runtimeMode } : {}), ...(interactionMode ? { interactionMode } : {}), @@ -701,40 +848,37 @@ 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; + const stickyModelOptions = normalizeProviderModelOptions(candidate.stickyModelOptions) ?? {}; + const normalizedStickyModelSelection = normalizeModelSelection(candidate.stickyModelSelection, { + provider: candidate.stickyProvider ?? "codex", + model: candidate.stickyModel, + modelOptions: stickyModelOptions, + }); + const nextStickyModelOptions = mergeModelSelectionIntoProviderModelOptions( + normalizedStickyModelSelection, + stickyModelOptions, + ); + const stickyModelSelection = syncModelSelectionOptions( + normalizedStickyModelSelection, + nextStickyModelOptions, + ); const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } = normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectId); - const draftsByThreadId = normalizePersistedDraftsByThreadId( - rawDraftMap, - (draftCandidate, provider) => - persistedVersion >= COMPOSER_DRAFT_STORAGE_VERSION - ? normalizeProviderModelOptions(draftCandidate.modelOptions, provider) - : normalizeProviderModelOptions( - draftCandidate.modelOptions, - provider, - draftCandidate as LegacyPersistedCodexThreadDraftState, - ), - ); + const draftsByThreadId = normalizePersistedDraftsByThreadId(rawDraftMap); return { draftsByThreadId, draftThreadsByThreadId, projectDraftThreadIdByProjectId, - stickyModel, - stickyModelOptions, + stickyModelSelection, + stickyModelOptions: nextStickyModelOptions ?? {}, }; } @@ -752,8 +896,7 @@ function partializeComposerDraftStoreState( draft.prompt.length === 0 && draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && - draft.provider === null && - draft.model === null && + draft.modelSelection === null && draft.modelOptions === null && draft.runtimeMode === null && draft.interactionMode === null @@ -776,9 +919,8 @@ function partializeComposerDraftStoreState( })), } : {}), - ...(draft.model ? { model: draft.model } : {}), + ...(draft.modelSelection ? { modelSelection: draft.modelSelection } : {}), ...(draft.modelOptions ? { modelOptions: draft.modelOptions } : {}), - ...(draft.provider ? { provider: draft.provider } : {}), ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}), ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}), }; @@ -788,7 +930,7 @@ function partializeComposerDraftStoreState( draftsByThreadId: persistedDraftsByThreadId, draftThreadsByThreadId: state.draftThreadsByThreadId, projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId, - stickyModel: state.stickyModel, + stickyModelSelection: state.stickyModelSelection, stickyModelOptions: state.stickyModelOptions, }; } @@ -799,29 +941,36 @@ 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; + normalizeProviderModelOptions(normalizedPersistedState.stickyModelOptions) ?? {}; + const normalizedStickyModelSelection = normalizeModelSelection( + normalizedPersistedState.stickyModelSelection, + { + provider: normalizedPersistedState.stickyProvider, + model: normalizedPersistedState.stickyModel, + modelOptions: stickyModelOptions, + }, + ); + const nextStickyModelOptions = mergeModelSelectionIntoProviderModelOptions( + normalizedStickyModelSelection, + stickyModelOptions, + ); + const stickyModelSelection = syncModelSelectionOptions( + normalizedStickyModelSelection, + nextStickyModelOptions, + ); return { - draftsByThreadId: normalizePersistedDraftsByThreadId( - normalizedPersistedState.draftsByThreadId, - (draftCandidate, provider) => - normalizeProviderModelOptions(draftCandidate.modelOptions, provider), - ), + draftsByThreadId: normalizePersistedDraftsByThreadId(normalizedPersistedState.draftsByThreadId), draftThreadsByThreadId, projectDraftThreadIdByProjectId, - stickyModel, - stickyModelOptions, + stickyModelSelection, + stickyModelOptions: nextStickyModelOptions ?? {}, }; } @@ -948,6 +1097,19 @@ function hydrateImagesFromPersisted( function toHydratedThreadDraft( persistedDraft: PersistedComposerThreadDraftState, ): ComposerThreadDraftState { + const legacyPersistedDraft = persistedDraft as LegacyPersistedComposerThreadDraftState; + const normalizedModelSelection = normalizeModelSelection(persistedDraft.modelSelection, { + provider: legacyPersistedDraft.provider, + model: legacyPersistedDraft.model, + modelOptions: legacyPersistedDraft.modelOptions, + legacyCodex: legacyPersistedDraft, + }); + const normalizedModelOptions = normalizeProviderModelOptions( + persistedDraft.modelOptions ?? legacyPersistedDraft.modelOptions, + ); + const modelOptions = + mergeModelSelectionIntoProviderModelOptions(normalizedModelSelection, normalizedModelOptions) ?? + null; return { prompt: persistedDraft.prompt, images: hydrateImagesFromPersisted(persistedDraft.attachments), @@ -958,9 +1120,8 @@ function toHydratedThreadDraft( ...context, text: "", })) ?? [], - provider: persistedDraft.provider ?? null, - model: persistedDraft.model ?? null, - modelOptions: persistedDraft.modelOptions ?? null, + modelSelection: syncModelSelectionOptions(normalizedModelSelection, modelOptions), + modelOptions, runtimeMode: persistedDraft.runtimeMode ?? null, interactionMode: persistedDraft.interactionMode ?? null, }; @@ -972,8 +1133,8 @@ export const useComposerDraftStore = create()( draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, - stickyModel: null, - stickyModelOptions: EMPTY_PROVIDER_MODEL_OPTIONS, + stickyModelSelection: null, + stickyModelOptions: {}, getDraftThreadByProjectId: (projectId) => { if (projectId.length === 0) { return null; @@ -1219,25 +1380,51 @@ export const useComposerDraftStore = create()( }; }); }, - setStickyModel: (model) => { - const normalizedModel = normalizeModelSlug(model, "codex") ?? null; - set((state) => { - if (state.stickyModel === normalizedModel) { - return state; - } - return { - stickyModel: normalizedModel, - }; - }); + setStickyModelSelection: (modelSelection) => { + const normalizedModelSelection = normalizeModelSelection(modelSelection); + set((state) => + Equal.equals( + state.stickyModelSelection, + syncModelSelectionOptions( + normalizedModelSelection, + mergeModelSelectionIntoProviderModelOptions( + normalizedModelSelection, + state.stickyModelOptions, + ), + ), + ) + ? state + : { + stickyModelSelection: syncModelSelectionOptions( + normalizedModelSelection, + mergeModelSelectionIntoProviderModelOptions( + normalizedModelSelection, + state.stickyModelOptions, + ), + ), + stickyModelOptions: + mergeModelSelectionIntoProviderModelOptions( + normalizedModelSelection, + state.stickyModelOptions, + ) ?? {}, + }, + ); }, setStickyModelOptions: (modelOptions) => { - const normalizedModelOptions = - normalizeProviderModelOptions(modelOptions) ?? EMPTY_PROVIDER_MODEL_OPTIONS; + const normalizedModelOptions = normalizeProviderModelOptions(modelOptions) ?? {}; set((state) => { - if (Equal.equals(state.stickyModelOptions, normalizedModelOptions)) { + const nextStickyModelSelection = syncModelSelectionOptions( + state.stickyModelSelection, + normalizedModelOptions, + ); + if ( + Equal.equals(state.stickyModelOptions, normalizedModelOptions) && + Equal.equals(state.stickyModelSelection, nextStickyModelSelection) + ) { return state; } return { + stickyModelSelection: nextStickyModelSelection, stickyModelOptions: normalizedModelOptions, }; }); @@ -1285,50 +1472,35 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId }; }); }, - setProvider: (threadId, provider) => { - if (threadId.length === 0) { - return; - } - const normalizedProvider = normalizeProviderKind(provider); - set((state) => { - const existing = state.draftsByThreadId[threadId]; - if (!existing && normalizedProvider === null) { - return state; - } - const base = existing ?? createEmptyThreadDraft(); - if (base.provider === normalizedProvider) { - return state; - } - const nextDraft: ComposerThreadDraftState = { - ...base, - provider: normalizedProvider, - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; - } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - setModel: (threadId, model) => { + setModelSelection: (threadId, modelSelection) => { if (threadId.length === 0) { return; } + const normalizedModelSelection = normalizeModelSelection(modelSelection); set((state) => { const existing = state.draftsByThreadId[threadId]; - const normalizedModel = normalizeModelSlug(model, existing?.provider ?? "codex") ?? null; - if (!existing && normalizedModel === null) { + if (!existing && normalizedModelSelection === null) { return state; } const base = existing ?? createEmptyThreadDraft(); - if (base.model === normalizedModel) { + const nextModelOptions = mergeModelSelectionIntoProviderModelOptions( + normalizedModelSelection, + base.modelOptions, + ); + const nextModelSelection = syncModelSelectionOptions( + normalizedModelSelection, + nextModelOptions, + ); + if ( + Equal.equals(base.modelSelection, nextModelSelection) && + Equal.equals(base.modelOptions, nextModelOptions) + ) { return state; } const nextDraft: ComposerThreadDraftState = { ...base, - model: normalizedModel, + modelSelection: nextModelSelection, + modelOptions: nextModelOptions, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; if (shouldRemoveDraft(nextDraft)) { @@ -1345,17 +1517,24 @@ export const useComposerDraftStore = create()( } set((state) => { const existing = state.draftsByThreadId[threadId]; - const provider = existing?.provider ?? null; - const nextModelOptions = normalizeProviderModelOptions(modelOptions, provider); + const nextModelOptions = normalizeProviderModelOptions(modelOptions); if (!existing && nextModelOptions === null) { return state; } const base = existing ?? createEmptyThreadDraft(); - if (Equal.equals(base.modelOptions, nextModelOptions)) { + const nextModelSelection = syncModelSelectionOptions( + base.modelSelection, + nextModelOptions, + ); + if ( + Equal.equals(base.modelOptions, nextModelOptions) && + Equal.equals(base.modelSelection, nextModelSelection) + ) { return state; } const nextDraft: ComposerThreadDraftState = { ...base, + modelSelection: nextModelSelection, modelOptions: nextModelOptions, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; @@ -1383,20 +1562,38 @@ export const useComposerDraftStore = create()( normalizedProvider, nextProviderOptions, ); + const nextModelSelection = syncModelSelectionOptions( + base.modelSelection, + nextModelOptions, + ); const nextStickyModelOptions = options?.persistSticky === true - ? (nextModelOptions ?? EMPTY_PROVIDER_MODEL_OPTIONS) + ? (replaceProviderModelOptions( + state.stickyModelOptions, + normalizedProvider, + nextProviderOptions, + ) ?? {}) : state.stickyModelOptions; + const stickySelectionBase = + state.stickyModelSelection ?? + (options?.persistSticky === true ? base.modelSelection : null); + const nextStickyModelSelection = + options?.persistSticky === true + ? syncModelSelectionOptions(stickySelectionBase, nextStickyModelOptions) + : state.stickyModelSelection; if ( + Equal.equals(base.modelSelection, nextModelSelection) && Equal.equals(base.modelOptions, nextModelOptions) && - Equal.equals(state.stickyModelOptions, nextStickyModelOptions) + Equal.equals(state.stickyModelOptions, nextStickyModelOptions) && + Equal.equals(state.stickyModelSelection, nextStickyModelSelection) ) { return state; } const nextDraft: ComposerThreadDraftState = { ...base, + modelSelection: nextModelSelection, modelOptions: nextModelOptions, }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; @@ -1409,7 +1606,10 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId, ...(options?.persistSticky === true - ? { stickyModelOptions: nextStickyModelOptions } + ? { + stickyModelSelection: nextStickyModelSelection, + stickyModelOptions: nextStickyModelOptions, + } : {}), }; }); @@ -1768,8 +1968,8 @@ export const useComposerDraftStore = create()( draftsByThreadId, draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId, projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId, - stickyModel: normalizedPersisted.stickyModel, - stickyModelOptions: normalizedPersisted.stickyModelOptions, + stickyModelSelection: normalizedPersisted.stickyModelSelection as ModelSelection | null, + stickyModelOptions: normalizedPersisted.stickyModelOptions ?? {}, }; }, }, @@ -1780,6 +1980,34 @@ export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftS return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT); } +export function useEffectiveComposerModelState(input: { + threadId: ThreadId; + selectedProvider: ProviderKind; + threadModelSelection: ModelSelection | null | undefined; + projectModelSelection: ModelSelection | null | undefined; + customModelsByProvider: Record; +}): EffectiveComposerModelState { + const draft = useComposerThreadDraft(input.threadId); + + return useMemo( + () => + deriveEffectiveComposerModelState({ + draft, + selectedProvider: input.selectedProvider, + threadModelSelection: input.threadModelSelection, + projectModelSelection: input.projectModelSelection, + customModelsByProvider: input.customModelsByProvider, + }), + [ + draft, + input.customModelsByProvider, + input.projectModelSelection, + input.selectedProvider, + input.threadModelSelection, + ], + ); +} + /** * Clear draft threads that have been promoted to server threads. * diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index e31809cdd2..69010dfe94 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,7 +12,7 @@ import { useStore } from "../store"; export function useHandleNewThread() { const projects = useStore((store) => store.projects); const threads = useStore((store) => store.threads); - const stickyModel = useComposerDraftStore((store) => store.stickyModel); + const stickyModelSelection = useComposerDraftStore((store) => store.stickyModelSelection); const stickyModelOptions = useComposerDraftStore((store) => store.stickyModelOptions); const navigate = useNavigate(); const routeThreadId = useParams({ @@ -41,9 +40,8 @@ export function useHandleNewThread() { clearProjectDraftThreadId, getDraftThread, getDraftThreadByProjectId, - setModel, + setModelSelection, setModelOptions, - setProvider, setDraftThreadContext, setProjectDraftThreadId, } = useComposerDraftStore.getState(); @@ -102,9 +100,8 @@ export function useHandleNewThread() { envMode: options?.envMode ?? "local", runtimeMode: DEFAULT_RUNTIME_MODE, }); - if (stickyModel) { - setProvider(threadId, inferProviderForModel(stickyModel)); - setModel(threadId, stickyModel); + if (stickyModelSelection) { + setModelSelection(threadId, stickyModelSelection); } if (Object.keys(stickyModelOptions).length > 0) { setModelOptions(threadId, stickyModelOptions); @@ -116,7 +113,7 @@ export function useHandleNewThread() { }); })(); }, - [navigate, routeThreadId, stickyModel, stickyModelOptions], + [navigate, routeThreadId, stickyModelOptions, stickyModelSelection], ); 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/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..75fc4be539 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -15,7 +15,6 @@ import { getDefaultReasoningEffort, getModelOptions, getReasoningEffortOptions, - inferProviderForModel, isClaudeUltrathinkPrompt, normalizeClaudeModelOptions, normalizeCodexModelOptions, @@ -188,23 +187,6 @@ describe("getReasoningEffortOptions", () => { }); }); -describe("inferProviderForModel", () => { - it("detects known provider model slugs", () => { - expect(inferProviderForModel("gpt-5.3-codex")).toBe("codex"); - expect(inferProviderForModel("claude-sonnet-4-6")).toBe("claudeAgent"); - expect(inferProviderForModel("sonnet")).toBe("claudeAgent"); - }); - - it("falls back when the model is unknown", () => { - expect(inferProviderForModel("custom/internal-model")).toBe("codex"); - expect(inferProviderForModel("custom/internal-model", "claudeAgent")).toBe("claudeAgent"); - }); - - it("treats claude-prefixed custom slugs as claude", () => { - expect(inferProviderForModel("claude-custom-internal")).toBe("claudeAgent"); - }); -}); - describe("getDefaultReasoningEffort", () => { it("returns provider-scoped defaults", () => { expect(getDefaultReasoningEffort("codex")).toBe(DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 2d46320753..4b678531fd 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -136,23 +136,6 @@ export function resolveModelSlugForProvider( return resolveModelSlug(model, provider); } -export function inferProviderForModel( - model: string | null | undefined, - fallback: ProviderKind = "codex", -): ProviderKind { - const normalizedClaude = normalizeModelSlug(model, "claudeAgent"); - if (normalizedClaude && MODEL_SLUG_SET_BY_PROVIDER.claudeAgent.has(normalizedClaude)) { - return "claudeAgent"; - } - - const normalizedCodex = normalizeModelSlug(model, "codex"); - if (normalizedCodex && MODEL_SLUG_SET_BY_PROVIDER.codex.has(normalizedCodex)) { - return "codex"; - } - - return typeof model === "string" && model.trim().startsWith("claude-") ? "claudeAgent" : fallback; -} - export function getReasoningEffortOptions(provider: "codex"): ReadonlyArray; export function getReasoningEffortOptions( provider: "claudeAgent",