authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-02 02:11:15-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 02:11:15-04:00
log5392c9bb99c4be1f1c3fd78743df62885fd214dd
tree3e5f6e216b71690946b0def47ed87fdd08b3b9c4
parent535c83dea5025e49df12df4a14b4d1dfb7d373ce
signature Signed by PGP key B5690EEEBB952194

fix(models): restore sticky new-thread selections (#9164)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

17 files changed, 295 insertions(+), 44 deletions(-)

apps/mobile/src/lib/modelOptions.test.ts+9-3
...@@ -102,7 +102,7 @@ describe("mobile model options", () => {...@@ -102,7 +102,7 @@ describe("mobile model options", () => {
102 ]);102 ]);
103 });103 });
104104
105 it("normalizes a legacy fallback selection against current capabilities", () => {105 it("does not materialize catalog defaults for missing stored options", () => {
106 const config = {106 const config = {
107 providers: [107 providers: [
108 {108 {
...@@ -140,11 +140,17 @@ describe("mobile model options", () => {...@@ -140,11 +140,17 @@ describe("mobile model options", () => {
140 const [option] = buildModelOptions(config, {140 const [option] = buildModelOptions(config, {
141 instanceId: ProviderInstanceId.make("codex"),141 instanceId: ProviderInstanceId.make("codex"),
142 model: "gpt-test",142 model: "gpt-test",
143 options: [{ id: "fastMode", value: true }],
144 });143 });
145144
146 expect(option?.capabilities?.optionDescriptors?.[0]?.id).toBe("serviceTier");145 expect(option?.capabilities?.optionDescriptors?.[0]?.id).toBe("serviceTier");
147 expect(option?.selection.options).toEqual([{ id: "serviceTier", value: "default" }]);146 expect(option?.selection.options).toBeUndefined();
147
148 const [explicitOption] = buildModelOptions(config, {
149 instanceId: ProviderInstanceId.make("codex"),
150 model: "gpt-test",
151 options: [{ id: "serviceTier", value: "priority" }],
152 });
153 expect(explicitOption?.selection.options).toEqual([{ id: "serviceTier", value: "priority" }]);
148 });154 });
149155
150 it("rejects stored selections whose provider is not usable", () => {156 it("rejects stored selections whose provider is not usable", () => {
apps/mobile/src/lib/modelOptions.ts+3-2
...@@ -4,7 +4,7 @@ import type {...@@ -4,7 +4,7 @@ import type {
4 ServerConfig as T3ServerConfig,4 ServerConfig as T3ServerConfig,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import {6import {
7 buildProviderOptionSelectionsFromDescriptors,7 buildExplicitProviderOptionSelectionsFromDescriptors,
8 getProviderOptionDescriptors,8 getProviderOptionDescriptors,
9} from "@t3tools/shared/model";9} from "@t3tools/shared/model";
1010
...@@ -45,11 +45,12 @@ function normalizeSelectionOptions(...@@ -45,11 +45,12 @@ function normalizeSelectionOptions(
45 if (!capabilities) {45 if (!capabilities) {
46 return selection;46 return selection;
47 }47 }
48 const options = buildProviderOptionSelectionsFromDescriptors(48 const options = buildExplicitProviderOptionSelectionsFromDescriptors(
49 getProviderOptionDescriptors({49 getProviderOptionDescriptors({
50 caps: capabilities,50 caps: capabilities,
51 selections: selection.options,51 selections: selection.options,
52 }),52 }),
53 selection.options,
53 );54 );
54 return options55 return options
55 ? { ...selection, options }56 ? { ...selection, options }
apps/server/src/cli/project.ts-2
...@@ -30,7 +30,6 @@ import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSn...@@ -30,7 +30,6 @@ import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSn
30import { OrchestrationLayerLive } from "../orchestration/runtimeLayer.ts";30import { OrchestrationLayerLive } from "../orchestration/runtimeLayer.ts";
31import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts";31import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts";
32import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";32import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
33import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts";
34import {33import {
35 clearPersistedServerRuntimeState,34 clearPersistedServerRuntimeState,
36 readPersistedServerRuntimeState,35 readPersistedServerRuntimeState,
...@@ -481,7 +480,6 @@ const projectAddCommand = Command.make("add", {...@@ -481,7 +480,6 @@ const projectAddCommand = Command.make("add", {
481 projectId,480 projectId,
482 title,481 title,
483 workspaceRoot,482 workspaceRoot,
484 defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(),
485 createdAt: DateTime.formatIso(yield* DateTime.now),483 createdAt: DateTime.formatIso(yield* DateTime.now),
486 });484 });
487 return `Added project ${projectId} (${title}) at ${workspaceRoot}.`;485 return `Added project ${projectId} (${title}) at ${workspaceRoot}.`;
apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts+55-2
...@@ -1,4 +1,11 @@...@@ -1,4 +1,11 @@
1import { CommandId, EventId, ProjectId, type OrchestrationEvent } from "@t3tools/contracts";1import {
2 CommandId,
3 EventId,
4 ProjectId,
5 ProviderInstanceId,
6 type ModelSelection,
7 type OrchestrationEvent,
8} from "@t3tools/contracts";
2import { expect, it } from "@effect/vitest";9import { expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
4import * as NodeServices from "@effect/platform-node/NodeServices";11import * as NodeServices from "@effect/platform-node/NodeServices";
...@@ -31,7 +38,53 @@ const seedProjectCreated = (sequence: number): OrchestrationEvent => ({...@@ -31,7 +38,53 @@ const seedProjectCreated = (sequence: number): OrchestrationEvent => ({
31 },38 },
32});39});
3340
34it.layer(NodeServices.layer)("decider project defaultThreadEnvMode", (it) => {41it.layer(NodeServices.layer)("decider project defaults", (it) => {
42 it.effect("only treats metadata updates as explicit model defaults", () =>
43 Effect.gen(function* () {
44 const selection: ModelSelection = {
45 instanceId: ProviderInstanceId.make("codex"),
46 model: "gpt-5.6-sol",
47 options: [{ id: "reasoningEffort", value: "high" }],
48 };
49 const created = yield* decideOrchestrationCommand({
50 command: {
51 type: "project.create",
52 commandId: CommandId.make("cmd-project-model-create"),
53 projectId,
54 title: "Model default",
55 workspaceRoot: "/tmp/model-default",
56 defaultModelSelection: selection,
57 createdAt: now,
58 },
59 readModel: createEmptyReadModel(now),
60 });
61 const createdEvent = Array.isArray(created) ? created[0] : created;
62 expect(createdEvent.type).toBe("project.created");
63 expect(
64 (createdEvent.payload as { defaultModelSelection?: unknown }).defaultModelSelection,
65 ).toBeNull();
66
67 const withProject = yield* projectEvent(createEmptyReadModel(now), {
68 ...createdEvent,
69 sequence: 1,
70 });
71 const updated = yield* decideOrchestrationCommand({
72 command: {
73 type: "project.meta.update",
74 commandId: CommandId.make("cmd-project-model-update"),
75 projectId,
76 defaultModelSelection: selection,
77 },
78 readModel: withProject,
79 });
80 const updatedEvent = Array.isArray(updated) ? updated[0] : updated;
81 expect(updatedEvent.type).toBe("project.meta-updated");
82 expect(
83 (updatedEvent.payload as { defaultModelSelection?: unknown }).defaultModelSelection,
84 ).toEqual(selection);
85 }),
86 );
87
35 it.effect("propagates defaultThreadEnvMode through meta.update into the read model", () =>88 it.effect("propagates defaultThreadEnvMode through meta.update into the read model", () =>
36 Effect.gen(function* () {89 Effect.gen(function* () {
37 const readModel = yield* projectEvent(createEmptyReadModel(now), seedProjectCreated(1));90 const readModel = yield* projectEvent(createEmptyReadModel(now), seedProjectCreated(1));
apps/server/src/orchestration/decider.ts+4-1
...@@ -219,7 +219,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -219,7 +219,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
219 projectId: command.projectId,219 projectId: command.projectId,
220 title: command.title,220 title: command.title,
221 workspaceRoot: command.workspaceRoot,221 workspaceRoot: command.workspaceRoot,
222 defaultModelSelection: command.defaultModelSelection ?? null,222 // Project creation has no user model choice. Older clients sent an
223 // automatic seed here, but only a metadata update records an
224 // explicit project default.
225 defaultModelSelection: null,
223 faviconPath: null,226 faviconPath: null,
224 scripts: [],227 scripts: [],
225 createdAt: command.createdAt,228 createdAt: command.createdAt,
apps/server/src/persistence/Migrations.ts+2
...@@ -55,6 +55,7 @@ import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts";...@@ -55,6 +55,7 @@ import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts";
55import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts";55import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts";
56import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts";56import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts";
57import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts";57import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts";
58import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.ts";
5859
59/**60/**
60 * Migration loader with all migrations defined inline.61 * Migration loader with all migrations defined inline.
...@@ -110,6 +111,7 @@ export const migrationEntries = [...@@ -110,6 +111,7 @@ export const migrationEntries = [
110 [41, "AuthSessionClientConnection", Migration0041],111 [41, "AuthSessionClientConnection", Migration0041],
111 [42, "ProjectionThreadLinkedPullRequest", Migration0042],112 [42, "ProjectionThreadLinkedPullRequest", Migration0042],
112 [43, "ProjectionThreadsUnsettledAt", Migration0043],113 [43, "ProjectionThreadsUnsettledAt", Migration0043],
114 [44, "ClearAutomaticProjectModelDefaults", Migration0044],
113] as const;115] as const;
114116
115export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);117export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts+90
...@@ -380,3 +380,93 @@ layer("016_CanonicalizeModelSelections", (it) => {...@@ -380,3 +380,93 @@ layer("016_CanonicalizeModelSelections", (it) => {
380 }),380 }),
381 );381 );
382});382});
383
384layer("044_ClearAutomaticProjectModelDefaults", (it) => {
385 it.effect("clears create-time seeds and preserves explicit project defaults", () =>
386 Effect.gen(function* () {
387 const sql = yield* SqlClient.SqlClient;
388 yield* runMigrations({ toMigrationInclusive: 43 });
389
390 yield* sql`
391 INSERT INTO projection_projects (
392 project_id,
393 title,
394 workspace_root,
395 default_model_selection_json,
396 default_thread_env_mode,
397 favicon_path,
398 scripts_json,
399 created_at,
400 updated_at,
401 deleted_at
402 )
403 VALUES
404 ('project-auto', 'Auto', '/tmp/auto', '{"instanceId":"codex","model":"gpt-5.6-sol"}', NULL, NULL, '[]', '2026-08-01T00:00:00.000Z', '2026-08-01T00:00:00.000Z', NULL),
405 ('project-title-only', 'Title only', '/tmp/title-only', '{"instanceId":"codex","model":"gpt-5.6-sol"}', NULL, NULL, '[]', '2026-08-01T00:00:00.000Z', '2026-08-02T00:00:00.000Z', NULL),
406 ('project-explicit', 'Explicit', '/tmp/explicit', '{"instanceId":"codex","model":"gpt-5.6-sol","options":[{"id":"reasoningEffort","value":"high"}]}', NULL, NULL, '[]', '2026-08-01T00:00:00.000Z', '2026-08-02T00:00:00.000Z', NULL)
407 `;
408
409 yield* sql`
410 INSERT INTO orchestration_events (
411 event_id,
412 aggregate_kind,
413 stream_id,
414 stream_version,
415 event_type,
416 occurred_at,
417 command_id,
418 causation_event_id,
419 correlation_id,
420 actor_kind,
421 payload_json,
422 metadata_json
423 )
424 VALUES
425 ('event-auto-create', 'project', 'project-auto', 0, 'project.created', '2026-08-01T00:00:00.000Z', 'command-auto-create', NULL, 'command-auto-create', 'client', '{"defaultModelSelection":{"instanceId":"codex","model":"gpt-5.6-sol"}}', '{}'),
426 ('event-title-create', 'project', 'project-title-only', 0, 'project.created', '2026-08-01T00:00:00.000Z', 'command-title-create', NULL, 'command-title-create', 'client', '{"defaultModelSelection":{"instanceId":"codex","model":"gpt-5.6-sol"}}', '{}'),
427 ('event-title-update', 'project', 'project-title-only', 1, 'project.meta-updated', '2026-08-02T00:00:00.000Z', 'command-title-update', NULL, 'command-title-update', 'client', '{"title":"Renamed"}', '{}'),
428 ('event-explicit-create', 'project', 'project-explicit', 0, 'project.created', '2026-08-01T00:00:00.000Z', 'command-explicit-create', NULL, 'command-explicit-create', 'client', '{"defaultModelSelection":{"instanceId":"codex","model":"gpt-5.6-sol"}}', '{}'),
429 ('event-explicit-update', 'project', 'project-explicit', 1, 'project.meta-updated', '2026-08-02T00:00:00.000Z', 'command-explicit-update', NULL, 'command-explicit-update', 'client', '{"defaultModelSelection":{"instanceId":"codex","model":"gpt-5.6-sol","options":[{"id":"reasoningEffort","value":"high"}]}}', '{}')
430 `;
431
432 yield* runMigrations({ toMigrationInclusive: 44 });
433
434 const projects = yield* sql<{
435 readonly projectId: string;
436 readonly selection: string | null;
437 }>`
438 SELECT
439 project_id AS "projectId",
440 default_model_selection_json AS "selection"
441 FROM projection_projects
442 ORDER BY project_id
443 `;
444 assert.deepStrictEqual(projects, [
445 { projectId: "project-auto", selection: null },
446 {
447 projectId: "project-explicit",
448 selection:
449 '{"instanceId":"codex","model":"gpt-5.6-sol","options":[{"id":"reasoningEffort","value":"high"}]}',
450 },
451 { projectId: "project-title-only", selection: null },
452 ]);
453
454 const createdEvents = yield* sql<{
455 readonly streamId: string;
456 readonly model: string | null;
457 }>`
458 SELECT
459 stream_id AS "streamId",
460 json_extract(payload_json, '$.defaultModelSelection.model') AS "model"
461 FROM orchestration_events
462 WHERE event_type = 'project.created'
463 ORDER BY stream_id
464 `;
465 assert.deepStrictEqual(createdEvents, [
466 { streamId: "project-auto", model: null },
467 { streamId: "project-explicit", model: "gpt-5.6-sol" },
468 { streamId: "project-title-only", model: null },
469 ]);
470 }),
471 );
472});
apps/server/src/persistence/Migrations/044_ClearAutomaticProjectModelDefaults.ts created+51
...@@ -0,0 +1,51 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 // Project creation never exposed a model choice. A later metadata event
8 // containing this field is the evidence that the user set or reset one.
9 yield* sql`
10 WITH automatically_seeded_projects AS (
11 SELECT created.stream_id AS project_id
12 FROM orchestration_events AS created
13 WHERE created.aggregate_kind = 'project'
14 AND created.event_type = 'project.created'
15 AND json_type(created.payload_json, '$.defaultModelSelection') IS NOT NULL
16 AND json_type(created.payload_json, '$.defaultModelSelection') <> 'null'
17 AND NOT EXISTS (
18 SELECT 1
19 FROM orchestration_events AS configured
20 WHERE configured.aggregate_kind = 'project'
21 AND configured.stream_id = created.stream_id
22 AND configured.event_type = 'project.meta-updated'
23 AND json_type(configured.payload_json, '$.defaultModelSelection') IS NOT NULL
24 )
25 )
26 UPDATE projection_projects
27 SET default_model_selection_json = NULL
28 WHERE project_id IN (SELECT project_id FROM automatically_seeded_projects)
29 `;
30
31 yield* sql`
32 UPDATE orchestration_events AS created
33 SET payload_json = json_set(
34 created.payload_json,
35 '$.defaultModelSelection',
36 json('null')
37 )
38 WHERE created.aggregate_kind = 'project'
39 AND created.event_type = 'project.created'
40 AND json_type(created.payload_json, '$.defaultModelSelection') IS NOT NULL
41 AND json_type(created.payload_json, '$.defaultModelSelection') <> 'null'
42 AND NOT EXISTS (
43 SELECT 1
44 FROM orchestration_events AS configured
45 WHERE configured.aggregate_kind = 'project'
46 AND configured.stream_id = created.stream_id
47 AND configured.event_type = 'project.meta-updated'
48 AND json_type(configured.payload_json, '$.defaultModelSelection') IS NOT NULL
49 )
50 `;
51});
apps/server/src/serverRuntimeStartup.test.ts+21-6
...@@ -16,8 +16,8 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna...@@ -16,8 +16,8 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna
16import * as AnalyticsService from "./telemetry/AnalyticsService.ts";16import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
17import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";17import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
1818
19it("uses the canonical Codex default for auto-bootstrapped model selection", () => {19it("uses the canonical Codex default for the auto-bootstrapped welcome thread", () => {
20 assert.deepStrictEqual(ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), {20 assert.deepStrictEqual(ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), {
21 instanceId: ProviderInstanceId.make("codex"),21 instanceId: ProviderInstanceId.make("codex"),
22 model: DEFAULT_MODEL,22 model: DEFAULT_MODEL,
23 });23 });
...@@ -158,7 +158,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa...@@ -158,7 +158,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
158 id: bootstrapProjectId,158 id: bootstrapProjectId,
159 title: "Startup Project",159 title: "Startup Project",
160 workspaceRoot: "/tmp/startup-project",160 workspaceRoot: "/tmp/startup-project",
161 defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(),161 defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(),
162 scripts: [],162 scripts: [],
163 createdAt: "2026-01-01T00:00:00.000Z",163 createdAt: "2026-01-01T00:00:00.000Z",
164 updatedAt: "2026-01-01T00:00:00.000Z",164 updatedAt: "2026-01-01T00:00:00.000Z",
...@@ -197,7 +197,13 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa...@@ -197,7 +197,13 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
197197
198it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when missing", () =>198it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when missing", () =>
199 Effect.gen(function* () {199 Effect.gen(function* () {
200 const dispatchCalls = yield* Ref.make<ReadonlyArray<string>>([]);200 const dispatchCalls = yield* Ref.make<
201 ReadonlyArray<{
202 readonly type: string;
203 readonly defaultModelSelection?: unknown;
204 readonly modelSelection?: unknown;
205 }>
206 >([]);
201 const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe(207 const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe(
202 Effect.provideService(ServerConfig.ServerConfig, {208 Effect.provideService(ServerConfig.ServerConfig, {
203 cwd: "/tmp/startup-project",209 cwd: "/tmp/startup-project",
...@@ -224,7 +230,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when...@@ -224,7 +230,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
224 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {230 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
225 readEvents: () => Stream.empty,231 readEvents: () => Stream.empty,
226 dispatch: (command) =>232 dispatch: (command) =>
227 Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe(233 Ref.update(dispatchCalls, (calls) => [...calls, command]).pipe(
228 Effect.as({ sequence: 1 }),234 Effect.as({ sequence: 1 }),
229 ),235 ),
230 streamDomainEvents: Stream.empty,236 streamDomainEvents: Stream.empty,
...@@ -236,7 +242,16 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when...@@ -236,7 +242,16 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
236242
237 assert.equal(typeof targets.bootstrapProjectId, "string");243 assert.equal(typeof targets.bootstrapProjectId, "string");
238 assert.equal(typeof targets.bootstrapThreadId, "string");244 assert.equal(typeof targets.bootstrapThreadId, "string");
239 assert.deepStrictEqual(yield* Ref.get(dispatchCalls), ["project.create", "thread.create"]);245 const commands = yield* Ref.get(dispatchCalls);
246 assert.deepStrictEqual(
247 commands.map((command) => command.type),
248 ["project.create", "thread.create"],
249 );
250 assert.equal("defaultModelSelection" in commands[0]!, false);
251 assert.deepStrictEqual(
252 commands[1]?.modelSelection,
253 ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(),
254 );
240 }),255 }),
241);256);
242257
apps/server/src/serverRuntimeStartup.ts+6-7
...@@ -166,7 +166,7 @@ export const launchStartupHeartbeat = recordStartupHeartbeat.pipe(...@@ -166,7 +166,7 @@ export const launchStartupHeartbeat = recordStartupHeartbeat.pipe(
166 Effect.asVoid,166 Effect.asVoid,
167);167);
168168
169export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({169export const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({
170 instanceId: ProviderInstanceId.make("codex"),170 instanceId: ProviderInstanceId.make("codex"),
171 model: DEFAULT_MODEL,171 model: DEFAULT_MODEL,
172});172});
...@@ -199,26 +199,25 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {...@@ -199,26 +199,25 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
199 serverConfig.cwd,199 serverConfig.cwd,
200 );200 );
201 let nextProjectId: ProjectId;201 let nextProjectId: ProjectId;
202 let nextProjectDefaultModelSelection: ModelSelection;202 let nextThreadModelSelection: ModelSelection;
203203
204 if (Option.isNone(existingProject)) {204 if (Option.isNone(existingProject)) {
205 const createdAt = DateTime.formatIso(yield* DateTime.now);205 const createdAt = DateTime.formatIso(yield* DateTime.now);
206 nextProjectId = ProjectId.make(yield* randomUUID);206 nextProjectId = ProjectId.make(yield* randomUUID);
207 const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project";207 const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project";
208 nextProjectDefaultModelSelection = getAutoBootstrapDefaultModelSelection();208 nextThreadModelSelection = getAutoBootstrapThreadModelSelection();
209 yield* orchestrationEngine.dispatch({209 yield* orchestrationEngine.dispatch({
210 type: "project.create",210 type: "project.create",
211 commandId: CommandId.make(yield* randomUUID),211 commandId: CommandId.make(yield* randomUUID),
212 projectId: nextProjectId,212 projectId: nextProjectId,
213 title: bootstrapProjectTitle,213 title: bootstrapProjectTitle,
214 workspaceRoot: serverConfig.cwd,214 workspaceRoot: serverConfig.cwd,
215 defaultModelSelection: nextProjectDefaultModelSelection,
216 createdAt,215 createdAt,
217 });216 });
218 } else {217 } else {
219 nextProjectId = existingProject.value.id;218 nextProjectId = existingProject.value.id;
220 nextProjectDefaultModelSelection =219 nextThreadModelSelection =
221 existingProject.value.defaultModelSelection ?? getAutoBootstrapDefaultModelSelection();220 existingProject.value.defaultModelSelection ?? getAutoBootstrapThreadModelSelection();
222 }221 }
223222
224 const existingThreadId =223 const existingThreadId =
...@@ -232,7 +231,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {...@@ -232,7 +231,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
232 threadId: createdThreadId,231 threadId: createdThreadId,
233 projectId: nextProjectId,232 projectId: nextProjectId,
234 title: "New thread",233 title: "New thread",
235 modelSelection: nextProjectDefaultModelSelection,234 modelSelection: nextThreadModelSelection,
236 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,235 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
237 runtimeMode: "full-access",236 runtimeMode: "full-access",
238 branch: null,237 branch: null,
apps/web/src/components/CommandPalette.tsx+2-13
...@@ -157,11 +157,7 @@ import {...@@ -157,11 +157,7 @@ import {
157} from "./ThreadCommandSubtitle";157} from "./ThreadCommandSubtitle";
158import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";158import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";
159import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server";159import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server";
160import {160import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances";
161 deriveProviderInstanceEntries,
162 resolveDefaultProviderModelSelection,
163 type ProviderInstanceEntry,
164} from "../providerInstances";
165import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings";161import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings";
166import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command";162import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command";
167import { Button } from "./ui/button";163import { Button } from "./ui/button";
...@@ -1873,10 +1869,6 @@ function OpenCommandPaletteDialog(props: {...@@ -1873,10 +1869,6 @@ function OpenCommandPaletteDialog(props: {
1873 }1869 }
18741870
1875 const projectId = newProjectId();1871 const projectId = newProjectId();
1876 const targetEnvironmentProviders =
1877 environments.find((environment) => environment.environmentId === input.environmentId)
1878 ?.serverConfig?.providers ??
1879 (input.environmentId === primaryEnvironmentId ? providers : []);
1880 const createResult = await createProject({1872 const createResult = await createProject({
1881 environmentId: input.environmentId,1873 environmentId: input.environmentId,
1882 input: {1874 input: {
...@@ -1884,10 +1876,7 @@ function OpenCommandPaletteDialog(props: {...@@ -1884,10 +1876,7 @@ function OpenCommandPaletteDialog(props: {
1884 title: inferProjectTitleFromPath(cwd),1876 title: inferProjectTitleFromPath(cwd),
1885 workspaceRoot: cwd,1877 workspaceRoot: cwd,
1886 createWorkspaceRootIfMissing: true,1878 createWorkspaceRootIfMissing: true,
1887 defaultModelSelection: resolveDefaultProviderModelSelection(1879 defaultModelSelection: null,
1888 targetEnvironmentProviders,
1889 null,
1890 ),
1891 },1880 },
1892 });1881 });
1893 if (createResult._tag === "Failure") {1882 if (createResult._tag === "Failure") {
apps/web/src/components/chat/composerProviderState.test.tsx+4-6
...@@ -69,7 +69,7 @@ describe("getComposerProviderState", () => {...@@ -69,7 +69,7 @@ describe("getComposerProviderState", () => {
69 );69 );
70 });70 });
7171
72 it("returns descriptor defaults when no selections are provided", () => {72 it("uses descriptor defaults for display without dispatching them as overrides", () => {
73 const state = getComposerProviderState({73 const state = getComposerProviderState({
74 provider: PROVIDER,74 provider: PROVIDER,
75 model: MODEL,75 model: MODEL,
...@@ -86,7 +86,7 @@ describe("getComposerProviderState", () => {...@@ -86,7 +86,7 @@ describe("getComposerProviderState", () => {
86 expect(state).toEqual({86 expect(state).toEqual({
87 provider: PROVIDER,87 provider: PROVIDER,
88 promptEffort: "high",88 promptEffort: "high",
89 modelOptionsForDispatch: selections(["effort", "high"]),89 modelOptionsForDispatch: undefined,
90 });90 });
91 });91 });
9292
...@@ -165,9 +165,7 @@ describe("getComposerProviderState", () => {...@@ -165,9 +165,7 @@ describe("getComposerProviderState", () => {
165 });165 });
166166
167 expect(state.promptEffort).toBe("high");167 expect(state.promptEffort).toBe("high");
168 expect(state.modelOptionsForDispatch).toEqual(168 expect(state.modelOptionsForDispatch).toEqual(selections(["agent", "plan"]));
169 selections(["effort", "high"], ["contextWindow", "200k"], ["agent", "plan"]),
170 );
171 });169 });
172170
173 it("drops the plan agent from dispatch when legacy plan mode is disabled", () => {171 it("drops the plan agent from dispatch when legacy plan mode is disabled", () => {
...@@ -219,7 +217,7 @@ describe("getComposerProviderState", () => {...@@ -219,7 +217,7 @@ describe("getComposerProviderState", () => {
219 planModeEnabled: false,217 planModeEnabled: false,
220 });218 });
221219
222 expect(state.modelOptionsForDispatch).toEqual(selections(["agent", "research"]));220 expect(state.modelOptionsForDispatch).toBeUndefined();
223 });221 });
224222
225 it("returns undefined dispatch options when the model declares no descriptors", () => {223 it("returns undefined dispatch options when the model declares no descriptors", () => {
apps/web/src/components/chat/composerProviderState.tsx+5-2
...@@ -6,7 +6,7 @@ import {...@@ -6,7 +6,7 @@ import {
6 type ServerProviderModel,6 type ServerProviderModel,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import {8import {
9 buildProviderOptionSelectionsFromDescriptors,9 buildExplicitProviderOptionSelectionsFromDescriptors,
10 getProviderOptionCurrentValue,10 getProviderOptionCurrentValue,
11 getProviderOptionDescriptors,11 getProviderOptionDescriptors,
12 isClaudeUltrathinkPrompt,12 isClaudeUltrathinkPrompt,
...@@ -94,7 +94,10 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com...@@ -94,7 +94,10 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com
94 return {94 return {
95 provider,95 provider,
96 promptEffort,96 promptEffort,
97 modelOptionsForDispatch: buildProviderOptionSelectionsFromDescriptors(descriptors),97 modelOptionsForDispatch: buildExplicitProviderOptionSelectionsFromDescriptors(
98 descriptors,
99 modelOptions,
100 ),
98 ...(ultrathinkActive101 ...(ultrathinkActive
99 ? {102 ? {
100 composerFrameClassName: "ultrathink-frame",103 composerFrameClassName: "ultrathink-frame",
docs/user/composer.md+10
...@@ -52,6 +52,16 @@ On mobile, the model picker shows each OpenCode model's upstream provider, such...@@ -52,6 +52,16 @@ On mobile, the model picker shows each OpenCode model's upstream provider, such
52GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list52GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list
53when starting a thread or changing an existing thread's model.53when starting a thread or changing an existing thread's model.
5454
55## Model defaults
56
57T3 Code remembers the last provider, model, and model options you selected and reuses that
58selection for new threads. A model configured in a project's settings overrides the remembered
59selection for that project; resetting the project setting returns it to the remembered selection.
60
61Model options shown as provider defaults remain display values until you choose them in T3 Code.
62T3 Code only sends options you selected explicitly, so an unset reasoning level or service tier can
63still come from the provider's own configuration.
64
55## Quote an assistant response65## Quote an assistant response
5666
57On web and desktop, select text in an assistant response, then choose **Cite in composer** from the67On web and desktop, select text in an assistant response, then choose **Cite in composer** from the
packages/contracts/src/orchestration.ts+2
...@@ -708,6 +708,8 @@ export const ProjectCreateCommand = Schema.Struct({...@@ -708,6 +708,8 @@ export const ProjectCreateCommand = Schema.Struct({
708 title: TrimmedNonEmptyString,708 title: TrimmedNonEmptyString,
709 workspaceRoot: TrimmedNonEmptyString,709 workspaceRoot: TrimmedNonEmptyString,
710 createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean),710 createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean),
711 // Retained for older clients that sent an automatic create-time seed. The
712 // server ignores it; explicit project defaults use project.meta.update.
711 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),713 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
712 createdAt: IsoDateTime,714 createdAt: IsoDateTime,
713});715});
packages/shared/src/model.test.ts+17
...@@ -3,6 +3,7 @@ import { ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts";...@@ -3,6 +3,7 @@ import { ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts";
33
4import {4import {
5 applyClaudePromptEffortPrefix,5 applyClaudePromptEffortPrefix,
6 buildExplicitProviderOptionSelectionsFromDescriptors,
6 buildProviderOptionSelectionsFromDescriptors,7 buildProviderOptionSelectionsFromDescriptors,
7 createModelCapabilities,8 createModelCapabilities,
8 createModelSelection,9 createModelSelection,
...@@ -111,6 +112,22 @@ describe("descriptor helpers", () => {...@@ -111,6 +112,22 @@ describe("descriptor helpers", () => {
111 ]);112 ]);
112 });113 });
113114
115 it("builds dispatch options only from explicit selections", () => {
116 const descriptors = getProviderOptionDescriptors({
117 caps: codexCaps,
118 selections: [{ id: "fastMode", value: true }],
119 });
120
121 expect(buildExplicitProviderOptionSelectionsFromDescriptors(descriptors, undefined)).toBe(
122 undefined,
123 );
124 expect(
125 buildExplicitProviderOptionSelectionsFromDescriptors(descriptors, [
126 { id: "fastMode", value: true },
127 ]),
128 ).toEqual([{ id: "fastMode", value: true }]);
129 });
130
114 it("stores option selection arrays in model selections", () => {131 it("stores option selection arrays in model selections", () => {
115 expect(132 expect(
116 createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [133 createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [
packages/shared/src/model.ts+14
...@@ -204,6 +204,20 @@ export function buildProviderOptionSelectionsFromDescriptors(...@@ -204,6 +204,20 @@ export function buildProviderOptionSelectionsFromDescriptors(
204 return nextSelections.length > 0 ? nextSelections : undefined;204 return nextSelections.length > 0 ? nextSelections : undefined;
205}205}
206206
207export function buildExplicitProviderOptionSelectionsFromDescriptors(
208 descriptors: ReadonlyArray<ProviderOptionDescriptor> | null | undefined,
209 selections: ReadonlyArray<ProviderOptionSelection> | null | undefined,
210): Array<ProviderOptionSelection> | undefined {
211 if (!selections || selections.length === 0) {
212 return undefined;
213 }
214 const explicitIds = new Set(selections.map((selection) => selection.id));
215 const normalized = buildProviderOptionSelectionsFromDescriptors(descriptors)?.filter(
216 (selection) => explicitIds.has(selection.id),
217 );
218 return normalized && normalized.length > 0 ? normalized : undefined;
219}
220
207export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean {221export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean {
208 return typeof text === "string" && /\bultrathink\b/i.test(text);222 return typeof text === "string" && /\bultrathink\b/i.test(text);
209}223}