| author | |
| committer | |
| log | 5392c9bb99c4be1f1c3fd78743df62885fd214dd |
| tree | 3e5f6e216b71690946b0def47ed87fdd08b3b9c4 |
| parent | 535c83dea5025e49df12df4a14b4d1dfb7d373ce |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 102 | ]); |
| 103 | 103 | }); |
| 104 | 104 | |
| 105 | it("normalizes a legacy fallback selection against current capabilities", () => { | |
| 105 | it("does not materialize catalog defaults for missing stored options", () => { | |
| 106 | 106 | const config = { |
| 107 | 107 | providers: [ |
| 108 | 108 | { |
| ... | ... | @@ -140,11 +140,17 @@ describe("mobile model options", () => { |
| 140 | 140 | const [option] = buildModelOptions(config, { |
| 141 | 141 | instanceId: ProviderInstanceId.make("codex"), |
| 142 | 142 | model: "gpt-test", |
| 143 | options: [{ id: "fastMode", value: true }], | |
| 144 | 143 | }); |
| 145 | 144 | |
| 146 | 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 | }); |
| 149 | 155 | |
| 150 | 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 | 4 | ServerConfig as T3ServerConfig, |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | 6 | import { |
| 7 | buildProviderOptionSelectionsFromDescriptors, | |
| 7 | buildExplicitProviderOptionSelectionsFromDescriptors, | |
| 8 | 8 | getProviderOptionDescriptors, |
| 9 | 9 | } from "@t3tools/shared/model"; |
| 10 | 10 | |
| ... | ... | @@ -45,11 +45,12 @@ function normalizeSelectionOptions( |
| 45 | 45 | if (!capabilities) { |
| 46 | 46 | return selection; |
| 47 | 47 | } |
| 48 | const options = buildProviderOptionSelectionsFromDescriptors( | |
| 48 | const options = buildExplicitProviderOptionSelectionsFromDescriptors( | |
| 49 | 49 | getProviderOptionDescriptors({ |
| 50 | 50 | caps: capabilities, |
| 51 | 51 | selections: selection.options, |
| 52 | 52 | }), |
| 53 | selection.options, | |
| 53 | 54 | ); |
| 54 | 55 | return options |
| 55 | 56 | ? { ...selection, options } |
apps/server/src/cli/project.ts-2| ... | ... | @@ -30,7 +30,6 @@ import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSn |
| 30 | 30 | import { OrchestrationLayerLive } from "../orchestration/runtimeLayer.ts"; |
| 31 | 31 | import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts"; |
| 32 | 32 | import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; |
| 33 | import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts"; | |
| 34 | 33 | import { |
| 35 | 34 | clearPersistedServerRuntimeState, |
| 36 | 35 | readPersistedServerRuntimeState, |
| ... | ... | @@ -481,7 +480,6 @@ const projectAddCommand = Command.make("add", { |
| 481 | 480 | projectId, |
| 482 | 481 | title, |
| 483 | 482 | workspaceRoot, |
| 484 | defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), | |
| 485 | 483 | createdAt: DateTime.formatIso(yield* DateTime.now), |
| 486 | 484 | }); |
| 487 | 485 | return `Added project ${projectId} (${title}) at ${workspaceRoot}.`; |
apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts+55-2| ... | ... | @@ -1,4 +1,11 @@ |
| 1 | import { CommandId, EventId, ProjectId, type OrchestrationEvent } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | CommandId, | |
| 3 | EventId, | |
| 4 | ProjectId, | |
| 5 | ProviderInstanceId, | |
| 6 | type ModelSelection, | |
| 7 | type OrchestrationEvent, | |
| 8 | } from "@t3tools/contracts"; | |
| 2 | 9 | import { expect, it } from "@effect/vitest"; |
| 3 | 10 | import * as Effect from "effect/Effect"; |
| 4 | 11 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| ... | ... | @@ -31,7 +38,53 @@ const seedProjectCreated = (sequence: number): OrchestrationEvent => ({ |
| 31 | 38 | }, |
| 32 | 39 | }); |
| 33 | 40 | |
| 34 | it.layer(NodeServices.layer)("decider project defaultThreadEnvMode", (it) => { | |
| 41 | it.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 | 88 | it.effect("propagates defaultThreadEnvMode through meta.update into the read model", () => |
| 36 | 89 | Effect.gen(function* () { |
| 37 | 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 | 219 | projectId: command.projectId, |
| 220 | 220 | title: command.title, |
| 221 | 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 | 226 | faviconPath: null, |
| 224 | 227 | scripts: [], |
| 225 | 228 | createdAt: command.createdAt, |
apps/server/src/persistence/Migrations.ts+2| ... | ... | @@ -55,6 +55,7 @@ import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; |
| 55 | 55 | import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts"; |
| 56 | 56 | import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts"; |
| 57 | 57 | import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts"; |
| 58 | import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.ts"; | |
| 58 | 59 | |
| 59 | 60 | /** |
| 60 | 61 | * Migration loader with all migrations defined inline. |
| ... | ... | @@ -110,6 +111,7 @@ export const migrationEntries = [ |
| 110 | 111 | [41, "AuthSessionClientConnection", Migration0041], |
| 111 | 112 | [42, "ProjectionThreadLinkedPullRequest", Migration0042], |
| 112 | 113 | [43, "ProjectionThreadsUnsettledAt", Migration0043], |
| 114 | [44, "ClearAutomaticProjectModelDefaults", Migration0044], | |
| 113 | 115 | ] as const; |
| 114 | 116 | |
| 115 | 117 | export 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 | 380 | }), |
| 381 | 381 | ); |
| 382 | 382 | }); |
| 383 | ||
| 384 | layer("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 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | ||
| 4 | export 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 | 16 | import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; |
| 17 | 17 | import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; |
| 18 | 18 | |
| 19 | it("uses the canonical Codex default for auto-bootstrapped model selection", () => { | |
| 20 | assert.deepStrictEqual(ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), { | |
| 19 | it("uses the canonical Codex default for the auto-bootstrapped welcome thread", () => { | |
| 20 | assert.deepStrictEqual(ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), { | |
| 21 | 21 | instanceId: ProviderInstanceId.make("codex"), |
| 22 | 22 | model: DEFAULT_MODEL, |
| 23 | 23 | }); |
| ... | ... | @@ -158,7 +158,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa |
| 158 | 158 | id: bootstrapProjectId, |
| 159 | 159 | title: "Startup Project", |
| 160 | 160 | workspaceRoot: "/tmp/startup-project", |
| 161 | defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), | |
| 161 | defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), | |
| 162 | 162 | scripts: [], |
| 163 | 163 | createdAt: "2026-01-01T00:00:00.000Z", |
| 164 | 164 | updatedAt: "2026-01-01T00:00:00.000Z", |
| ... | ... | @@ -197,7 +197,13 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa |
| 197 | 197 | |
| 198 | 198 | it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when missing", () => |
| 199 | 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 | 207 | const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( |
| 202 | 208 | Effect.provideService(ServerConfig.ServerConfig, { |
| 203 | 209 | cwd: "/tmp/startup-project", |
| ... | ... | @@ -224,7 +230,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when |
| 224 | 230 | Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { |
| 225 | 231 | readEvents: () => Stream.empty, |
| 226 | 232 | dispatch: (command) => |
| 227 | Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( | |
| 233 | Ref.update(dispatchCalls, (calls) => [...calls, command]).pipe( | |
| 228 | 234 | Effect.as({ sequence: 1 }), |
| 229 | 235 | ), |
| 230 | 236 | streamDomainEvents: Stream.empty, |
| ... | ... | @@ -236,7 +242,16 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when |
| 236 | 242 | |
| 237 | 243 | assert.equal(typeof targets.bootstrapProjectId, "string"); |
| 238 | 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 | ); |
| 242 | 257 |
apps/server/src/serverRuntimeStartup.ts+6-7| ... | ... | @@ -166,7 +166,7 @@ export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( |
| 166 | 166 | Effect.asVoid, |
| 167 | 167 | ); |
| 168 | 168 | |
| 169 | export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({ | |
| 169 | export const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ | |
| 170 | 170 | instanceId: ProviderInstanceId.make("codex"), |
| 171 | 171 | model: DEFAULT_MODEL, |
| 172 | 172 | }); |
| ... | ... | @@ -199,26 +199,25 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { |
| 199 | 199 | serverConfig.cwd, |
| 200 | 200 | ); |
| 201 | 201 | let nextProjectId: ProjectId; |
| 202 | let nextProjectDefaultModelSelection: ModelSelection; | |
| 202 | let nextThreadModelSelection: ModelSelection; | |
| 203 | 203 | |
| 204 | 204 | if (Option.isNone(existingProject)) { |
| 205 | 205 | const createdAt = DateTime.formatIso(yield* DateTime.now); |
| 206 | 206 | nextProjectId = ProjectId.make(yield* randomUUID); |
| 207 | 207 | const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project"; |
| 208 | nextProjectDefaultModelSelection = getAutoBootstrapDefaultModelSelection(); | |
| 208 | nextThreadModelSelection = getAutoBootstrapThreadModelSelection(); | |
| 209 | 209 | yield* orchestrationEngine.dispatch({ |
| 210 | 210 | type: "project.create", |
| 211 | 211 | commandId: CommandId.make(yield* randomUUID), |
| 212 | 212 | projectId: nextProjectId, |
| 213 | 213 | title: bootstrapProjectTitle, |
| 214 | 214 | workspaceRoot: serverConfig.cwd, |
| 215 | defaultModelSelection: nextProjectDefaultModelSelection, | |
| 216 | 215 | createdAt, |
| 217 | 216 | }); |
| 218 | 217 | } else { |
| 219 | 218 | nextProjectId = existingProject.value.id; |
| 220 | nextProjectDefaultModelSelection = | |
| 221 | existingProject.value.defaultModelSelection ?? getAutoBootstrapDefaultModelSelection(); | |
| 219 | nextThreadModelSelection = | |
| 220 | existingProject.value.defaultModelSelection ?? getAutoBootstrapThreadModelSelection(); | |
| 222 | 221 | } |
| 223 | 222 | |
| 224 | 223 | const existingThreadId = |
| ... | ... | @@ -232,7 +231,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { |
| 232 | 231 | threadId: createdThreadId, |
| 233 | 232 | projectId: nextProjectId, |
| 234 | 233 | title: "New thread", |
| 235 | modelSelection: nextProjectDefaultModelSelection, | |
| 234 | modelSelection: nextThreadModelSelection, | |
| 236 | 235 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 237 | 236 | runtimeMode: "full-access", |
| 238 | 237 | branch: null, |
apps/web/src/components/CommandPalette.tsx+2-13| ... | ... | @@ -157,11 +157,7 @@ import { |
| 157 | 157 | } from "./ThreadCommandSubtitle"; |
| 158 | 158 | import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; |
| 159 | 159 | import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; |
| 160 | import { | |
| 161 | deriveProviderInstanceEntries, | |
| 162 | resolveDefaultProviderModelSelection, | |
| 163 | type ProviderInstanceEntry, | |
| 164 | } from "../providerInstances"; | |
| 160 | import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; | |
| 165 | 161 | import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; |
| 166 | 162 | import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command"; |
| 167 | 163 | import { Button } from "./ui/button"; |
| ... | ... | @@ -1873,10 +1869,6 @@ function OpenCommandPaletteDialog(props: { |
| 1873 | 1869 | } |
| 1874 | 1870 | |
| 1875 | 1871 | const projectId = newProjectId(); |
| 1876 | const targetEnvironmentProviders = | |
| 1877 | environments.find((environment) => environment.environmentId === input.environmentId) | |
| 1878 | ?.serverConfig?.providers ?? | |
| 1879 | (input.environmentId === primaryEnvironmentId ? providers : []); | |
| 1880 | 1872 | const createResult = await createProject({ |
| 1881 | 1873 | environmentId: input.environmentId, |
| 1882 | 1874 | input: { |
| ... | ... | @@ -1884,10 +1876,7 @@ function OpenCommandPaletteDialog(props: { |
| 1884 | 1876 | title: inferProjectTitleFromPath(cwd), |
| 1885 | 1877 | workspaceRoot: cwd, |
| 1886 | 1878 | createWorkspaceRootIfMissing: true, |
| 1887 | defaultModelSelection: resolveDefaultProviderModelSelection( | |
| 1888 | targetEnvironmentProviders, | |
| 1889 | null, | |
| 1890 | ), | |
| 1879 | defaultModelSelection: null, | |
| 1891 | 1880 | }, |
| 1892 | 1881 | }); |
| 1893 | 1882 | if (createResult._tag === "Failure") { |
apps/web/src/components/chat/composerProviderState.test.tsx+4-6| ... | ... | @@ -69,7 +69,7 @@ describe("getComposerProviderState", () => { |
| 69 | 69 | ); |
| 70 | 70 | }); |
| 71 | 71 | |
| 72 | it("returns descriptor defaults when no selections are provided", () => { | |
| 72 | it("uses descriptor defaults for display without dispatching them as overrides", () => { | |
| 73 | 73 | const state = getComposerProviderState({ |
| 74 | 74 | provider: PROVIDER, |
| 75 | 75 | model: MODEL, |
| ... | ... | @@ -86,7 +86,7 @@ describe("getComposerProviderState", () => { |
| 86 | 86 | expect(state).toEqual({ |
| 87 | 87 | provider: PROVIDER, |
| 88 | 88 | promptEffort: "high", |
| 89 | modelOptionsForDispatch: selections(["effort", "high"]), | |
| 89 | modelOptionsForDispatch: undefined, | |
| 90 | 90 | }); |
| 91 | 91 | }); |
| 92 | 92 | |
| ... | ... | @@ -165,9 +165,7 @@ describe("getComposerProviderState", () => { |
| 165 | 165 | }); |
| 166 | 166 | |
| 167 | 167 | 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"])); | |
| 171 | 169 | }); |
| 172 | 170 | |
| 173 | 171 | it("drops the plan agent from dispatch when legacy plan mode is disabled", () => { |
| ... | ... | @@ -219,7 +217,7 @@ describe("getComposerProviderState", () => { |
| 219 | 217 | planModeEnabled: false, |
| 220 | 218 | }); |
| 221 | 219 | |
| 222 | expect(state.modelOptionsForDispatch).toEqual(selections(["agent", "research"])); | |
| 220 | expect(state.modelOptionsForDispatch).toBeUndefined(); | |
| 223 | 221 | }); |
| 224 | 222 | |
| 225 | 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 | 6 | type ServerProviderModel, |
| 7 | 7 | } from "@t3tools/contracts"; |
| 8 | 8 | import { |
| 9 | buildProviderOptionSelectionsFromDescriptors, | |
| 9 | buildExplicitProviderOptionSelectionsFromDescriptors, | |
| 10 | 10 | getProviderOptionCurrentValue, |
| 11 | 11 | getProviderOptionDescriptors, |
| 12 | 12 | isClaudeUltrathinkPrompt, |
| ... | ... | @@ -94,7 +94,10 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com |
| 94 | 94 | return { |
| 95 | 95 | provider, |
| 96 | 96 | promptEffort, |
| 97 | modelOptionsForDispatch: buildProviderOptionSelectionsFromDescriptors(descriptors), | |
| 97 | modelOptionsForDispatch: buildExplicitProviderOptionSelectionsFromDescriptors( | |
| 98 | descriptors, | |
| 99 | modelOptions, | |
| 100 | ), | |
| 98 | 101 | ...(ultrathinkActive |
| 99 | 102 | ? { |
| 100 | 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 | 52 | GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list |
| 53 | 53 | when starting a thread or changing an existing thread's model. |
| 54 | 54 | |
| 55 | ## Model defaults | |
| 56 | ||
| 57 | T3 Code remembers the last provider, model, and model options you selected and reuses that | |
| 58 | selection for new threads. A model configured in a project's settings overrides the remembered | |
| 59 | selection for that project; resetting the project setting returns it to the remembered selection. | |
| 60 | ||
| 61 | Model options shown as provider defaults remain display values until you choose them in T3 Code. | |
| 62 | T3 Code only sends options you selected explicitly, so an unset reasoning level or service tier can | |
| 63 | still come from the provider's own configuration. | |
| 64 | ||
| 55 | 65 | ## Quote an assistant response |
| 56 | 66 | |
| 57 | 67 | On 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 | 708 | title: TrimmedNonEmptyString, |
| 709 | 709 | workspaceRoot: TrimmedNonEmptyString, |
| 710 | 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 | 713 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), |
| 712 | 714 | createdAt: IsoDateTime, |
| 713 | 715 | }); |
packages/shared/src/model.test.ts+17| ... | ... | @@ -3,6 +3,7 @@ import { ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts"; |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | applyClaudePromptEffortPrefix, |
| 6 | buildExplicitProviderOptionSelectionsFromDescriptors, | |
| 6 | 7 | buildProviderOptionSelectionsFromDescriptors, |
| 7 | 8 | createModelCapabilities, |
| 8 | 9 | createModelSelection, |
| ... | ... | @@ -111,6 +112,22 @@ describe("descriptor helpers", () => { |
| 111 | 112 | ]); |
| 112 | 113 | }); |
| 113 | 114 | |
| 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 | 131 | it("stores option selection arrays in model selections", () => { |
| 115 | 132 | expect( |
| 116 | 133 | createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ |
packages/shared/src/model.ts+14| ... | ... | @@ -204,6 +204,20 @@ export function buildProviderOptionSelectionsFromDescriptors( |
| 204 | 204 | return nextSelections.length > 0 ? nextSelections : undefined; |
| 205 | 205 | } |
| 206 | 206 | |
| 207 | export 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 | ||
| 207 | 221 | export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { |
| 208 | 222 | return typeof text === "string" && /\bultrathink\b/i.test(text); |
| 209 | 223 | } |