diff --git a/apps/server/src/services/threads/project-execution-defaults.ts b/apps/server/src/services/threads/project-execution-defaults.ts
index fa1870e5..f68bddbf 100644
--- a/apps/server/src/services/threads/project-execution-defaults.ts
+++ b/apps/server/src/services/threads/project-execution-defaults.ts
@@ -78,14 +78,17 @@ export function resolveProjectExecutionDefaultsForCreate(
deps: Pick<AppDeps, "db" | "providerRegistry">,
args: ResolveProjectExecutionDefaultsForCreateArgs,
): ResolvedProjectExecutionDefaultsForCreate {
- const storedDefaults = getProjectExecutionDefaults(deps.db, {
- projectId: args.projectId,
- });
const requestedProviderId = resolveRequestedCreateExecutionValue({
field: "providerId",
sources: args.executionInputSources,
value: args.providerId,
});
+ // A requested provider gets ITS remembered settings; none requested gets the
+ // project's most recently used provider.
+ const storedDefaults = getProjectExecutionDefaults(deps.db, {
+ projectId: args.projectId,
+ ...(requestedProviderId ? { providerId: requestedProviderId } : {}),
+ });
const requestedModel = resolveRequestedCreateExecutionValue({
field: "model",
sources: args.executionInputSources,
diff --git a/apps/server/src/services/threads/thread-execution-override.ts b/apps/server/src/services/threads/thread-execution-override.ts
index bdff65d0..2bfae984 100644
--- a/apps/server/src/services/threads/thread-execution-override.ts
+++ b/apps/server/src/services/threads/thread-execution-override.ts
@@ -199,6 +199,7 @@ function resolveFallbackModel(
}
const projectDefaults = getProjectExecutionDefaults(deps.db, {
projectId: thread.projectId,
+ providerId: thread.providerId,
});
return projectDefaults?.model ?? null;
}
diff --git a/apps/server/src/services/threads/thread-execution-plan.ts b/apps/server/src/services/threads/thread-execution-plan.ts
index d8739be0..e269d61b 100644
--- a/apps/server/src/services/threads/thread-execution-plan.ts
+++ b/apps/server/src/services/threads/thread-execution-plan.ts
@@ -73,6 +73,7 @@ export function resolveExistingThreadPermissionMode(
}
const projectDefaults = getProjectExecutionDefaults(deps.db, {
projectId: thread.projectId,
+ providerId: thread.providerId,
});
const projectExecution =
projectDefaults?.providerId === thread.providerId ? projectDefaults : null;
@@ -264,6 +265,7 @@ export async function resolveExistingThreadExecutionPlan(
args.projectDefaults === undefined
? getProjectExecutionDefaults(deps.db, {
projectId: thread.projectId,
+ providerId: thread.providerId,
})
: args.projectDefaults;
const projectExecution =
diff --git a/packages/db/drizzle/0117_project_defaults_per_provider.sql b/packages/db/drizzle/0117_project_defaults_per_provider.sql
new file mode 100644
index 00000000..fa146665
--- /dev/null
+++ b/packages/db/drizzle/0117_project_defaults_per_provider.sql
@@ -0,0 +1,2 @@
+DROP INDEX `project_execution_defaults_project_idx`;--> statement-breakpoint
+CREATE UNIQUE INDEX `project_execution_defaults_project_provider_idx` ON `project_execution_defaults` (`project_id`,`provider_id`);
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index 96049dca..2d179472 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -820,6 +820,13 @@
"when": 1789075667774,
"tag": "0116_majestic_swordsman",
"breakpoints": true
+ },
+ {
+ "idx": 117,
+ "version": "6",
+ "when": 1789200000000,
+ "tag": "0117_project_defaults_per_provider",
+ "breakpoints": true
}
]
-}
\ No newline at end of file
+}
diff --git a/packages/db/src/data/project-execution-defaults.ts b/packages/db/src/data/project-execution-defaults.ts
index 8de60373..cfc682cd 100644
--- a/packages/db/src/data/project-execution-defaults.ts
+++ b/packages/db/src/data/project-execution-defaults.ts
@@ -1,4 +1,4 @@
-import { eq, inArray } from "drizzle-orm";
+import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
import type {
ProjectExecutionDefaults,
PermissionMode,
@@ -10,6 +10,12 @@ import { projectExecutionDefaults } from "../schema.js";
export interface GetProjectExecutionDefaultsArgs {
projectId: string;
+ /**
+ * The provider whose remembered settings are wanted. Omitted, the project's
+ * most recently used provider is returned, which is what preselects a new
+ * thread.
+ */
+ providerId?: string;
}
export interface ListProjectExecutionDefaultsByProjectIdsArgs {
@@ -38,7 +44,17 @@ export function getProjectExecutionDefaults(
serviceTier: projectExecutionDefaults.serviceTier,
})
.from(projectExecutionDefaults)
- .where(eq(projectExecutionDefaults.projectId, args.projectId))
+ .where(
+ args.providerId === undefined
+ ? eq(projectExecutionDefaults.projectId, args.projectId)
+ : and(
+ eq(projectExecutionDefaults.projectId, args.projectId),
+ eq(projectExecutionDefaults.providerId, args.providerId),
+ ),
+ )
+ // rowid breaks a same-millisecond tie toward the row written last.
+ .orderBy(desc(projectExecutionDefaults.updatedAt), desc(sql`rowid`))
+ .limit(1)
.get();
return row ?? null;
@@ -64,6 +80,8 @@ export function listProjectExecutionDefaultsByProjectIds(
})
.from(projectExecutionDefaults)
.where(inArray(projectExecutionDefaults.projectId, [...args.projectIds]))
+ // Oldest first, so the loop below leaves each project's latest provider.
+ .orderBy(asc(projectExecutionDefaults.updatedAt), asc(sql`rowid`))
.all();
for (const row of rows) {
@@ -90,9 +108,8 @@ export function upsertProjectExecutionDefaults(
updatedAt,
})
.onConflictDoUpdate({
- target: [projectExecutionDefaults.projectId],
+ target: [projectExecutionDefaults.projectId, projectExecutionDefaults.providerId],
set: {
- providerId: args.providerId,
model: args.model,
reasoningLevel: args.reasoningLevel,
permissionMode: args.permissionMode,
diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts
index aa6443cc..03e1ba35 100644
--- a/packages/db/src/schema.ts
+++ b/packages/db/src/schema.ts
@@ -145,7 +145,10 @@ export const projectExecutionDefaults = sqliteTable(
updatedAt: integer("updated_at").notNull(),
},
(table) => [
- uniqueIndex("project_execution_defaults_project_idx").on(table.projectId),
+ uniqueIndex("project_execution_defaults_project_provider_idx").on(
+ table.projectId,
+ table.providerId,
+ ),
],
);
diff --git a/packages/db/test/data/project-execution-defaults.test.ts b/packages/db/test/data/project-execution-defaults.test.ts
index 851cfcfc..8b5e0071 100644
--- a/packages/db/test/data/project-execution-defaults.test.ts
+++ b/packages/db/test/data/project-execution-defaults.test.ts
@@ -124,6 +124,41 @@ describe("project-execution-defaults", () => {
});
});
+ it("keeps each provider's defaults when another provider is used", () => {
+ const { db, project } = setup();
+
+ upsertProjectExecutionDefaults(db, {
+ projectId: project.id,
+ providerId: "codex",
+ model: "gpt-5",
+ reasoningLevel: "high",
+ permissionMode: "full",
+ serviceTier: "default",
+ updatedAt: 1,
+ });
+ upsertProjectExecutionDefaults(db, {
+ projectId: project.id,
+ providerId: "claude-code",
+ model: "claude-opus-4-1",
+ reasoningLevel: "medium",
+ permissionMode: "auto",
+ serviceTier: "fast",
+ updatedAt: 2,
+ });
+
+ // Using claude-code must not erase what the project remembers for codex.
+ expect(
+ getProjectExecutionDefaults(db, {
+ projectId: project.id,
+ providerId: "codex",
+ }),
+ ).toMatchObject({ providerId: "codex", model: "gpt-5", reasoningLevel: "high" });
+ // Unscoped reads still preselect the most recently used provider.
+ expect(
+ getProjectExecutionDefaults(db, { projectId: project.id }),
+ ).toMatchObject({ providerId: "claude-code" });
+ });
+
it("deletes defaults when the project is deleted", () => {
const { db, project } = setup();
What happens
Use codex in a project with, say,
highreasoning. Then start one thread on claude-code. Go back to codex: the project no longer remembers codex's model or reasoning; it falls back to the provider defaults. Each provider switch erases the other provider's remembered settings.Why
project_execution_defaultsholds one row per project:packages/db/src/schema.ts:148:uniqueIndex("project_execution_defaults_project_idx").on(table.projectId)packages/db/src/data/project-execution-defaults.ts:93: the upsert conflicts onprojectIdalone and overwritesproviderId.apps/server/src/services/threads/thread-execution-plan.ts:270then discards the stored row whenever its provider differs from the thread's, so an existing codex thread gets no project defaults after any claude-code thread was created.The existing tests describe the intended behaviour as provider-scoped ("upserts provider-scoped execution defaults",
project-execution-defaults.test.ts:39; "returns null when a project has no stored defaults for a provider"; "replaces the previous defaults for the same project and provider"). The storage doesn't implement that.Fix (patch below, against
main8d32c61)(project_id, provider_id)(migration0117), with the upsert conflicting on both columns.getProjectExecutionDefaultstakes an optionalproviderId. With one, it returns that provider's row. Without one, it returns the project's most recently used provider, ordered byupdated_atthenrowid, so new-thread preselection behaves exactly as today.thread-execution-plan.ts,thread-execution-override.ts) and create with an explicitly requested provider ask for their own provider's row.This also fixes a latent bug:
thread-execution-override.tsreturned the other provider's model as a fallback when the stored row belonged to a different provider.Tests
mainand passes with the patch.packages/dbdefaults suite 6/6. Serverproject-execution-defaults,thread-execution-plan,public-threads.defaultsandpublic-project-startup27/27. Typecheck green.The migration SQL and journal entry are in the patch. The
0117_snapshot.jsonI derived from0116is left out of the issue for size; regenerate it with drizzle-kit.Patch (without the generated snapshot)