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", + }; +};