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", () => {
102102 ]);
103103 });
104104
105 it("normalizes a legacy fallback selection against current capabilities", () => {
105 it("does not materialize catalog defaults for missing stored options", () => {
106106 const config = {
107107 providers: [
108108 {
......@@ -140,11 +140,17 @@ describe("mobile model options", () => {
140140 const [option] = buildModelOptions(config, {
141141 instanceId: ProviderInstanceId.make("codex"),
142142 model: "gpt-test",
143 options: [{ id: "fastMode", value: true }],
144143 });
145144
146145 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" }]);
148154 });
149155
150156 it("rejects stored selections whose provider is not usable", () => {
apps/mobile/src/lib/modelOptions.ts+3-2
......@@ -4,7 +4,7 @@ import type {
44 ServerConfig as T3ServerConfig,
55} from "@t3tools/contracts";
66import {
7 buildProviderOptionSelectionsFromDescriptors,
7 buildExplicitProviderOptionSelectionsFromDescriptors,
88 getProviderOptionDescriptors,
99} from "@t3tools/shared/model";
1010
......@@ -45,11 +45,12 @@ function normalizeSelectionOptions(
4545 if (!capabilities) {
4646 return selection;
4747 }
48 const options = buildProviderOptionSelectionsFromDescriptors(
48 const options = buildExplicitProviderOptionSelectionsFromDescriptors(
4949 getProviderOptionDescriptors({
5050 caps: capabilities,
5151 selections: selection.options,
5252 }),
53 selection.options,
5354 );
5455 return options
5556 ? { ...selection, options }
apps/server/src/cli/project.ts-2
......@@ -30,7 +30,6 @@ import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSn
3030import { OrchestrationLayerLive } from "../orchestration/runtimeLayer.ts";
3131import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts";
3232import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
33import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts";
3433import {
3534 clearPersistedServerRuntimeState,
3635 readPersistedServerRuntimeState,
......@@ -481,7 +480,6 @@ const projectAddCommand = Command.make("add", {
481480 projectId,
482481 title,
483482 workspaceRoot,
484 defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(),
485483 createdAt: DateTime.formatIso(yield* DateTime.now),
486484 });
487485 return `Added project ${projectId} (${title}) at ${workspaceRoot}.`;
apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts+55-2
......@@ -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";
29import { expect, it } from "@effect/vitest";
310import * as Effect from "effect/Effect";
411import * as NodeServices from "@effect/platform-node/NodeServices";
......@@ -31,7 +38,53 @@ const seedProjectCreated = (sequence: number): OrchestrationEvent => ({
3138 },
3239});
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
3588 it.effect("propagates defaultThreadEnvMode through meta.update into the read model", () =>
3689 Effect.gen(function* () {
3790 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"
219219 projectId: command.projectId,
220220 title: command.title,
221221 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,
223226 faviconPath: null,
224227 scripts: [],
225228 createdAt: command.createdAt,
apps/server/src/persistence/Migrations.ts+2
......@@ -55,6 +55,7 @@ import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts";
5555import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts";
5656import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts";
5757import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts";
58import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.ts";
5859
5960/**
6061 * Migration loader with all migrations defined inline.
......@@ -110,6 +111,7 @@ export const migrationEntries = [
110111 [41, "AuthSessionClientConnection", Migration0041],
111112 [42, "ProjectionThreadLinkedPullRequest", Migration0042],
112113 [43, "ProjectionThreadsUnsettledAt", Migration0043],
114 [44, "ClearAutomaticProjectModelDefaults", Migration0044],
113115] as const;
114116
115117export 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) => {
380380 }),
381381 );
382382});
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
1616import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
1717import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
1818
19it("uses the canonical Codex default for auto-bootstrapped model selection", () => {
20 assert.deepStrictEqual(ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), {
19it("uses the canonical Codex default for the auto-bootstrapped welcome thread", () => {
20 assert.deepStrictEqual(ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), {
2121 instanceId: ProviderInstanceId.make("codex"),
2222 model: DEFAULT_MODEL,
2323 });
......@@ -158,7 +158,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
158158 id: bootstrapProjectId,
159159 title: "Startup Project",
160160 workspaceRoot: "/tmp/startup-project",
161 defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(),
161 defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(),
162162 scripts: [],
163163 createdAt: "2026-01-01T00:00:00.000Z",
164164 updatedAt: "2026-01-01T00:00:00.000Z",
......@@ -197,7 +197,13 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
197197
198198it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when missing", () =>
199199 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 >([]);
201207 const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe(
202208 Effect.provideService(ServerConfig.ServerConfig, {
203209 cwd: "/tmp/startup-project",
......@@ -224,7 +230,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
224230 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
225231 readEvents: () => Stream.empty,
226232 dispatch: (command) =>
227 Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe(
233 Ref.update(dispatchCalls, (calls) => [...calls, command]).pipe(
228234 Effect.as({ sequence: 1 }),
229235 ),
230236 streamDomainEvents: Stream.empty,
......@@ -236,7 +242,16 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
236242
237243 assert.equal(typeof targets.bootstrapProjectId, "string");
238244 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 );
240255 }),
241256);
242257
apps/server/src/serverRuntimeStartup.ts+6-7
......@@ -166,7 +166,7 @@ export const launchStartupHeartbeat = recordStartupHeartbeat.pipe(
166166 Effect.asVoid,
167167);
168168
169export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({
169export const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({
170170 instanceId: ProviderInstanceId.make("codex"),
171171 model: DEFAULT_MODEL,
172172});
......@@ -199,26 +199,25 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
199199 serverConfig.cwd,
200200 );
201201 let nextProjectId: ProjectId;
202 let nextProjectDefaultModelSelection: ModelSelection;
202 let nextThreadModelSelection: ModelSelection;
203203
204204 if (Option.isNone(existingProject)) {
205205 const createdAt = DateTime.formatIso(yield* DateTime.now);
206206 nextProjectId = ProjectId.make(yield* randomUUID);
207207 const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project";
208 nextProjectDefaultModelSelection = getAutoBootstrapDefaultModelSelection();
208 nextThreadModelSelection = getAutoBootstrapThreadModelSelection();
209209 yield* orchestrationEngine.dispatch({
210210 type: "project.create",
211211 commandId: CommandId.make(yield* randomUUID),
212212 projectId: nextProjectId,
213213 title: bootstrapProjectTitle,
214214 workspaceRoot: serverConfig.cwd,
215 defaultModelSelection: nextProjectDefaultModelSelection,
216215 createdAt,
217216 });
218217 } else {
219218 nextProjectId = existingProject.value.id;
220 nextProjectDefaultModelSelection =
221 existingProject.value.defaultModelSelection ?? getAutoBootstrapDefaultModelSelection();
219 nextThreadModelSelection =
220 existingProject.value.defaultModelSelection ?? getAutoBootstrapThreadModelSelection();
222221 }
223222
224223 const existingThreadId =
......@@ -232,7 +231,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
232231 threadId: createdThreadId,
233232 projectId: nextProjectId,
234233 title: "New thread",
235 modelSelection: nextProjectDefaultModelSelection,
234 modelSelection: nextThreadModelSelection,
236235 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
237236 runtimeMode: "full-access",
238237 branch: null,
apps/web/src/components/CommandPalette.tsx+2-13
......@@ -157,11 +157,7 @@ import {
157157} from "./ThreadCommandSubtitle";
158158import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";
159159import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server";
160import {
161 deriveProviderInstanceEntries,
162 resolveDefaultProviderModelSelection,
163 type ProviderInstanceEntry,
164} from "../providerInstances";
160import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances";
165161import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings";
166162import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command";
167163import { Button } from "./ui/button";
......@@ -1873,10 +1869,6 @@ function OpenCommandPaletteDialog(props: {
18731869 }
18741870
18751871 const projectId = newProjectId();
1876 const targetEnvironmentProviders =
1877 environments.find((environment) => environment.environmentId === input.environmentId)
1878 ?.serverConfig?.providers ??
1879 (input.environmentId === primaryEnvironmentId ? providers : []);
18801872 const createResult = await createProject({
18811873 environmentId: input.environmentId,
18821874 input: {
......@@ -1884,10 +1876,7 @@ function OpenCommandPaletteDialog(props: {
18841876 title: inferProjectTitleFromPath(cwd),
18851877 workspaceRoot: cwd,
18861878 createWorkspaceRootIfMissing: true,
1887 defaultModelSelection: resolveDefaultProviderModelSelection(
1888 targetEnvironmentProviders,
1889 null,
1890 ),
1879 defaultModelSelection: null,
18911880 },
18921881 });
18931882 if (createResult._tag === "Failure") {
apps/web/src/components/chat/composerProviderState.test.tsx+4-6
......@@ -69,7 +69,7 @@ describe("getComposerProviderState", () => {
6969 );
7070 });
7171
72 it("returns descriptor defaults when no selections are provided", () => {
72 it("uses descriptor defaults for display without dispatching them as overrides", () => {
7373 const state = getComposerProviderState({
7474 provider: PROVIDER,
7575 model: MODEL,
......@@ -86,7 +86,7 @@ describe("getComposerProviderState", () => {
8686 expect(state).toEqual({
8787 provider: PROVIDER,
8888 promptEffort: "high",
89 modelOptionsForDispatch: selections(["effort", "high"]),
89 modelOptionsForDispatch: undefined,
9090 });
9191 });
9292
......@@ -165,9 +165,7 @@ describe("getComposerProviderState", () => {
165165 });
166166
167167 expect(state.promptEffort).toBe("high");
168 expect(state.modelOptionsForDispatch).toEqual(
169 selections(["effort", "high"], ["contextWindow", "200k"], ["agent", "plan"]),
170 );
168 expect(state.modelOptionsForDispatch).toEqual(selections(["agent", "plan"]));
171169 });
172170
173171 it("drops the plan agent from dispatch when legacy plan mode is disabled", () => {
......@@ -219,7 +217,7 @@ describe("getComposerProviderState", () => {
219217 planModeEnabled: false,
220218 });
221219
222 expect(state.modelOptionsForDispatch).toEqual(selections(["agent", "research"]));
220 expect(state.modelOptionsForDispatch).toBeUndefined();
223221 });
224222
225223 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 {
66 type ServerProviderModel,
77} from "@t3tools/contracts";
88import {
9 buildProviderOptionSelectionsFromDescriptors,
9 buildExplicitProviderOptionSelectionsFromDescriptors,
1010 getProviderOptionCurrentValue,
1111 getProviderOptionDescriptors,
1212 isClaudeUltrathinkPrompt,
......@@ -94,7 +94,10 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com
9494 return {
9595 provider,
9696 promptEffort,
97 modelOptionsForDispatch: buildProviderOptionSelectionsFromDescriptors(descriptors),
97 modelOptionsForDispatch: buildExplicitProviderOptionSelectionsFromDescriptors(
98 descriptors,
99 modelOptions,
100 ),
98101 ...(ultrathinkActive
99102 ? {
100103 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
5252GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list
5353when 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
5565## Quote an assistant response
5666
5767On 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({
708708 title: TrimmedNonEmptyString,
709709 workspaceRoot: TrimmedNonEmptyString,
710710 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.
711713 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
712714 createdAt: IsoDateTime,
713715});
packages/shared/src/model.test.ts+17
......@@ -3,6 +3,7 @@ import { ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts";
33
44import {
55 applyClaudePromptEffortPrefix,
6 buildExplicitProviderOptionSelectionsFromDescriptors,
67 buildProviderOptionSelectionsFromDescriptors,
78 createModelCapabilities,
89 createModelSelection,
......@@ -111,6 +112,22 @@ describe("descriptor helpers", () => {
111112 ]);
112113 });
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
114131 it("stores option selection arrays in model selections", () => {
115132 expect(
116133 createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [
packages/shared/src/model.ts+14
......@@ -204,6 +204,20 @@ export function buildProviderOptionSelectionsFromDescriptors(
204204 return nextSelections.length > 0 ? nextSelections : undefined;
205205}
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
207221export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean {
208222 return typeof text === "string" && /\bultrathink\b/i.test(text);
209223}