diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index f1cf6afc91..526c2f5fb0 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -493,7 +493,7 @@ describe("ProviderCommandReactor", () => { }); }); - it("rejects a first turn when requested provider conflicts with the thread model", async () => { + it("allows a first turn when requested provider differs from thread model", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -515,34 +515,15 @@ describe("ProviderCommandReactor", () => { }), ); - await waitFor(async () => { - const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find( - (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), - ); - return ( - thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? - false - ); - }); - - expect(harness.startSession).not.toHaveBeenCalled(); - expect(harness.sendTurn).not.toHaveBeenCalled(); + await waitFor(() => harness.startSession.mock.calls.length === 1); - const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); - expect(thread?.session).toBeNull(); - expect( - thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), - ).toMatchObject({ - summary: "Provider turn start failed", - payload: { - detail: expect.stringContaining("cannot switch to 'claudeAgent'"), - }, + // First turn should succeed and bind to the requested provider + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + provider: "claudeAgent", }); }); - it("rejects a turn when the requested model belongs to a different provider", async () => { + it("allows a first turn when model belongs to a different provider", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -564,28 +545,11 @@ describe("ProviderCommandReactor", () => { }), ); - await waitFor(async () => { - const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find( - (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), - ); - return ( - thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? - false - ); - }); - - expect(harness.startSession).not.toHaveBeenCalled(); - expect(harness.sendTurn).not.toHaveBeenCalled(); + await waitFor(() => harness.startSession.mock.calls.length === 1); - 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'"), - }, + // First turn should succeed and infer claudeAgent from the model + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + provider: "claudeAgent", }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 57405ca515..8ee10708eb 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -26,7 +26,7 @@ import { ProviderCommandReactor, type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; -import { inferProviderForModel } from "@t3tools/shared/model"; +import { inferProviderForModel, isKnownModelSlug } from "@t3tools/shared/model"; type ProviderIntentEvent = Extract< OrchestrationEvent, @@ -233,26 +233,44 @@ const make = Effect.gen(function* () { ) ? thread.session.providerName : undefined; - const threadProvider: ProviderKind = currentProvider ?? inferProviderForModel(thread.model); - if (options?.provider !== undefined && options.provider !== threadProvider) { + // Determine the effective thread provider based on session state. + // First turn (no session): trust explicit options.provider, then options.model, then fallback. + // Subsequent turns: enforce binding — no provider switching allowed after session is established. + const threadProvider: ProviderKind = + currentProvider ?? + (options?.provider ?? null) ?? + (options?.model ? inferProviderForModel(options.model, "codex") : null) ?? + "codex"; + + // Only enforce binding when session already exists. + if (currentProvider !== undefined && options?.provider !== undefined && options.provider !== currentProvider) { return yield* new ProviderAdapterRequestError({ - provider: threadProvider, + provider: currentProvider, method: "thread.turn.start", - detail: `Thread '${threadId}' is bound to provider '${threadProvider}' and cannot switch to '${options.provider}'.`, + detail: `Thread '${threadId}' is bound to provider '${currentProvider}' and cannot switch to '${options.provider}'.`, }); } - if ( - options?.model !== undefined && - inferProviderForModel(options.model, threadProvider) !== threadProvider - ) { - return yield* new ProviderAdapterRequestError({ - provider: threadProvider, - method: "thread.turn.start", - detail: `Model '${options.model}' does not belong to provider '${threadProvider}' for thread '${threadId}'.`, - }); + + // Binding check for model vs provider mismatch only when session exists. + if (currentProvider !== undefined && options?.model !== undefined) { + const modelProvider = inferProviderForModel(options.model, currentProvider); + if (modelProvider !== currentProvider) { + return yield* new ProviderAdapterRequestError({ + provider: currentProvider, + method: "thread.turn.start", + detail: `Model '${options.model}' does not belong to provider '${currentProvider}' for thread '${threadId}'.`, + }); + } } - const preferredProvider: ProviderKind = currentProvider ?? threadProvider; - const desiredModel = options?.model ?? thread.model; + + const preferredProvider: ProviderKind = currentProvider ?? (options?.provider ?? threadProvider); + // Only pass known model slugs to the SDK. Unknown custom models (e.g., "MiniMax-M2.7") + // should be left for the SDK to resolve via ANTHROPIC_MODEL env var. + const optionsModel = options?.model; + const desiredModel = + optionsModel && isKnownModelSlug(optionsModel, preferredProvider) + ? optionsModel + : (thread.model ?? null) ?? undefined; const effectiveCwd = resolveThreadWorkspaceCwd({ thread, projects: readModel.projects, diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ea1821014a..adb98a13b3 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -27,6 +27,7 @@ import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts" import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts"; import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplementation.ts"; import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts"; +import Migration0016 from "./Migrations/016_ProjectionThreadsModelNullable.ts"; import { Effect } from "effect"; /** @@ -55,6 +56,7 @@ const loader = Migrator.fromRecord({ "13_ProjectionThreadProposedPlans": Migration0013, "14_ProjectionThreadProposedPlanImplementation": Migration0014, "15_ProjectionTurnsSourceProposedPlan": Migration0015, + "16_ProjectionThreadsModelNullable": Migration0016, }); /** diff --git a/apps/server/src/persistence/Migrations/016_ProjectionThreadsModelNullable.ts b/apps/server/src/persistence/Migrations/016_ProjectionThreadsModelNullable.ts new file mode 100644 index 0000000000..7956e02df6 --- /dev/null +++ b/apps/server/src/persistence/Migrations/016_ProjectionThreadsModelNullable.ts @@ -0,0 +1,64 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // SQLite doesn't support ALTER TABLE to change NOT NULL to nullable, + // so we need to recreate the table + yield* sql` + ALTER TABLE projection_threads RENAME TO projection_threads_old + `; + + yield* sql` + CREATE TABLE projection_threads ( + thread_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + title TEXT NOT NULL, + model TEXT, + runtime_mode TEXT NOT NULL, + interaction_mode TEXT NOT NULL, + branch TEXT, + worktree_path TEXT, + latest_turn_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + deleted_at + ) + SELECT + thread_id, + project_id, + title, + model, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + deleted_at + FROM projection_threads_old + `; + + yield* sql` + DROP TABLE projection_threads_old + `; +}); \ No newline at end of file diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 7a30870f2d..95784af696 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -23,7 +23,7 @@ export const ProjectionThread = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: Schema.String, - model: Schema.String, + model: Schema.NullOr(Schema.String), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 43adc70f1a..c3f92a60d7 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -17,6 +17,9 @@ import { type SDKResultMessage, type SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; import { ApprovalRequestId, type CanonicalItemType, @@ -58,6 +61,7 @@ import { Queue, Random, Ref, + Schema, Stream, } from "effect"; @@ -75,6 +79,27 @@ import { ClaudeAdapter, type ClaudeAdapterShape } from "../Services/ClaudeAdapte import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = "claudeAgent" as const; + +const SettingsJsonSchema = Schema.Struct({ + env: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}); + +/** + * Reads the `env` block from `~/.claude/settings.json` and returns it as a + * plain object. This is needed because GUI applications on macOS don't inherit + * shell environment variables, so the Claude binary would not receive API + * credentials from settings.json when spawned programmatically. + */ +const getClaudeEnvFromSettings = Effect.try({ + try: () => { + const settingsPath = path.join(os.homedir(), ".claude", "settings.json"); + const content = fs.readFileSync(settingsPath, "utf-8"); + const decoded = Schema.decodeUnknownSync(SettingsJsonSchema)(content); + return decoded.env ?? {}; + }, + catch: () => ({} as Record), +}); + type ClaudeTextStreamKind = Extract; type ClaudeToolResultStreamKind = Extract< RuntimeContentStreamKind, @@ -2575,7 +2600,11 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { ...(newSessionId ? { sessionId: newSessionId } : {}), includePartialMessages: true, canUseTool, - env: process.env, + env: (() => { + const result = Effect.runSync(Effect.result(getClaudeEnvFromSettings)); + return result._tag === "Success" ? result.success : {}; + })(), + settingSources: ["user"], ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), }; diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 2e6ac51b7f..bd98332f90 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -631,13 +631,13 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< (project) => project.workspaceRoot === cwd && project.deletedAt === null, ); let bootstrapProjectId: ProjectId; - let bootstrapProjectDefaultModel: string; + let bootstrapProjectDefaultModel: string | undefined; if (!existingProject) { const createdAt = new Date().toISOString(); bootstrapProjectId = ProjectId.makeUnsafe(crypto.randomUUID()); const bootstrapProjectTitle = path.basename(cwd) || "project"; - bootstrapProjectDefaultModel = "gpt-5-codex"; + bootstrapProjectDefaultModel = undefined; yield* orchestrationEngine.dispatch({ type: "project.create", commandId: CommandId.makeUnsafe(crypto.randomUUID()), @@ -649,7 +649,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); } else { bootstrapProjectId = existingProject.id; - bootstrapProjectDefaultModel = existingProject.defaultModel ?? "gpt-5-codex"; + bootstrapProjectDefaultModel = existingProject.defaultModel ?? undefined; } const existingThread = snapshot.threads.find( @@ -664,7 +664,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< threadId, projectId: bootstrapProjectId, title: "New thread", - model: bootstrapProjectDefaultModel, + model: bootstrapProjectDefaultModel ?? null, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", branch: null, diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 3208adc8bb..24859de358 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -273,7 +273,9 @@ export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, - model: TrimmedNonEmptyString, + model: Schema.NullOr(TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(() => null), + ), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode.pipe( Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), @@ -332,7 +334,7 @@ const ThreadCreateCommand = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, - model: TrimmedNonEmptyString, + model: Schema.NullOr(TrimmedNonEmptyString), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode.pipe( Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), @@ -634,7 +636,9 @@ export const ThreadCreatedPayload = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, - model: TrimmedNonEmptyString, + model: Schema.NullOr(TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(() => null), + ), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 625dfd5adf..6c0ae1e1c4 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -20,6 +20,12 @@ const MODEL_SLUG_SET_BY_PROVIDER: Record> = codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), }; +export function isKnownModelSlug(model: string | null | undefined, provider: ProviderKind): boolean { + if (typeof model !== "string") return false; + const normalized = normalizeModelSlug(model, provider); + return normalized !== null && MODEL_SLUG_SET_BY_PROVIDER[provider].has(normalized); +} + const CLAUDE_OPUS_4_6_MODEL = "claude-opus-4-6"; const CLAUDE_SONNET_4_6_MODEL = "claude-sonnet-4-6"; const CLAUDE_HAIKU_4_5_MODEL = "claude-haiku-4-5";