diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index e823d2ddf33a3..0f441b26e4fb1 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1161,9 +1161,11 @@ export function deriveEffectiveComposerModelState(input: { : baseModel; const modelOptions = modelSelectionByProviderToOptions(input.draft?.modelSelectionByProvider) ?? - providerSelectionsFromModelSelection(input.threadModelSelection) ?? - providerSelectionsFromModelSelection(input.projectModelSelection) ?? - null; + // A thread saved without options keeps provider defaults; only threads with + // no selection of their own inherit the project/settings default options. + (input.threadModelSelection + ? providerSelectionsFromModelSelection(input.threadModelSelection) + : providerSelectionsFromModelSelection(input.projectModelSelection)); return { selectedModel, diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 05a7e76b6b25b..301323af3bbbd 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -361,6 +361,25 @@ describe("instance-scoped model selection", () => { expect(dispatch.modelOptionsForDispatch).toBeUndefined(); }); + it("does not apply default options to an existing thread saved without options", () => { + const instanceId = ProviderInstanceId.make("claudeAgent"); + const driver = ProviderDriverKind.make("claudeAgent"); + const providers = [provider({ provider: driver, instanceId, models: ["claude-opus-5-5"] })]; + const state = deriveEffectiveComposerModelState({ + draft: null, + providers, + selectedProvider: driver, + selectedInstanceId: instanceId, + threadModelSelection: createModelSelection(instanceId, "claude-opus-5-5"), + projectModelSelection: createModelSelection(instanceId, "claude-opus-5-5", [ + { id: "effort", value: "high" }, + ]), + settings: settingsWithProviderInstances(), + }); + + expect(state.modelOptions?.[instanceId]).toBeUndefined(); + }); + it("preserves an explicit draft OpenCode selection while the catalog is empty", () => { const instanceId = ProviderInstanceId.make("opencode_work"); const driver = ProviderDriverKind.make("opencode"); diff --git a/knip.jsonc b/knip.jsonc index c2093737fd942..08d9109510a45 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -27,6 +27,8 @@ "entry": [ // These fixture scripts are injected into a browser page by path. "fixtures/*/*.js", + // Manual UI proofs import these fixtures by path. + "fixtures/*.mjs", "knip-schemas.ts", "mobile-behavior/run.ts", "mobile-native-client.ts", diff --git a/scripts/fixtures/composer-option-fallback.mjs b/scripts/fixtures/composer-option-fallback.mjs new file mode 100644 index 0000000000000..79038e46a9486 --- /dev/null +++ b/scripts/fixtures/composer-option-fallback.mjs @@ -0,0 +1,42 @@ +export const selection = { instanceId: "claudeAgent", model: "claude-opus-5-5" }; + +export async function seedComposerOptionFallback(rpc, workspaceRoot) { + const call = async (tag, payload) => { + const exit = await rpc(tag, payload); + if (exit._tag !== "Success") throw new Error(JSON.stringify(exit)); + return exit.value; + }; + await call("server.updateSettings", { + patch: { defaultModelSelection: { ...selection, options: [{ id: "effort", value: "high" }] } }, + }); + await call("projects.mutate", { + type: "project.create", + commandId: "proof-project-create", + projectId: "proof-project", + title: "Synthetic proof", + workspaceRoot, + }); + for (const row of ["V1", "V2", "V3", "V4", "A2", "B"]) { + await call("orchestration.dispatchCommand", { + type: "thread.create", + commandId: `proof-create-${row}`, + createdBy: "agent", + creationSource: "mcp", + threadId: `proof-${row}`, + projectId: "proof-project", + title: "Option fallback proof", + modelSelection: { + ...selection, + ...(row === "A2" + ? { options: [] } + : row === "B" + ? { options: [{ id: "effort", value: "low" }] } + : {}), + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + } +}