diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..d1f3dbdada --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## Unreleased + +- Restore Composer reasoning preferences separately for each provider/model. + Keep upstream model inheritance based on successful message submission and + global permission defaults unchanged. + +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 bebc1d609d..424d56d28e 100644 --- a/apps/desktop/src/components/Composer.tsx +++ b/apps/desktop/src/components/Composer.tsx @@ -1,3 +1,4 @@ +import { rememberedComposerThinking } from "../lib/composer-model-preferences"; import { isRemoteSession, sessionAllows } from "../lib/remote-session-safety"; import { inheritedSessionModelBinding } from "../lib/session-model"; import { @@ -381,7 +382,9 @@ export function Composer({ const sessionThinkingLevel = activeSession?.thinkingLevel ?? (!activeSession ? draftConfiguration?.thinkingLevel : undefined) ?? - draftThinkingLevel; + (!activeSession + ? rememberedComposerThinking(provider, modelId, thinkingProvider?.supportedThinkingLevels, draftThinkingLevel, selectedModelInfo?.catalogSource) + : draftThinkingLevel); 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 bc43869668..adb213313c 100644 --- a/apps/desktop/src/features/chat/composer/hooks/useComposerModelMenu.ts +++ b/apps/desktop/src/features/chat/composer/hooks/useComposerModelMenu.ts @@ -1,3 +1,4 @@ +import { rememberedComposerThinking } from "../../../../lib/composer-model-preferences"; import { sameRecentModel } from "../../../../lib/recent-models"; import type { Mode, @@ -40,7 +41,7 @@ type UseComposerModelMenuOptions = { controlsBlocked: boolean; configureActiveSession: (configuration: { mode: Mode; providerId?: string; modelId?: string; thinkingLevel: SessionThinkingLevel; - }) => Promise; + }, options?: { rememberModel?: boolean }) => Promise; }; export function useComposerModelMenu({ @@ -96,7 +97,7 @@ export function useComposerModelMenu({ providerId: current.providerId, modelId: current.modelId, thinkingLevel: level, - }); + }, { rememberModel: true }); }, onError: (error) => { const current = thinkingConfigRef.current; @@ -235,8 +236,10 @@ export function useComposerModelMenu({ }, [modelHighlight, open]); 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; if (isImageGenerationModel( imageGenerationBindings( useAppStore.getState().settings?.imageGenerationModels, @@ -263,7 +266,8 @@ export function useComposerModelMenu({ sameComposerModelId(modelId ?? "", nextModelId); const nextThinkingLevel = selectedSameModel ? thinkingLevelForProvider(nextModelProvider, thinkingLevel) - : (nextModel?.catalogSource === "models.dev" + : rememberedComposerThinking(candidate, nextModelId, nextModelProvider?.supportedThinkingLevels, + (nextModel?.catalogSource === "models.dev" ? initialThinkingLevelForBinding( nextBinding, nextModelProvider?.supportedThinkingLevels, @@ -271,13 +275,13 @@ export function useComposerModelMenu({ : initialThinkingLevelForUnmatchedModel( nextBinding, nextModelProvider?.supportedThinkingLevels, - )); + )), nextModel?.catalogSource); await configureActiveSession({ mode, providerId: candidate.id, modelId: nextModelId, thinkingLevel: nextThinkingLevel, - }); + }, { rememberModel: true }); setQuery(""); setOtherModelsExpanded(false); 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..a26c266323 --- /dev/null +++ b/apps/desktop/src/lib/composer-model-preferences.ts @@ -0,0 +1,99 @@ +import { + initialThinkingLevelForBinding, + initialThinkingLevelForUnmatchedModel, + type ModelInfo, + isSessionThinkingLevel, + modelIdsMatch, + type ProviderPublic, + type SessionThinkingLevel, +} from "@pi-desktop/shared"; +import type { SessionModelRef } 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, + fallback: SessionThinkingLevel = initialThinkingLevelForBinding( + provider?.models.find((entry) => modelIdsMatch(entry.id, modelId ?? "")), + fallbackLevels, + ), + catalogSource?: ModelInfo["catalogSource"], +): SessionThinkingLevel { + const binding = provider?.models.find((entry) => + modelIdsMatch(entry.id, modelId ?? ""), + ); + const choice = readChoices().find( + (entry) => + entry.providerId === provider?.id && + modelIdsMatch(entry.modelId, modelId ?? ""), + ); + if (!choice) return fallback; + const resolveThinking = catalogSource === "models.dev" + ? initialThinkingLevelForBinding + : initialThinkingLevelForUnmatchedModel; + return resolveThinking( + { ...binding, defaultThinkingLevel: choice.thinkingLevel }, + fallbackLevels, + ); +} diff --git a/apps/desktop/src/stores/app-state.ts b/apps/desktop/src/stores/app-state.ts index 7858a3e7b8..ed6dcff78e 100644 --- a/apps/desktop/src/stores/app-state.ts +++ b/apps/desktop/src/stores/app-state.ts @@ -228,7 +228,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 3e9fefe368..b63428d250 100644 --- a/apps/desktop/src/stores/runtime/session-coordination.ts +++ b/apps/desktop/src/stores/runtime/session-coordination.ts @@ -1,3 +1,4 @@ +import { rememberedComposerThinking } from "../../lib/composer-model-preferences"; import type { ContextCompactionRecord, SessionDetail, @@ -317,7 +318,9 @@ export function createSessionCoordination({ created = await api.createSession({ title: untitledTaskTitle(), mode: draftConfig?.mode ?? normalizeMode(settings?.defaultMode), - thinkingLevel: draftConfig?.thinkingLevel ?? defaultThinkingLevel, + thinkingLevel: draftConfig?.thinkingLevel ?? rememberedComposerThinking( + defaultProvider, inherited.modelId, defaultProvider?.supportedThinkingLevels, defaultThinkingLevel, catalogModel?.catalogSource, + ), 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 e1c41d699b..285dcc0a68 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 type { Mode, PendingInteractiveRequests, @@ -170,6 +171,7 @@ export function createSessionSlice({ > { const pendingInteractiveRestoreGenerations = new Map(); + let modelChoiceIntent = 0; const refreshSessionList = createRefreshCoordinator(async () => { const result = await api.listSessions(); set((state) => { @@ -778,9 +780,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, @@ -807,6 +816,7 @@ export function createSessionSlice({ config, ), ); + rememberChoice(); set((state) => ({ sessions: state.sessions.map((session) => session.id === sessionId @@ -822,6 +832,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..0f5c88ebcb --- /dev/null +++ b/apps/desktop/test/composer-model-preferences.test.mjs @@ -0,0 +1,122 @@ +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, +} = 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 remember = (providerId, modelId, thinkingLevel) => + rememberComposerModel({ providerId, modelId, thinkingLevel }); + +test("unremembered models preserve caller defaults including unmatched catalog behavior", () => { + assert.equal(rememberedComposerThinking(providers[0], "one", undefined, "omit"), "omit"); + remember("a", "one", "low"); + assert.equal(rememberedComposerThinking(providers[0], "one", undefined, "omit"), "low"); +}); + +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("permissions and operating mode never enter the preference", () => { + rememberComposerModel({ + providerId: "b", + modelId: "one", + thinkingLevel: "low", + permissionMode: "auto", + mode: "goal", + }); + 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(rememberedComposerThinking(providers[0], "one", undefined, "high"), "high"); + } + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + get() { + throw new Error("blocked"); + }, + }); + assert.doesNotThrow(() => remember("b", "one", "low")); + assert.equal(rememberedComposerThinking(providers[0], "one", undefined, "high"), "high"); +}); + +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, + ); +}); + +test("generic empty bindings restore reasoning while known empty bindings remain disabled", () => { + const generic = { ...providers[0], models: [{ id: "one", thinkingLevels: [] }] }; + for (const level of ["low", "omit"]) { + remember("a", "one", level); + assert.equal(rememberedComposerThinking(generic, "one", undefined, "off"), level); + assert.equal(rememberedComposerThinking(generic, "one", undefined, "off", "models.dev"), "off"); + assert.equal(rememberedComposerThinking({ ...generic, models: [{ id: "one", thinkingLevels: ["off"] }] }, "one", undefined, "off"), "off"); + } +}); diff --git a/apps/desktop/test/composer-preferences-flow.test.mjs b/apps/desktop/test/composer-preferences-flow.test.mjs new file mode 100644 index 0000000000..4c947f70dd --- /dev/null +++ b/apps/desktop/test/composer-preferences-flow.test.mjs @@ -0,0 +1,81 @@ +import i18n from "i18next"; +import { en } from "@pi-desktop/i18n"; +import assert from "node:assert/strict"; +import { register } from "node:module"; +import test from "node:test"; +register(new URL("./helpers/ts-import-hooks.mjs", import.meta.url)); +const { useAppStore, materializeDraftSession } = await import("../src/stores/app-store.ts"); +const { api } = await import("../src/lib/api.ts"); +const { loadRecentModels } = await import("../src/lib/recent-models.ts"); +const key = "pi.desktop.composerModelPreferences.v1"; + +test("reasoning memory follows accepted choices without changing usage-based model inheritance", async t => { + await i18n.init({ lng: "en", resources: { en: { translation: en } } }); + const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + const storage = new Map(); + Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { + getItem: key => storage.get(key), setItem: (key, value) => storage.set(key, value), + } }); + t.after(() => { if (original) Object.defineProperty(globalThis, "localStorage", original); else delete globalThis.localStorage; }); + const provider = { id: "p", enabled: true, authKind: "none", supportedThinkingLevels: ["low", "high"], models: ["a", "b"].map(id => ({ id, thinkingLevels: ["low", "high"], defaultThinkingLevel: "high" })) }; + useAppStore.setState({ providers: [provider], providerModels: {}, activeSessionId: null, sessions: [], recentModels: [], draftConfiguration: null, pendingPlans: {}, runningSessions: {}, workspace: null, + settings: { defaultProviderId: "p", defaultModelId: "a", defaultMode: "agent", defaultPermissionMode: "ask" } }); + let sequence = 0; + let reject = false; + let held; + t.mock.method(api, "createSession", async config => ({ session: { ...config, id: `memory-${++sequence}`, title: "Conversation", messageCount: 0 } })); + t.mock.method(api, "configureSession", async (id, config) => { + if (reject) throw new Error("rejected"); + if (held) await held; + return { session: { ...useAppStore.getState().sessions.find(s => s.id === id), ...config, id } }; + }); + t.mock.method(api, "prompt", async () => ({})); + const choose = (modelId, thinkingLevel, options = { rememberModel: true }) => useAppStore.getState().configureActiveSession({ mode: "agent", providerId: "p", modelId, thinkingLevel }, options); + const current = () => useAppStore.getState().sessions.find(s => s.id === useAppStore.getState().activeSessionId); + await choose("b", "low"); + assert.deepEqual(loadRecentModels(), [], "choosing is not usage"); + await materializeDraftSession(); + assert.equal(current().thinkingLevel, "low", "explicit draft wins"); + assert.equal(await useAppStore.getState().sendPrompt("Use B"), true); + assert.equal(loadRecentModels()[0].modelId, "b"); + useAppStore.setState({ runningSessions: {}, isRunning: false, activeSessionId: null, draftConfiguration: null, recentModels: loadRecentModels() }); + await materializeDraftSession(); + assert.equal(current().modelId, "b"); + assert.equal(current().thinkingLevel, "low", "new chat restores persisted reasoning"); + assert.notEqual(current().permissionMode, "auto"); + const configured = useAppStore.getState().providers; + useAppStore.setState({ providers: [{ ...provider, models: provider.models.map(model => ({ ...model, thinkingLevels: [], defaultThinkingLevel: null })) }], activeSessionId: null, draftConfiguration: null }); + await materializeDraftSession(); + assert.equal(current().thinkingLevel, "low", "empty generic bindings restore low when creating a session"); + await choose("b", "omit"); + useAppStore.setState({ activeSessionId: null, draftConfiguration: null }); + await materializeDraftSession(); + assert.equal(current().thinkingLevel, "omit", "empty generic bindings restore omit when creating a session"); + useAppStore.setState({ providers: configured }); + await choose("b", "low"); + const before = storage.get(key); + // Explicit omission uses an empty options object, as permission/mode callers do. + await choose("b", "low"); + await choose("b", "high", {}); + assert.equal(storage.get(key), before, "non-menu configuration must not record"); + reject = true; + await assert.rejects(choose("b", "high")); + reject = false; + assert.equal(storage.get(key), before); + let release; + held = new Promise(resolve => { release = resolve; }); + const slow = choose("b", "high"); + useAppStore.setState({ activeSessionId: null }); + await choose("a", "low"); + release(); await slow; held = undefined; + assert.equal(JSON.parse(storage.get(key))[0].modelId, "a", "stale response cannot replace newer intent"); + assert.equal(loadRecentModels()[0].modelId, "b", "reasoning memory does not reorder usage"); + const id = useAppStore.getState().sessions.at(-1).id; + useAppStore.setState({ activeSessionId: id, pendingPlans: { [id]: { status: "pending" } } }); + const blocked = storage.get(key); + await choose("b", "high"); + assert.equal(storage.get(key), blocked); + useAppStore.setState({ pendingPlans: {}, runningSessions: { [id]: true } }); + await choose("b", "high"); + assert.equal(JSON.parse(storage.get(key))[0].thinkingLevel, "high", "accepted next-turn staging records intent"); +}); diff --git a/docs/adr/README.md b/docs/adr/README.md index c659818437..4a5efc31f4 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -379,3 +379,4 @@ Each ADR includes: | plugin-fetch-redirect-policy | [Host-enforced plugin fetch redirect policy](plugin-fetch-redirect-policy.md) | Accepted (implementation candidate for #1475) | | local-agent-remote-workspace-tools | [Local Agent and model requests for remote workspaces](local-agent-remote-workspace-tools.md) | Accepted for implementation | +| remember-composer-model-preferences | [Remember Composer reasoning per model](remember-composer-model-preferences.md) | Proposed (PR #712) | diff --git a/docs/adr/remember-composer-model-preferences.md b/docs/adr/remember-composer-model-preferences.md new file mode 100644 index 0000000000..5fd3df6205 --- /dev/null +++ b/docs/adr/remember-composer-model-preferences.md @@ -0,0 +1,43 @@ +# ADR: Remember explicit Composer model and reasoning choices + +- Status: Proposed (PR #712) +- Date: 2026-09-20 +- Amends: ADR 0114, ADR 0295 (desktop creation defaults only) +- Related: Issue #678; E2E-COMPOSER-remember-model-and-thinking + +## Context + +Current Desktop inherits the most recently used usable chat model, but reasoning +still resets when switching away and returning or creating a new conversation. +Repeating the reasoning 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. This table only restores reasoning; upstream +recent-model history remains the sole authority for choosing a new chat model. +Only accepted message submission updates model recency. Explicit drafts win, +and unavailable/image-only bindings are skipped by the existing resolver. +Restore thinking per provider/model and clamp against current capabilities; +without a preference preserve current catalog and unmatched-model defaults. + +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 5bc62ea30d..fb6b143d6b 100644 --- a/docs/spec/03-runtime/13-model-catalog-and-selection.md +++ b/docs/spec/03-runtime/13-model-catalog-and-selection.md @@ -636,3 +636,18 @@ The default conversation model has a separate **Image generation model** row bel it. Model Advanced can select that unique binding; provider form Save commits it, Cancel discards it, and replacing it leaves the conversation default unchanged. See [image generation and editing](21-image-generation.md) for the tool and batch contract. + +### Per-model Composer reasoning memory + +The existing recent-model history remains authoritative: only accepted message +submission updates model recency. Selecting a model alone does not reorder it. +Explicit Composer selections additionally remember reasoning per provider/model +on this device. Switching back, new chats inheriting that model, and restart +restore the remembered level, clamped to current supported levels. An explicit +draft wins; absent memory preserves current catalog/unmatched-model defaults. + +Existing conversations keep their configuration. Navigation, permission changes, +failed or blocked writes never update memory. Accepted next-turn staging does; +an older result cannot supersede a newer choice. Permissions remain inherited. +The versioned localStorage preference is bounded to 100 entries. Corrupt or +unavailable storage falls back safely; no IPC/database/Plugin SDK changes occur. diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 0c0ddbdee8..bb6273f2f9 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -18159,3 +18159,18 @@ host-created files. The full app's file-preview viewer is covered separately. the theme-token and reduced-motion guarantees; the interactive sidebar scenario remains a manual validation scenario run in a capable environment. + +## E2E-COMPOSER-remember-model-and-thinking + +- Preconditions: Two enabled chat models supporting low/high; isolated profile. +- Steps: Select B, choose low, switch to A and back. Submit successfully with B, + create another chat, and restart. Navigate to an old chat, then create a new + one. Exercise failed/stale writes, blocked approval and next-turn staging. +- Expected: B restores low on return and in new chats/restart after usage. Drafts + win; existing chats keep their binding. Recency changes only on accepted send. + Failed/blocked/older writes and navigation never overwrite reasoning memory. + Auto permissions never carry into a new chat; absent memory keeps defaults. +- Spec: 03-runtime/13-model-catalog-and-selection.md, Per-model Composer reasoning memory. +- Coverage: composer-model-preferences.test.mjs, composer-preferences-flow.test.mjs, + recent-model-flow.test.mjs, scripts/e2e-composer-preferences.mjs. +- Status: Automated coverage; task validation results recorded in the PR. 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 e8a271b8bd..8eb470cc75 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 @@ -377,3 +377,18 @@ Electron 使用本地 `Pi catalog` 记录装饰缓存和新发现的模型行。 ## 生图模型绑定 默认对话模型下方有独立的生图模型行。模型高级设置可指定唯一绑定;保存服务商表单才生效,取消丢弃选择,替换不会改变对话默认值。工具和批量合约见[图片生成与编辑](/zh-CN/spec/03-runtime/21-image-generation)。 + +### Per-model Composer reasoning memory + +The existing recent-model history remains authoritative: only accepted message +submission updates model recency. Selecting a model alone does not reorder it. +Explicit Composer selections additionally remember reasoning per provider/model +on this device. Switching back, new chats inheriting that model, and restart +restore the remembered level, clamped to current supported levels. An explicit +draft wins; absent memory preserves current catalog/unmatched-model defaults. + +Existing conversations keep their configuration. Navigation, permission changes, +failed or blocked writes never update memory. Accepted next-turn staging does; +an older result cannot supersede a newer choice. Permissions remain inherited. +The versioned localStorage preference is bounded to 100 entries. Corrupt or +unavailable storage falls back safely; no IPC/database/Plugin SDK changes occur. 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 3fb4276b46..e7c92487f5 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 @@ -9746,3 +9746,18 @@ host-created files. The full app's file-preview viewer is covered separately. - **关联规格**:`04-ux/06-settings-ia.md`、`04-ux/09-interaction-patterns.md`、ADR 0294、D659、issue #1653。 - **验收**:仅在 Host 项目激活成功后,渲染层才恢复该规范化路径的本地归档元数据。 - **状态**:`apps/desktop/test/app-store-sidebar.test.mjs` 与 `apps/desktop/test/project-create-dialog.test.mjs` 有源码契约覆盖;完整 UI 场景仍为草稿,仅在明确请求且环境可用时运行。 + +## E2E-COMPOSER-remember-model-and-thinking + +- Preconditions: Two enabled chat models supporting low/high; isolated profile. +- Steps: Select B, choose low, switch to A and back. Submit successfully with B, + create another chat, and restart. Navigate to an old chat, then create a new + one. Exercise failed/stale writes, blocked approval and next-turn staging. +- Expected: B restores low on return and in new chats/restart after usage. Drafts + win; existing chats keep their binding. Recency changes only on accepted send. + Failed/blocked/older writes and navigation never overwrite reasoning memory. + Auto permissions never carry into a new chat; absent memory keeps defaults. +- Spec: 03-runtime/13-model-catalog-and-selection.md, Per-model Composer reasoning memory. +- Coverage: composer-model-preferences.test.mjs, composer-preferences-flow.test.mjs, + recent-model-flow.test.mjs, scripts/e2e-composer-preferences.mjs. +- Status: Automated coverage; task validation results recorded in the PR. diff --git a/scripts/e2e-composer-preferences.mjs b/scripts/e2e-composer-preferences.mjs new file mode 100644 index 0000000000..d07cf73fc1 --- /dev/null +++ b/scripts/e2e-composer-preferences.mjs @@ -0,0 +1,166 @@ +#!/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"', + "import.meta.env.DEV": "false", + "import.meta.env.MODE": '"production"', + __BASELINE__: process.env.PI_COMPOSER_BASELINE ? "true" : "false", + }, + plugins: [ + { + name: "fixture-asset-urls", + setup(build) { + build.onResolve({ filter: /\?url$/ }, (args) => ({ + path: args.path, + namespace: "fixture-asset-url", + })); + build.onLoad({ filter: /.*/, namespace: "fixture-asset-url" }, () => ({ + contents: 'export default "about:blank";', + loader: "js", + })); + }, + }, + ...(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..4e92724465 --- /dev/null +++ b/scripts/e2e/composer-preferences.jsx @@ -0,0 +1,339 @@ +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"; + +window.addEventListener("error", (event) => console.error(event.error?.stack ?? event.message)); + +// 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.prompt = async () => ({}); +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"); + const disclosure = document.querySelector(".composer-menu-root [aria-expanded=\"false\"]"); + if (disclosure) { disclosure.click(); await settle(); } + const option = [...document.querySelectorAll(".composer-model-option")].find(node => node.querySelector(".composer-model-label")?.textContent === id); + check(option, `Missing model ${id}: ${document.querySelector(".composer-menu-root")?.textContent?.slice(0, 500)}`); + option.click(); + await settle(); + 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"); + const option = [...document.querySelectorAll(".composer-thinking-tick")].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: Object.fromEntries(providers.map(provider => [provider.id, [{ modelId: provider.models[0].id, catalogSource: "models.dev", capabilities: ["text"], supportedThinkingLevels: ["low", "high"] }]])), + 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("beta-model"), + `reload model: ${text}`, + ); + if (!__BASELINE__) check(text.includes("low"), `reload thinking: ${text}`); + await useAppStore.getState().newSession(); + check( + current().providerId === ("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", + }); + check(await useAppStore.getState().sendPrompt("Remember this model for the next conversation"), "prompt rejected"); + useAppStore.setState({ runningSessions: {}, isRunning: false }); + 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 === ("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", + }; +};