Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 10 additions & 46 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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();

Expand All @@ -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",
});
});

Expand Down
50 changes: 34 additions & 16 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -55,6 +56,7 @@ const loader = Migrator.fromRecord({
"13_ProjectionThreadProposedPlans": Migration0013,
"14_ProjectionThreadProposedPlanImplementation": Migration0014,
"15_ProjectionTurnsSourceProposedPlan": Migration0015,
"16_ProjectionThreadsModelNullable": Migration0016,
});

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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
`;
});
2 changes: 1 addition & 1 deletion apps/server/src/persistence/Services/ProjectionThreads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
31 changes: 30 additions & 1 deletion apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -58,6 +61,7 @@ import {
Queue,
Random,
Ref,
Schema,
Stream,
} from "effect";

Expand All @@ -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<string, string>),
});

type ClaudeTextStreamKind = Extract<RuntimeContentStreamKind, "assistant_text" | "reasoning_text">;
type ClaudeToolResultStreamKind = Extract<
RuntimeContentStreamKind,
Expand Down Expand Up @@ -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] } : {}),
};

Expand Down
8 changes: 4 additions & 4 deletions apps/server/src/wsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand All @@ -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(
Expand All @@ -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,
Expand Down
10 changes: 7 additions & 3 deletions packages/contracts/src/orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
6 changes: 6 additions & 0 deletions packages/shared/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ const MODEL_SLUG_SET_BY_PROVIDER: Record<ProviderKind, ReadonlySet<ModelSlug>> =
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";
Expand Down