From d81ed2b722778e23fc2f849df1723e90c82c3c65 Mon Sep 17 00:00:00 2001 From: hui455 <2075649045@qq.com> Date: Sun, 20 Sep 2026 20:19:37 +0800 Subject: [PATCH] feat(composer): remember model and reasoning choices Keep explicit Composer selections available to subsequent conversations without rewriting global defaults or propagating session permissions. Restore reasoning preferences per provider and model, with validated fallbacks and protection against stale configuration completions. Refs #678 --- CHANGELOG.md | 9 + apps/desktop/src/components/Composer.tsx | 40 +-- .../composer/hooks/useComposerModelMenu.ts | 24 +- .../src/lib/composer-model-preferences.ts | 145 ++++++++ apps/desktop/src/stores/app-state.ts | 2 +- .../stores/runtime/session-coordination.ts | 18 +- .../src/stores/slices/session-slice.ts | 13 +- .../test/composer-model-preferences.test.mjs | 158 +++++++++ apps/desktop/test/session-create.test.mjs | 2 +- apps/desktop/test/thinking-ui.test.mjs | 15 +- docs/adr/README.md | 1 + .../remember-composer-model-preferences.md | 41 +++ .../13-model-catalog-and-selection.md | 39 ++- docs/spec/06-delivery/04-e2e-test-plan.md | 23 ++ .../13-model-catalog-and-selection.md | 20 +- .../spec/06-delivery/04-e2e-test-plan.md | 14 + scripts/e2e-composer-preferences.mjs | 149 ++++++++ scripts/e2e/composer-preferences.jsx | 331 ++++++++++++++++++ 18 files changed, 960 insertions(+), 84 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 apps/desktop/src/lib/composer-model-preferences.ts create mode 100644 apps/desktop/test/composer-model-preferences.test.mjs create mode 100644 docs/adr/remember-composer-model-preferences.md create mode 100644 scripts/e2e-composer-preferences.mjs create mode 100644 scripts/e2e/composer-preferences.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..b435058eaf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## Unreleased + +- New desktop conversations remember the last model explicitly selected in the + Composer and restore reasoning preferences separately for each provider/model. + Unavailable models fall back to Settings; permissions keep their global default. + +Released, localized in-app notes remain in `packages/shared/src/changelog.ts`. diff --git a/apps/desktop/src/components/Composer.tsx b/apps/desktop/src/components/Composer.tsx index a60883b20b..d0ef2305e8 100644 --- a/apps/desktop/src/components/Composer.tsx +++ b/apps/desktop/src/components/Composer.tsx @@ -17,6 +17,7 @@ import { stripInlineComposerFileReferenceTokens, } from "@pi-desktop/shared"; import { useAppStore } from "../stores/app-store"; +import { newSessionModelConfiguration } from "../lib/composer-model-preferences"; import { latestTurnContextInspector } from "../lib/latest-turn-context"; import { isActivePlanExecution } from "../lib/plan-mode-state"; import { headAsk, queuedAskCount } from "../lib/pending-asks"; @@ -323,18 +324,19 @@ export function Composer({ : sessionPermissionMode; const composerPermissionMode: Exclude = mode === "goal" ? "auto" : effectivePermissionMode; + const newModel = !activeSession + ? newSessionModelConfiguration({ + draft: draftConfiguration, + settings, + providers, + }) + : undefined; const provider = providers.find( - (candidate) => - candidate.id === - (activeSession?.providerId ?? - (!activeSession ? draftConfiguration?.providerId : undefined) ?? - settings?.defaultProviderId), + (candidate) => candidate.id === + (activeSession?.providerId ?? newModel?.providerId ?? settings?.defaultProviderId), ); - const modelId = - activeSession?.modelId ?? - (!activeSession ? draftConfiguration?.modelId : undefined) ?? - settings?.defaultModelId ?? - provider?.defaultModelId; + const modelId = activeSession?.modelId ?? newModel?.modelId ?? + settings?.defaultModelId ?? provider?.defaultModelId; const selectedModelCatalog = provider ? providerModels[provider.id] : undefined; const catalogThinkingProvider = thinkingProviderForModel( provider, @@ -347,19 +349,11 @@ export function Composer({ activeSession, catalogThinkingProvider, }); - const selectedBinding = provider?.models.find((candidate) => - modelIdsMatch(candidate.id, modelId ?? ""), - ); - // A draft without a session starts at the selected model's stored default - // thinking level, clamped onto that binding's enabled ladder. - const draftThinkingLevel = initialThinkingLevelForBinding( - selectedBinding, - thinkingProvider?.supportedThinkingLevels, - ); - const sessionThinkingLevel = - activeSession?.thinkingLevel ?? - (!activeSession ? draftConfiguration?.thinkingLevel : undefined) ?? - draftThinkingLevel; + const sessionThinkingLevel = activeSession?.thinkingLevel ?? newModel?.thinkingLevel ?? + initialThinkingLevelForBinding( + provider?.models.find((candidate) => modelIdsMatch(candidate.id, modelId ?? "")), + thinkingProvider?.supportedThinkingLevels, + ); const configuredThinkingLevel = isThinkingLevel(sessionThinkingLevel) ? sessionThinkingLevel : "off"; diff --git a/apps/desktop/src/features/chat/composer/hooks/useComposerModelMenu.ts b/apps/desktop/src/features/chat/composer/hooks/useComposerModelMenu.ts index a457d5adfa..0b9d73d641 100644 --- a/apps/desktop/src/features/chat/composer/hooks/useComposerModelMenu.ts +++ b/apps/desktop/src/features/chat/composer/hooks/useComposerModelMenu.ts @@ -4,11 +4,8 @@ import type { ProviderPublic, SessionThinkingLevel, } from "@pi-desktop/shared"; -import { - initialThinkingLevelForBinding, - modelIdsMatch, -} from "@pi-desktop/shared"; import { useAppStore } from "../../../../stores/app-store"; +import { rememberedComposerThinking } from "../../../../lib/composer-model-preferences"; import { composerModelMatchesQuery, composerModelsForProvider, @@ -18,7 +15,6 @@ import { import { providerThinkingLevels } from "../../../../lib/session-thinking"; import { sessionThinkingMenuLevels, - thinkingLevelForProvider, thinkingProviderForModel, type ComposerMenuView, } from "../model"; @@ -83,7 +79,7 @@ export function useComposerModelMenu({ providerId: current.providerId, modelId: current.modelId, thinkingLevel: level, - }); + }, { rememberModel: true }); }, onError: (error) => { const current = thinkingConfigRef.current; @@ -245,29 +241,27 @@ export function useComposerModelMenu({ }; const selectModel = async (candidate: ProviderPublic, nextModelId: string) => { + const sessionId = useAppStore.getState().activeSessionId; thinkingQueueRef.current?.invalidate(); await thinkingQueueRef.current?.idle(); + if (useAppStore.getState().activeSessionId !== sessionId) return; try { const nextModelProvider = thinkingProviderForModel( candidate, nextModelId, providerModels[candidate.id], ); - const nextBinding = candidate.models.find((entry) => - modelIdsMatch(entry.id, nextModelId), + const nextThinkingLevel = rememberedComposerThinking( + candidate, + nextModelId, + nextModelProvider?.supportedThinkingLevels, ); - const nextThinkingLevel = activeSessionId - ? thinkingLevelForProvider(nextModelProvider, thinkingLevel) - : initialThinkingLevelForBinding( - nextBinding, - nextModelProvider?.supportedThinkingLevels, - ); await configureActiveSession({ mode, providerId: candidate.id, modelId: nextModelId, thinkingLevel: nextThinkingLevel, - }); + }, { rememberModel: true }); setQuery(""); setView("root"); setModelHighlight(-1); diff --git a/apps/desktop/src/lib/composer-model-preferences.ts b/apps/desktop/src/lib/composer-model-preferences.ts new file mode 100644 index 0000000000..96da5b597e --- /dev/null +++ b/apps/desktop/src/lib/composer-model-preferences.ts @@ -0,0 +1,145 @@ +import { + initialThinkingLevelForBinding, + isSessionThinkingLevel, + modelIdsMatch, + type ProviderPublic, + type SessionThinkingLevel, +} from "@pi-desktop/shared"; +import { + inheritedSessionModelBinding, + type SessionModelRef, + type SessionModelSettings, +} from "./session-model"; + +export const COMPOSER_MODEL_PREFERENCES_KEY = + "pi.desktop.composerModelPreferences.v1"; +const MAX_CHOICES = 100; +type Choice = Required & { + thinkingLevel: SessionThinkingLevel; +}; + +function isChoice(value: unknown): value is Choice { + if (!value || typeof value !== "object") return false; + const row = value as Record; + return ( + typeof row.providerId === "string" && + row.providerId.length > 0 && + typeof row.modelId === "string" && + row.modelId.length > 0 && + isSessionThinkingLevel(row.thinkingLevel) + ); +} + +function readChoices(): Choice[] { + try { + const raw: unknown = JSON.parse( + globalThis.localStorage?.getItem(COMPOSER_MODEL_PREFERENCES_KEY) ?? + "null", + ); + return Array.isArray(raw) ? raw.filter(isChoice).slice(0, MAX_CHOICES) : []; + } catch { + // Missing, blocked or corrupt device preferences fall back to Settings. + return []; + } +} + +/** Only explicit Composer model/reasoning actions call this, never session navigation. */ +export function rememberComposerModel( + choice: SessionModelRef & { thinkingLevel: SessionThinkingLevel }, +): void { + if (!isChoice(choice)) return; + const choices = readChoices().filter( + (entry) => + entry.providerId !== choice.providerId || + !modelIdsMatch(entry.modelId, choice.modelId), + ); + try { + globalThis.localStorage?.setItem( + COMPOSER_MODEL_PREFERENCES_KEY, + JSON.stringify( + [ + { + providerId: choice.providerId, + modelId: choice.modelId, + thinkingLevel: choice.thinkingLevel, + }, + ...choices, + ].slice(0, MAX_CHOICES), + ), + ); + } catch { + // Optional device preferences must not prevent configuring a session. + } +} + +export function rememberedComposerThinking( + provider: ProviderPublic | undefined, + modelId: string | undefined, + fallbackLevels = provider?.supportedThinkingLevels, +): SessionThinkingLevel { + const binding = provider?.models.find((entry) => + modelIdsMatch(entry.id, modelId ?? ""), + ); + const choice = readChoices().find( + (entry) => + entry.providerId === provider?.id && + modelIdsMatch(entry.modelId, modelId ?? ""), + ); + return initialThinkingLevelForBinding( + choice + ? { + thinkingLevels: binding?.thinkingLevels ?? fallbackLevels ?? [], + defaultThinkingLevel: choice.thinkingLevel, + } + : binding, + fallbackLevels, + ); +} + +/** Draft > last explicit usable choice > Settings. Existing sessions do not call this. */ +export function newSessionModelConfiguration({ + draft, + settings, + providers, +}: { + draft?: (SessionModelRef & { thinkingLevel?: SessionThinkingLevel }) | null; + settings?: SessionModelSettings | null; + providers: readonly ProviderPublic[]; +}): SessionModelRef & { thinkingLevel: SessionThinkingLevel } { + const last = readChoices()[0]; + const rememberedProvider = providers.find( + (provider) => + provider.id === last?.providerId && + provider.enabled && + (provider.hasSecret || provider.authKind === "none"), + ); + const rememberedModel = rememberedProvider?.models.length + ? rememberedProvider.models.find((entry) => + modelIdsMatch(entry.id, last?.modelId ?? ""), + )?.id + : rememberedProvider?.defaultModelId && + modelIdsMatch(rememberedProvider.defaultModelId, last?.modelId ?? "") + ? rememberedProvider.defaultModelId + : undefined; + const explicitModel = + draft?.providerId !== undefined || draft?.modelId !== undefined; + const inherited = inheritedSessionModelBinding({ + draft: explicitModel + ? draft + : rememberedModel + ? { + providerId: rememberedProvider?.id, + modelId: rememberedModel, + } + : null, + settings, + providers, + }); + const provider = providers.find((entry) => entry.id === inherited.providerId); + return { + ...inherited, + thinkingLevel: + draft?.thinkingLevel ?? + rememberedComposerThinking(provider, inherited.modelId), + }; +} diff --git a/apps/desktop/src/stores/app-state.ts b/apps/desktop/src/stores/app-state.ts index 093175da1a..ecb49fa165 100644 --- a/apps/desktop/src/stores/app-state.ts +++ b/apps/desktop/src/stores/app-state.ts @@ -200,7 +200,7 @@ export type AppState = { modelId?: string; thinkingLevel: SessionThinkingLevel; permissionMode?: PermissionMode; - }) => Promise; + }, options?: { rememberModel?: boolean }) => Promise; /** Returns true once accepted unless concurrent smart Stop restores it. */ sendPrompt: ( content: string, diff --git a/apps/desktop/src/stores/runtime/session-coordination.ts b/apps/desktop/src/stores/runtime/session-coordination.ts index d997341396..8b93c8b955 100644 --- a/apps/desktop/src/stores/runtime/session-coordination.ts +++ b/apps/desktop/src/stores/runtime/session-coordination.ts @@ -6,8 +6,6 @@ import type { } from "@pi-desktop/shared"; import { contextCompactionMark, - initialThinkingLevelForBinding, - modelIdsMatch, normalizeMode, } from "@pi-desktop/shared"; import { api } from "../../lib/api"; @@ -18,7 +16,7 @@ import { FORKED_SESSION_WINDOW, } from "../../lib/session-fork"; import { EMPTY_SESSION_WINDOW } from "../../lib/session-create"; -import { inheritedSessionModelBinding } from "../../lib/session-model"; +import { newSessionModelConfiguration } from "../../lib/composer-model-preferences"; import { clearSessionPanes, retainSessionPane, @@ -280,21 +278,11 @@ export function createSessionCoordination({ options && "draftConfiguration" in options ? options.draftConfiguration : state.draftConfiguration; - const inherited = inheritedSessionModelBinding({ + const inherited = newSessionModelConfiguration({ draft: draftConfig, settings, providers: state.providers, }); - const defaultProvider = state.providers.find( - (provider) => provider.id === inherited.providerId, - ); - const inheritedBinding = defaultProvider?.models.find((candidate) => - modelIdsMatch(candidate.id, inherited.modelId ?? ""), - ); - const defaultThinkingLevel = initialThinkingLevelForBinding( - inheritedBinding, - defaultProvider?.supportedThinkingLevels, - ); const previousSessionId = state.activeSessionId; revealEmptyCreatingSession(active); let created: Awaited>; @@ -302,7 +290,7 @@ export function createSessionCoordination({ created = await api.createSession({ title: untitledTaskTitle(), mode: draftConfig?.mode ?? normalizeMode(settings?.defaultMode), - thinkingLevel: draftConfig?.thinkingLevel ?? defaultThinkingLevel, + thinkingLevel: inherited.thinkingLevel, permissionMode: draftConfig?.permissionMode, providerId: inherited.providerId, modelId: inherited.modelId, diff --git a/apps/desktop/src/stores/slices/session-slice.ts b/apps/desktop/src/stores/slices/session-slice.ts index bb7c986b0a..b0f15c414e 100644 --- a/apps/desktop/src/stores/slices/session-slice.ts +++ b/apps/desktop/src/stores/slices/session-slice.ts @@ -1,4 +1,5 @@ import i18n from "i18next"; +import { rememberComposerModel } from "../../lib/composer-model-preferences"; import { prepareTranscriptAction } from "../runtime/transcript-action"; import type { Mode, @@ -119,6 +120,7 @@ export function createSessionSlice({ | "configureActiveSession" | "abortSession" > { + let modelChoiceIntent = 0; const refreshSessionList = createRefreshCoordinator(async () => { const result = await api.listSessions(); set({ sessions: decorateSessions(result.sessions, get().sessionMeta) }); @@ -574,9 +576,16 @@ export function createSessionSlice({ } }, - configureActiveSession: async (config) => { + configureActiveSession: async (config, options) => { const sessionId = get().activeSessionId; + const choiceIntent = options?.rememberModel ? ++modelChoiceIntent : undefined; + const rememberChoice = () => { + if (choiceIntent !== undefined && choiceIntent === modelChoiceIntent) { + rememberComposerModel(config); + } + }; if (!sessionId) { + rememberChoice(); set((state) => ({ draftConfiguration: { mode: config.mode, @@ -602,6 +611,7 @@ export function createSessionSlice({ config, ), ); + rememberChoice(); set((state) => ({ sessions: state.sessions.map((session) => session.id === sessionId @@ -617,6 +627,7 @@ export function createSessionSlice({ ); runtime.pendingSessionConfigurations.delete(sessionId); const result = await api.configureSession(sessionId, payload); + rememberChoice(); set((state) => ({ sessions: state.sessions.map((session) => session.id === sessionId diff --git a/apps/desktop/test/composer-model-preferences.test.mjs b/apps/desktop/test/composer-model-preferences.test.mjs new file mode 100644 index 0000000000..47b24f3eb4 --- /dev/null +++ b/apps/desktop/test/composer-model-preferences.test.mjs @@ -0,0 +1,158 @@ +import assert from "node:assert/strict"; +import { register } from "node:module"; +import test, { beforeEach } from "node:test"; +register(new URL("./helpers/ts-import-hooks.mjs", import.meta.url)); +const { + COMPOSER_MODEL_PREFERENCES_KEY: key, + rememberComposerModel, + rememberedComposerThinking, + newSessionModelConfiguration, +} = await import("../src/lib/composer-model-preferences.ts"); +const values = new Map(); +beforeEach(() => { + values.clear(); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + }, + }); +}); +const providers = ["a", "b"].map((id) => ({ + id, + enabled: true, + authKind: "none", + models: [ + { + id: "one", + thinkingLevels: ["low", "high"], + defaultThinkingLevel: "high", + }, + { id: "two", thinkingLevels: ["off"] }, + ], +})); +const settings = { defaultProviderId: "a", defaultModelId: "one" }; +const resolve = (extra = {}) => + newSessionModelConfiguration({ providers, settings, ...extra }); +const remember = (providerId, modelId, thinkingLevel) => + rememberComposerModel({ providerId, modelId, thinkingLevel }); + +test("fresh profiles keep Settings defaults; explicit choices survive subsequent reads", () => { + assert.deepEqual(resolve(), { + providerId: "a", + modelId: "one", + thinkingLevel: "high", + }); + remember("b", "one", "low"); + assert.deepEqual(resolve(), { + providerId: "b", + modelId: "one", + thinkingLevel: "low", + }); + assert.deepEqual(JSON.parse(values.get(key)), [ + { providerId: "b", modelId: "one", thinkingLevel: "low" }, + ]); + assert.equal(settings.defaultProviderId, "a"); +}); + +test("thinking is isolated by provider and model, including omit", () => { + remember("a", "one", "low"); + remember("b", "one", "omit"); + remember("a", "two", "off"); + assert.equal(rememberedComposerThinking(providers[0], "one"), "low"); + assert.equal(rememberedComposerThinking(providers[1], "one"), "omit"); + assert.equal(rememberedComposerThinking(providers[0], "two"), "off"); +}); + +test("removed, disabled and unauthenticated choices fall back to Settings, not older choices", () => { + remember("a", "two", "off"); + remember("b", "one", "low"); + for (const unavailable of [ + null, + { ...providers[1], enabled: false }, + { ...providers[1], models: [], defaultModelId: undefined }, + { ...providers[1], authKind: "api-key", hasSecret: false }, + ]) { + assert.equal( + resolve({ + providers: [providers[0], ...(unavailable ? [unavailable] : [])], + }).modelId, + "one", + ); + assert.equal( + resolve({ + providers: [providers[0], ...(unavailable ? [unavailable] : [])], + }).providerId, + "a", + ); + } +}); + +test("draft wins while permissions and operating mode never enter the preference", () => { + rememberComposerModel({ + providerId: "b", + modelId: "one", + thinkingLevel: "low", + permissionMode: "auto", + mode: "goal", + }); + assert.deepEqual( + resolve({ + draft: { providerId: "a", modelId: "two", thinkingLevel: "off" }, + }), + { providerId: "a", modelId: "two", thinkingLevel: "off" }, + ); + assert.equal(values.get(key).includes("auto"), false); + assert.equal(values.get(key).includes("goal"), false); +}); + +test("changed capabilities clamp saved thinking and never send omit to a non-reasoning model", () => { + remember("a", "one", "low"); + const changed = { + ...providers[0], + models: [{ id: "one", thinkingLevels: ["high"] }], + }; + assert.equal(rememberedComposerThinking(changed, "one"), "high"); + remember("a", "one", "omit"); + assert.equal( + rememberedComposerThinking( + { ...changed, models: [{ id: "one", thinkingLevels: ["off"] }] }, + "one", + ), + "off", + ); +}); + +test("invalid data and unavailable storage degrade to defaults without blocking", () => { + for (const raw of [ + "{", + "null", + "{}", + '[{"providerId":"b","modelId":"one","thinkingLevel":"invalid"}]', + ]) { + values.set(key, raw); + assert.equal(resolve().providerId, "a"); + } + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + get() { + throw new Error("blocked"); + }, + }); + assert.doesNotThrow(() => remember("b", "one", "low")); + assert.equal(resolve().providerId, "a"); +}); + +test("history stays bounded and repeated selections replace the matching entry", () => { + for (let index = 0; index < 105; index += 1) + remember("a", `model-${index}`, "low"); + remember("a", "model-104", "high"); + const saved = JSON.parse(values.get(key)); + assert.equal(saved.length, 100); + assert.equal(saved[0].thinkingLevel, "high"); + assert.equal( + saved.filter((entry) => entry.modelId === "model-104").length, + 1, + ); +}); diff --git a/apps/desktop/test/session-create.test.mjs b/apps/desktop/test/session-create.test.mjs index a3a737c029..6b97a32468 100644 --- a/apps/desktop/test/session-create.test.mjs +++ b/apps/desktop/test/session-create.test.mjs @@ -62,7 +62,7 @@ test("creating a session reveals the empty destination before host IO", () => { assert.match(persist, /commitCreatedEmptySession/); assert.match(sessionCoordination, /function commitCreatedEmptySession/); assert.match(sessionCoordination, /scheduleHomeDraftAdopt/); - assert.match(persist, /inheritedSessionModelBinding/); + assert.match(persist, /newSessionModelConfiguration/); assert.match(persist, /providerId: inherited\.providerId/); assert.match(persist, /modelId: inherited\.modelId/); assert.doesNotMatch(persist, /providerId: draftConfig\?\.providerId/); diff --git a/apps/desktop/test/thinking-ui.test.mjs b/apps/desktop/test/thinking-ui.test.mjs index 90b9182349..860b0fd061 100644 --- a/apps/desktop/test/thinking-ui.test.mjs +++ b/apps/desktop/test/thinking-ui.test.mjs @@ -155,16 +155,12 @@ test("draft Composer thinking follows the exact model selected in its menu", () composerSource, /const nextModelProvider = thinkingProviderForModel\([\s\S]*?providerModels\[candidate\.id\]/, ); - assert.match( - composerSource, - /const nextThinkingLevel = activeSession[\s\S]*?thinkingLevelForProvider\(nextModelProvider, thinkingLevel\)[\s\S]*?initialThinkingLevelForBinding\(/, - ); - assert.match(composerSource, /const selectedBinding = provider\?\.models\.find/); - assert.match(composerSource, /const draftThinkingLevel = initialThinkingLevelForBinding\(/); + assert.match(composerSource, /const nextThinkingLevel = rememberedComposerThinking\(/); + assert.match(composerSource, /newSessionModelConfiguration\(/); assert.doesNotMatch(composerSource, /highestSupportedThinkingLevel/); }); -test("new sessions default to the selected model binding's thinking level", () => { +test("new sessions use the shared remembered model and thinking resolver", () => { const materializeSource = sessionCoordinationSource.match( /async function persistSessionAndSelect[\s\S]*?\n }\n\n async function materializeDraftSession/, @@ -173,11 +169,10 @@ test("new sessions default to the selected model binding's thinking level", () = materializeSource.length > 0, "materializeDraftSession implementation not found", ); - assert.match(materializeSource, /initialThinkingLevelForBinding\(/); - assert.match(materializeSource, /inheritedBinding/); + assert.match(materializeSource, /newSessionModelConfiguration\(/); assert.match( materializeSource, - /thinkingLevel:[\s\S]*?defaultThinkingLevel/, + /thinkingLevel: inherited\.thinkingLevel/, ); assert.doesNotMatch(materializeSource, /highestSupportedThinkingLevel\(/); }); diff --git a/docs/adr/README.md b/docs/adr/README.md index e995670e8c..f2712692c4 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -20,6 +20,7 @@ Each ADR includes: | ID | Title | Status | |---|---|---| +| remember-composer-model-preferences | [Remember explicit Composer model and reasoning choices](remember-composer-model-preferences.md) | Accepted | | scheduled-desktop-automations | [Desktop automation scheduling](scheduled-desktop-automations.md) | Accepted for implementation | | subagent-model-fallback | [Ordered subagent model fallback](subagent-model-fallback.md) | Accepted for implementation | | subagent-model-opt-in | [Separate Subagent Model Opt-In from Definition Pins](subagent-model-opt-in.md) | Accepted for implementation | diff --git a/docs/adr/remember-composer-model-preferences.md b/docs/adr/remember-composer-model-preferences.md new file mode 100644 index 0000000000..9808b0750c --- /dev/null +++ b/docs/adr/remember-composer-model-preferences.md @@ -0,0 +1,41 @@ +# ADR: Remember explicit Composer model and reasoning choices + +- Status: Accepted +- Date: 2026-09-20 +- Amends: ADR 0114, ADR 0295 (desktop creation defaults only) +- Related: Issue #678; E2E-COMPOSER-remember-model-and-thinking + +## Context + +New desktop conversations snapshot global defaults even after the user chooses +a different model or reasoning level in the Composer. Repeating that selection +adds friction. Copying an arbitrary viewed conversation would instead turn +history navigation into an unexpected preference change. + +## Decision + +Remember explicit Composer model/reasoning actions as a device preference, +following existing renderer-local approval and sidebar preference ownership. +The versioned localStorage value contains at most 100 provider/model/level +triples, most recently chosen first. Validate stored input and currently usable +provider/model identities on consumption. New session and empty-home display +share one resolver: explicit draft, then last usable choice, then Settings. +Restore thinking per provider/model and clamp against current capabilities. + +Only the model menu opts into recording through the renderer configuration +action; rejected writes, navigation, legacy model pinning, permissions, forks, +and background callers do not record. Accepted next-turn staging does record +user intent, even if the eventual host flush fails. A monotonic action intent +prevents an older successful write from superseding a newer selection. + +## Consequences + +- Updating global defaults on every selection would overwrite an explicit + fallback and add host persistence writes; keep those Settings unchanged. +- Copying the viewed session would confuse browsing with selection and might + propagate Auto permissions. Permissions and operating mode remain unchanged. +- Host-synchronized preferences would add a cross-process contract for a device + interaction preference. No host API, SQLite schema, or migration changes are + needed. The selected session configuration remains host-owned and durable. +- Blocked/full/corrupt browser storage falls back to existing defaults. These + preferences are device/profile-local, not synchronized across devices. diff --git a/docs/spec/03-runtime/13-model-catalog-and-selection.md b/docs/spec/03-runtime/13-model-catalog-and-selection.md index b1cac92e65..8f06abc2d4 100644 --- a/docs/spec/03-runtime/13-model-catalog-and-selection.md +++ b/docs/spec/03-runtime/13-model-catalog-and-selection.md @@ -193,8 +193,23 @@ time, except `omit`, which is preserved on a reasoning model and sends no thinking override. An empty binding or a binding containing only `off` resolves to `off`. -For a newly created session, the renderer resolves the selected (or app-default) -model's `ModelBinding`. A reasoning model starts at that binding's +For a newly created session, the renderer resolves an explicit Composer draft, +then the last usable explicit Composer provider/model choice on this device, +then the app default. The last choice must still belong to an enabled, +authenticated provider and a configured model; otherwise use the app default, +not an older remembered model. This selection never changes Settings defaults. + +Composer model/reasoning actions remember the selected level per provider/model +in versioned, bounded renderer-local device preferences (at most 100 pairs). +Opening old conversations, session pinning, mode/permission changes, forks, +and background API callers do not update this memory. A rejected configuration +does not update it; accepted next-turn staging does. Older async completions +cannot replace a later explicit choice. Storage corruption/unavailability +falls back to Settings and never prevents configuring or creating a session. + +A new session or model selection restores that pair's remembered thinking +level, clamped onto the currently enabled levels (`omit` survives only for +reasoning models). Without a remembered level, a reasoning model starts at the binding's `defaultThinkingLevel` (`omit` is preserved; other values are clamped onto the enabled levels). When the default is unset it falls back to the highest enabled level seeded from published `supportedThinkingLevels`. A non-reasoning or @@ -204,7 +219,7 @@ a creation default only and never rewrites an existing session's stored choice. Unpinned sessions still advertise that inherited default model's reasoning capability on session list/get/create/fork/configure. Enrichment does not pin `providerId`/`modelId`; desktop session create does, by writing the then-current -app default (or Composer draft override) into the durable ids. Later Settings +resolved Composer choice (or Settings fallback) into the durable ids. Later Settings default-model changes do not rewrite an already created session. Opening a legacy row whose ids are still empty snapshots the last used turn, else the current default, so it stops following Settings. The Composer never treats a @@ -374,12 +389,12 @@ App-level default: - if none configured, onboarding checklist requires provider setup before first agent run Session-level: -- inherits app default at creation and stores that `providerId`/`modelId` pair -- later Settings default-model changes apply only to new sessions and the - unpersisted home draft, not to already created sessions -- initializes thinking to the highest level enabled by the inherited model's - binding; published levels seed a new binding, while an empty or `off`-only - binding starts at `off` +- snapshots explicit draft > last usable Composer choice > app default at creation +- Settings defaults remain the fallback; already created sessions keep their pair +- restores thinking per provider/model, then the binding default, then the highest + enabled level; an empty or `off`-only binding starts at `off` +- permission and operating mode still follow existing global defaults; a prior + session's Auto permission is never remembered for new sessions - can override independently ## 11. Capability gating @@ -505,9 +520,9 @@ same model to the check mark, the toggle and the duplicate guard. refresh keeps the cached picker populated - [ ] capability badges visible - [ ] session model change applies to next turn only -- [ ] a newly created session stores the then-current default provider/model, and later default-model changes do not rewrite that session -- [ ] a new session defaults a reasoning-capable inherited model to that - binding's stored default thinking level (clamped onto the enabled set; +- [ ] a newly created session snapshots draft > remembered usable choice > Settings; later default-model changes do not rewrite that session +- [ ] a new session restores remembered thinking for the provider/model, else + the binding's stored default (clamped onto the enabled set; strongest-enabled only when unset) and otherwise defaults to `off` - [ ] the settings picker always exposes the canonical thinking ladder; published levels seed known models and explicit binding levels clamp the diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 29d07c29ea..0db92016c0 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -7786,6 +7786,7 @@ identify the platform validation still needed. | Acceptance | Scenarios | |---|---| +| B / C / F / Quality - Remember Composer model and thinking | E2E-COMPOSER-remember-model-and-thinking | | C / F / Quality — Desktop automations | E2E-SCHEDULED-desktop-automation-lifecycle | | A / C — Unicode stdio framing | E2E-RPC-unicode-separators | | C / G / Quality — Plugins navigation | E2E-NAV-plugins-button-goes-back | @@ -13854,3 +13855,25 @@ the latest destination. These assertions measure work counts, not device FPS. - **Automation:** `pnpm test:e2e:dialog-overflow`; source/contract dialog suites supplement, but do not replace, real Chromium geometry and pointer checks. - **Status:** Implemented. Native Windows evidence; macOS/Linux not qualified. + +### E2E-COMPOSER-remember-model-and-thinking + +- **Preconditions:** Isolated device profile; two configured reasoning models; + global default Alpha/high/Ask; an existing non-empty conversation. +- **Steps:** Select Beta then low in the Composer. Switch to Alpha and back to + Beta. Set the current session's permission to Auto. Open an older Alpha + conversation and create a new task. Reload the renderer and create again. + Reject a configuration write; complete an older write after a newer choice. +- **Expected:** Beta/low is restored both on model return and new task/reload; + new permissions still inherit Ask. Existing sessions and Settings are unchanged. + Navigation and rejected/stale writes do not replace the last explicit choice. + Removed/disabled providers or models fall back to Settings; invalid thinking + is clamped and corrupt/unavailable storage degrades safely. +- **Specs:** `03-runtime/13-model-catalog-and-selection.md`, + `04-ux/03-permission-ux.md`. +- **Acceptance:** B / C / F / Quality. +- **Milestone:** Post-MVP. +- **Status:** Automated with `node scripts/e2e-composer-preferences.mjs` + (real React/Chromium/renderer wiring and localStorage; host API fixtures, + no live profiles or provider calls). Invalid-data cases are covered + by `apps/desktop/test/composer-model-preferences.test.mjs`. diff --git a/docs/zh-CN/spec/03-runtime/13-model-catalog-and-selection.md b/docs/zh-CN/spec/03-runtime/13-model-catalog-and-selection.md index 28d0cf990d..a8efaadbed 100644 --- a/docs/zh-CN/spec/03-runtime/13-model-catalog-and-selection.md +++ b/docs/zh-CN/spec/03-runtime/13-model-catalog-and-selection.md @@ -111,7 +111,15 @@ type RecentModelRef = { 在执行时对所选模型绑定的已启用档位钳位,但 `omit` 在推理模型上保留, 且不发送思考覆盖。 -对于新创建的会话,渲染器会解析所选(或应用默认)模型的 `ModelBinding`。 +新会话按“显式草稿 > 本机最近主动选择且仍可用的供应商/模型 > 全局默认”确定模型。 +记忆模型必须仍在启用且已认证的供应商配置中;失效时回退全局默认,不使用更早的记忆。 +思考等级按供应商和模型分别记忆,恢复时校验当前可用档位;`omit` 仅对推理模型保留。 +记忆使用版本化的本机渲染器偏好,最多保存 100 对,不修改全局设置或已有会话。 +仅模型/思考菜单的主动操作更新记忆;浏览历史、分叉、固定旧会话模型、模式及权限变更不更新。 +配置失败不更新;运行中接受的下一轮配置会更新;旧异步请求不能覆盖更新的选择。 +存储损坏或不可用时安全回退。权限仍继承全局默认。 + +没有记忆时,渲染器会解析所选模型的 `ModelBinding`。 具有推理能力的模型始于该绑定的 `defaultThinkingLevel`(`omit` 保留;其它值 钳位到已启用档位);当默认值未设置时,才回落到已发布 `supportedThinkingLevels` 中的最高已启用档。非推理模型或缺失的能力元数据从 @@ -119,7 +127,7 @@ type RecentModelRef = { 未固定的会话仍在 list/get/create/fork/configure 上展示该继承默认模型的 推理能力;丰富步骤不会写入 `providerId`/`modelId`。桌面创建会话时会把当时的 -应用默认(或 Composer 草稿覆盖)写入持久化 id。之后改默认模型不会改写已创建 +Composer 选择(或全局默认回退)写入持久化 id。之后改默认模型不会改写已创建 会话。打开仍为空 id 的旧行时,会快照最近一次使用的模型,否则快照当前默认, 从而不再跟随设置。当所选目录/绑定模型暴露了思考等级时,Composer 不得把 `supportsReasoning: false` 或空等级列表当作权威快照,因此回合中改档不会把 @@ -236,9 +244,9 @@ type ModelCatalogItem = { - 如果未配置,则新手引导清单需要在第一个代理运行之前设置提供商 会话级别: -- 创建时继承应用默认,并写入该 `providerId`/`modelId` -- 之后改设置里的默认模型只作用于新会话和未持久化的首页草稿,不改已创建会话 -- 将思维初始化到所选模型绑定的默认思考等级(钳位到已启用档; +- 创建时按显式草稿、最近可用主动选择、全局默认的顺序写入 `providerId`/`modelId` +- 全局默认仍是无可用记忆时的回退,不改已创建会话 +- 优先恢复该供应商/模型的思考等级记忆,否则使用模型绑定默认等级(钳位到已启用档; 未设置时才回落最高已启用档),当它支持推理时,否则 `off` - 可以独立覆盖 @@ -316,7 +324,7 @@ Electron 使用本地 `models.dev` 记录装饰缓存和新发现的模型行。 刷新使缓存的选择器保持填充状态 - [ ] 能力徽章可见 - [ ] 会话模型更改仅适用于下一回合 -- [ ] 新会话将具有推理能力的继承模型默认为该绑定存储的默认 +- [ ] 新会话优先恢复该供应商/模型的思考记忆,否则使用绑定存储的默认 思考等级(钳位到已启用档;未设置时才用最高已启用档),否则默认为 `off` - [ ] 推理选择器是能力门控和 pi 发布的稀疏级别 在 Composer、Electron main 和 pi sidecar 中以相同的方式设置钳位 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index c600c7b1fc..ee0e14e4dc 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -5061,6 +5061,7 @@ IPC 请求无法关闭。 | 验收 | 应用场景 | |---|---| +| B / C / F / Quality — 记忆模型和思考等级 | E2E-COMPOSER-remember-model-and-thinking | | C / F / Quality — 桌面定时任务 | E2E-SCHEDULED-desktop-automation-lifecycle | | A / C — Unicode stdio 成帧 | E2E-RPC-unicode-separators | | C / G / Quality — Plugins navigation | E2E-NAV-plugins-button-goes-back | @@ -8158,3 +8159,16 @@ the latest destination. These assertions measure work counts, not device FPS. - **阶段:** 发布后维护。 - **自动化:** `pnpm test:e2e:dialog-overflow`;源代码检查不能替代实际布局验证。 - **状态:** 已实现,原生 Windows 已验证,macOS/Linux 尚未实机验证。 + +### E2E-COMPOSER-remember-model-and-thinking + +- **前置条件:** 隔离测试配置,两个推理模型,默认 Alpha/high/Ask,已有非空会话。 +- **步骤:** 选择 Beta/low;切到 Alpha 再回 Beta;当前会话权限改为 Auto; + 浏览旧 Alpha 会话后新建任务;重载后再次创建;验证配置失败和异步旧请求晚完成。 +- **预期:** 新会话及切回模型恢复 Beta/low,权限仍继承 Ask;旧会话及全局设置不变。 + 浏览历史、失败及过时写入不覆盖记忆;模型失效回退默认,思考档位校验,存储异常安全降级。 +- **规格:** `03-runtime/13-model-catalog-and-selection.md`、`04-ux/03-permission-ux.md`。 +- **验收:** B / C / F / Quality。 +- **里程碑:** Post-MVP。 +- **状态:** `node scripts/e2e-composer-preferences.mjs` 自动化;真实 React/Chromium、 + 渲染器与 localStorage,主机 API 使用测试夹具,不访问真实用户配置或模型服务。 diff --git a/scripts/e2e-composer-preferences.mjs b/scripts/e2e-composer-preferences.mjs new file mode 100644 index 0000000000..b5d4b89f85 --- /dev/null +++ b/scripts/e2e-composer-preferences.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node +/** Isolated Chromium user path for remembered Composer model/reasoning choices. */ +import assert from "node:assert/strict"; +import { execFileSync, spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveElectronBinary } from "./e2e/boot.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const require = createRequire( + join(root, "packages/agent-runtime/package.json"), +); +const { build } = require("esbuild"); +const { electronBinary } = resolveElectronBinary(root); +const temp = await mkdtemp(join(tmpdir(), "pi-composer-preferences-")); +try { + await build({ + entryPoints: [join(root, "scripts/e2e/composer-preferences.jsx")], + outfile: join(temp, "renderer.js"), + bundle: true, + platform: "browser", + format: "esm", + jsx: "automatic", + define: { + "process.env.NODE_ENV": '"production"', + __BASELINE__: process.env.PI_COMPOSER_BASELINE ? "true" : "false", + }, + plugins: process.env.PI_COMPOSER_BASELINE + ? [ + { + name: "verified-upstream-baseline", + setup(build) { + build.onLoad( + { + filter: + /(?:Composer\.tsx|useComposerModelMenu\.ts|session-slice\.ts|session-coordination\.ts)$/, + }, + (args) => { + const relative = args.path + .slice(root.length + 1) + .replaceAll("\\", "/"); + return { + contents: execFileSync( + "git", + [ + "show", + `${process.env.PI_COMPOSER_BASELINE}:${relative}`, + ], + { cwd: root, encoding: "utf8" }, + ), + loader: args.path.endsWith("tsx") ? "tsx" : "ts", + }; + }, + ); + }, + }, + ] + : [], + alias: { + "@pi-desktop/i18n": join(root, "packages/i18n/src/index.ts"), + // The fixture lives outside the desktop package; use its React instance. + react: join(root, "apps/desktop/node_modules/react"), + "react-dom": join(root, "apps/desktop/node_modules/react-dom"), + }, + nodePaths: [join(root, "apps/desktop/node_modules")], + }); + // Use the built app's complete CSS, including the Tailwind reset. + const renderer = join(root, "apps/desktop/out/renderer"); + const appHtml = await readFile(join(renderer, "index.html"), "utf8"); + const css = [...appHtml.matchAll(/href="([^" ]+\.css)"/g)].map( + (match) => match[1], + ); + assert( + css.length, + "Build the app with pnpm build:js before running this check", + ); + await cp(join(renderer, "assets"), join(temp, "assets"), { recursive: true }); + await writeFile( + join(temp, "index.html"), + `Composer preferences test${css.map((path) => ``).join("")}`, + ); + await writeFile( + join(temp, "main.cjs"), + ` +const { app, BrowserWindow } = require("electron"); +const path = require("node:path"); +app.setPath("userData", path.join(__dirname, "profile")); +app.whenReady().then(async () => { + const window = new BrowserWindow({ show: false, width: 1040, height: 760, webPreferences: { backgroundThrottling: false, sandbox: true, contextIsolation: true, nodeIntegration: false } }); + window.webContents.on("console-message", (event) => console.error(event.message)); + try { + await window.loadFile(path.join(__dirname, "index.html")); + const checks = []; + for (const phase of ["select", "reload", ...(process.env.PI_COMPOSER_BASELINE ? [] : ["race"])]) { + if (phase === "reload") await window.loadFile(path.join(__dirname, "index.html")); + checks.push(await window.webContents.executeJavaScript("globalThis.composerPreferencesProbe(" + JSON.stringify(phase) + ")")); + if (phase === "select" && process.env.PI_E2E_ARTIFACT_DIR) { + const fs = require("node:fs"); + fs.mkdirSync(process.env.PI_E2E_ARTIFACT_DIR, { recursive: true }); + fs.writeFileSync(path.join(process.env.PI_E2E_ARTIFACT_DIR, process.env.PI_COMPOSER_BASELINE ? "before.png" : "after.png"), (await window.webContents.capturePage()).toPNG()); + } + } + console.log("COMPOSER_PREFERENCES_PROBE " + JSON.stringify({ ok: checks.every((check) => check.ok), checks })); + app.quit(); + } catch (error) { + console.error("COMPOSER_PREFERENCES_PROBE " + JSON.stringify({ ok: false, error: String(error) })); + app.exit(1); + } +}); +`, + ); + const env = { ...process.env }; + delete env.ELECTRON_RUN_AS_NODE; + const child = spawn(electronBinary, [join(temp, "main.cjs")], { + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + for (const stream of [child.stdout, child.stderr]) + stream.on("data", (data) => { + output += data; + }); + const timeout = setTimeout(() => child.kill("SIGKILL"), 45_000); + let code; + try { + code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + } finally { + clearTimeout(timeout); + } + const line = output + .split(/\r?\n/) + .find((line) => line.startsWith("COMPOSER_PREFERENCES_PROBE ")); + assert( + line, + `renderer returned no probe result (exit=${code}): ${output.slice(-2000)}`, + ); + const result = JSON.parse(line.slice("COMPOSER_PREFERENCES_PROBE ".length)); + console.log("COMPOSER_PREFERENCES_PROBE " + JSON.stringify(result)); + assert.equal(code, 0, output.slice(-6000)); + assert.equal(result.ok, true); +} finally { + await rm(temp, { recursive: true, force: true }); +} diff --git a/scripts/e2e/composer-preferences.jsx b/scripts/e2e/composer-preferences.jsx new file mode 100644 index 0000000000..158e4398c8 --- /dev/null +++ b/scripts/e2e/composer-preferences.jsx @@ -0,0 +1,331 @@ +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import i18n from "i18next"; +import { I18nextProvider } from "react-i18next"; +import { en } from "@pi-desktop/i18n"; +import { Composer } from "../../apps/desktop/src/components/Composer"; +import { + materializeDraftSession, + useAppStore, +} from "../../apps/desktop/src/stores/app-store"; +import { api } from "../../apps/desktop/src/lib/api"; + +// Real Composer, hooks, store and coordination; only the host API is a fixture. +const storageKey = "pi.desktop.composerModelPreferences.v1"; +const providers = ["alpha", "beta"].map((id) => ({ + id, + name: `Fixture ${id}`, + enabled: true, + authKind: "none", + hasSecret: false, + supportsReasoning: true, + supportedThinkingLevels: ["low", "high"], + models: [ + { + id: `${id}-model`, + thinkingLevels: ["low", "high"], + defaultThinkingLevel: "high", + }, + ], +})); +const settings = { + defaultMode: "agent", + defaultProviderId: "alpha", + defaultModelId: "alpha-model", + defaultPermissionMode: "ask", +}; +let sequence = 0; +let failConfigure = false; +let holdConfigure; +const sessions = new Map(); +api.composerCommands = async () => ({ commands: [] }); +api.listProviderModels = async () => ({ models: [] }); +api.createSession = async (config) => { + const session = { + id: `fixture-${++sequence}`, + title: "New task", + createdAt: sequence, + updatedAt: sequence, + messageCount: 0, + permissionMode: "inherit", + ...config, + }; + sessions.set(session.id, session); + return { session }; +}; +api.configureSession = async (id, config) => { + if (failConfigure) throw new Error("Fixture configuration rejected"); + if (holdConfigure) await holdConfigure; + const session = { ...sessions.get(id), ...config }; + sessions.set(id, session); + return { session }; +}; +api.pendingPlans = async () => ({ plans: [], state: "inactive" }); +api.getSession = async (id) => ({ + session: { ...sessions.get(id), messages: [] }, +}); +api.getSessionCollaboration = async () => ({ + sessionId: "fixture", + messages: [], + peers: [], +}); + +const check = (condition, message) => { + if (!condition) throw new Error(message); +}; +const settle = () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); +async function until(predicate, message) { + for (let frame = 0; frame < 120; frame += 1) { + if (predicate()) return; + await settle(); + } + throw new Error(message); +} +const current = () => { + const state = useAppStore.getState(); + return state.sessions.find((session) => session.id === state.activeSessionId); +}; +const click = async (selector) => { + const button = document.querySelector(selector); + check(button && !button.disabled, `Missing enabled control: ${selector}`); + button.click(); + await settle(); +}; +async function model(id) { + if (!document.querySelector(".composer-menu-root")) + await click(".composer-model-thinking-chip"); + await click(".composer-menu-entry"); + await click(`.composer-model-option[title='${id}']`); + await until(() => current()?.modelId === id, "model choice not applied"); +} +async function thinking(level) { + if (!document.querySelector(".composer-menu-root")) + await click(".composer-model-thinking-chip"); + await click(".composer-menu-entry:nth-child(2)"); + const option = [...document.querySelectorAll("[data-thinking-index]")].find( + (node) => node.textContent.trim() === level, + ); + check(option, `Missing reasoning option ${level}`); + option.click(); + await until( + () => current()?.thinkingLevel === level, + "thinking choice not applied", + ); + await settle(); +} +const host = document.createElement("main"); +host.style.cssText = "width:820px;margin:80px auto;padding:24px"; +document.body.append(host); +await i18n.init({ + lng: "en", + resources: { en: { translation: en } }, + interpolation: { escapeValue: false }, +}); +useAppStore.setState({ + ready: true, + settings, + providers, + providerModels: {}, + page: "chat", +}); +flushSync(() => + createRoot(host).render( + + + , + ), +); + +globalThis.composerPreferencesProbe = async (phase) => { + if (phase === "reload") { + await settle(); + const text = document.querySelector( + ".composer-model-thinking-chip", + ).textContent; + check( + text.includes(__BASELINE__ ? "alpha-model" : "beta-model"), + `reload model: ${text}`, + ); + if (!__BASELINE__) check(text.includes("low"), `reload thinking: ${text}`); + await useAppStore.getState().newSession(); + check( + current().providerId === (__BASELINE__ ? "alpha" : "beta"), + "reload creation disagrees with display", + ); + return { ok: true, phase, text }; + } + if (phase === "race") { + // Configuration failure cannot replace the last accepted preference. + const state = useAppStore.getState(); + const before = localStorage.getItem(storageKey); + failConfigure = true; + await state + .configureActiveSession( + { + mode: "agent", + providerId: "alpha", + modelId: "alpha-model", + thinkingLevel: "high", + }, + { rememberModel: true }, + ) + .catch(() => {}); + failConfigure = false; + check( + localStorage.getItem(storageKey) === before, + "failed configuration overwrote memory", + ); + // A stale slow completion must not win over a later explicit draft choice. + let release; + holdConfigure = new Promise((resolve) => { + release = resolve; + }); + const slow = state.configureActiveSession( + { + mode: "agent", + providerId: "alpha", + modelId: "alpha-model", + thinkingLevel: "high", + }, + { rememberModel: true }, + ); + useAppStore.setState({ activeSessionId: undefined }); + await useAppStore.getState().configureActiveSession( + { + mode: "agent", + providerId: "beta", + modelId: "beta-model", + thinkingLevel: "omit", + }, + { rememberModel: true }, + ); + release(); + await slow; + holdConfigure = undefined; + check( + JSON.parse(localStorage.getItem(storageKey))[0].thinkingLevel === "omit", + "stale completion overwrote memory", + ); + // A pending approval rejects changes without recording; a running session + // accepts next-turn intent without waiting for a host configuration flush. + const id = state.activeSessionId; + useAppStore.setState({ + activeSessionId: id, + pendingPlans: { [id]: { status: "pending" } }, + }); + const choice = { + mode: "agent", + providerId: "alpha", + modelId: "alpha-model", + thinkingLevel: "low", + }; + await useAppStore + .getState() + .configureActiveSession(choice, { rememberModel: true }); + check( + JSON.parse(localStorage.getItem(storageKey))[0].providerId === "beta", + "blocked approval changed memory", + ); + useAppStore.setState({ pendingPlans: {}, runningSessions: { [id]: true } }); + await useAppStore + .getState() + .configureActiveSession(choice, { rememberModel: true }); + check( + JSON.parse(localStorage.getItem(storageKey))[0].providerId === "alpha", + "accepted next-turn choice was not remembered", + ); + useAppStore.setState({ activeSessionId: undefined }); + await materializeDraftSession(); + check( + current().modelId === "beta-model" && current().thinkingLevel === "omit", + "first-send materialization must preserve the explicit draft over memory", + ); + return { ok: true, phase }; + } + localStorage.removeItem(storageKey); + const source = { + id: "source", + title: "Existing conversation", + mode: "agent", + providerId: "alpha", + modelId: "alpha-model", + thinkingLevel: "high", + permissionMode: "inherit", + messageCount: 1, + createdAt: 1, + updatedAt: 1, + }; + sessions.set(source.id, source); + useAppStore.setState({ sessions: [source], activeSessionId: source.id }); + await settle(); + await model("beta-model"); + await thinking("low"); + await model("alpha-model"); + if (!__BASELINE__) { + check( + current().thinkingLevel === "high", + "new model must use its own default", + ); + } + await model("beta-model"); + if (!__BASELINE__) { + check( + current().thinkingLevel === "low", + "returning to a model must restore its own level", + ); + } + await click(".composer-model-thinking-chip"); + // Permission-only configuration deliberately carries model fields, like the toolbar. + await useAppStore.getState().configureActiveSession({ + mode: "agent", + providerId: "beta", + modelId: "beta-model", + thinkingLevel: "low", + permissionMode: "auto", + }); + const remembered = localStorage.getItem(storageKey); + // Selecting an existing conversation must not replace the explicit preference. + const older = { ...source, id: "older", updatedAt: 0 }; + sessions.set(older.id, older); + useAppStore.setState({ + sessions: [...useAppStore.getState().sessions, older], + }); + await useAppStore.getState().selectSession(older.id); + check( + localStorage.getItem(storageKey) === remembered, + "navigation overwrote preference", + ); + await useAppStore.getState().newSession(); + await settle(); + check( + current().providerId === (__BASELINE__ ? "alpha" : "beta"), + "new session provider", + ); + check( + current().thinkingLevel === (__BASELINE__ ? "high" : "low"), + "new session thinking", + ); + check( + current().permissionMode == null || current().permissionMode === "inherit", + "permission override leaked", + ); + check( + sessions.get("source").permissionMode === "auto", + "existing session changed", + ); + check( + settings.defaultProviderId === "alpha" && + settings.defaultPermissionMode === "ask", + "Composer choices changed global defaults", + ); + return { + ok: true, + phase, + model: current().modelId, + thinking: current().thinkingLevel, + permission: current().permissionMode ?? "inherit", + }; +};