From 26c08b9f529b110735792fa0705c4d5f63074ba7 Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 17 Jul 2026 17:25:57 +0800 Subject: [PATCH 01/12] fix(web): remember the thinking level per model Persist kimi-web.thinking as a JSON map of model id to level instead of a single global value, and resolve the active level against the model's catalog (stored pick when still declared, else the model default) at loadModels, setModel, and on active-model changes via a watcher. Fixes the empty, unresponsive thinking picker shown for a model that does not declare a previously stored level (e.g. a max-only model with a stale global 'low'). --- .changeset/web-per-model-thinking.md | 5 + .../kimi-web/src/components/chat/Composer.vue | 9 +- .../components/mobile/MobileSettingsSheet.vue | 8 +- .../client/useModelProviderState.ts | 75 ++++++++--- .../src/composables/useKimiWebClient.ts | 67 ++++++---- apps/kimi-web/src/lib/modelThinking.test.ts | 122 +++++++++++++++++- apps/kimi-web/src/lib/modelThinking.ts | 20 ++- 7 files changed, 246 insertions(+), 60 deletions(-) create mode 100644 .changeset/web-per-model-thinking.md diff --git a/.changeset/web-per-model-thinking.md b/.changeset/web-per-model-thinking.md new file mode 100644 index 0000000000..a3d8672b38 --- /dev/null +++ b/.changeset/web-per-model-thinking.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Remember the thinking level per model, fixing an empty and unresponsive thinking picker when the active model does not support a previously stored level. diff --git a/apps/kimi-web/src/components/chat/Composer.vue b/apps/kimi-web/src/components/chat/Composer.vue index fc21e665be..55938c2c53 100644 --- a/apps/kimi-web/src/components/chat/Composer.vue +++ b/apps/kimi-web/src/components/chat/Composer.vue @@ -633,11 +633,10 @@ const currentModel = computed(() => ); const thinkingAvailability = computed(() => modelThinkingAvailability(currentModel.value)); const thinkingSegments = computed(() => segmentsFor(currentModel.value)); -// The stored level is shown and submitted verbatim (same as the TUI footer) — -// no coercion against the active model. No stored preference (undefined) shows -// the model default, which is what the daemon will resolve for the prompt. A -// level the model doesn't declare highlights no segment but still shows in the -// suffix. +// The client resolves the level per model (the model's stored pick when still +// declared, else the catalog default), so what arrives here is valid for the +// active model and highlights its segment. An undeclared level can only appear +// transiently, before the catalog loads, and simply highlights no segment. const thinkingLevel = computed(() => effectiveThinkingLevel(currentModel.value, props.thinking)); const activeThinkingSegment = computed(() => { const segs = thinkingSegments.value; diff --git a/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue b/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue index 6e80a74fe1..814ea8c398 100644 --- a/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue +++ b/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue @@ -79,10 +79,10 @@ const currentModel = computed(() => ); const thinkingAvailability = computed(() => modelThinkingAvailability(currentModel.value)); const thinkingSegments = computed(() => segmentsFor(currentModel.value)); -// The stored level is shown and submitted verbatim (same as the composer and -// the TUI) — no coercion against the active model. No stored preference shows -// the model default (what the daemon will resolve); a level the model doesn't -// declare simply highlights no segment. +// The client resolves the level per model (the model's stored pick when still +// declared, else the catalog default), so what arrives here is valid for the +// active model. An undeclared level can only appear transiently, before the +// catalog loads, and simply highlights no segment. const thinkingLevel = computed(() => effectiveThinkingLevel(currentModel.value, props.thinking)); const activeThinkingSegment = computed(() => { const segs = thinkingSegments.value; diff --git a/apps/kimi-web/src/composables/client/useModelProviderState.ts b/apps/kimi-web/src/composables/client/useModelProviderState.ts index 4c56916c3b..73cb225a2f 100644 --- a/apps/kimi-web/src/composables/client/useModelProviderState.ts +++ b/apps/kimi-web/src/composables/client/useModelProviderState.ts @@ -5,7 +5,7 @@ // model pick. Cross-dependencies (failure reporting, status refresh, activity, // in-flight set, thinking storage) are injected by the facade. -import { ref, type ComputedRef } from 'vue'; +import { ref, watch, type ComputedRef } from 'vue'; import { getKimiWebApi } from '../../api'; import type { AppMessage, @@ -19,6 +19,7 @@ import type { import { safeGetString, safeSetString, STORAGE_KEYS } from '../../lib/storage'; import { defaultThinkingLevelFor, + levelDeclaredBy, thinkingLevelForModelSwitch, thinkingLevelToConfig, } from '../../lib/modelThinking'; @@ -69,7 +70,11 @@ export interface UseModelProviderStateDeps { refreshSessionStatus: (sessionId: string) => Promise; persistSessionProfile: (patch: PersistSessionProfilePatch, sessionId?: string) => Promise; activity: ComputedRef; - saveThinkingToStorage: (v: ThinkingLevel) => void; + /** Read the persisted thinking pick for a model (undefined = never picked + * for that model). Storage is keyed by model id. */ + loadThinkingForModel: (modelId: string) => ThinkingLevel | undefined; + /** Persist an explicit thinking pick under the given model id. */ + saveThinkingToStorage: (modelId: string, level: ThinkingLevel) => void; /** Replace one session in place (matched by id). Owned by the facade so the * model module never assigns rawState.sessions directly. */ updateSession: (id: string, update: (session: AppSession) => AppSession) => void; @@ -89,6 +94,7 @@ export function useModelProviderState( refreshSessionStatus, persistSessionProfile, activity, + loadThinkingForModel, saveThinkingToStorage, updateSession, updateSessionMessages, @@ -131,15 +137,47 @@ export function useModelProviderState( return modelById(rawModel)?.id ?? rawModel ?? undefined; } + /** + * The level a model should run at: the user's stored pick for THIS model when + * the model still declares it, else the model's catalog default. Validation + * against the catalog entry is what keeps a stale or foreign level (picked + * for another model, or dropped by a catalog update) from reaching the UI and + * the daemon. + */ + function thinkingLevelForModel(model: AppModel): ThinkingLevel { + const stored = loadThinkingForModel(model.id); + if (stored !== undefined && levelDeclaredBy(model, stored)) return stored; + return defaultThinkingLevelFor(model); + } + function applyThinkingLevel(level: ThinkingLevel | undefined): ThinkingLevel | undefined { - // Stored verbatim — whatever the user picked is what gets submitted to the - // daemon (same as the TUI); no coercion against the active model. Only - // concrete levels are persisted; "no preference" stays in-memory. + // Whatever the user picked is what gets submitted to the daemon (same as + // the TUI). Persisted under the CURRENT model id — per-model storage keeps + // a pick from leaking onto models that never declared it. Only concrete + // levels are persisted; "no preference" stays in-memory. rawState.thinking = level; - if (level !== undefined) saveThinkingToStorage(level); + const modelId = currentModelId(); + if (level !== undefined && modelId !== undefined) saveThinkingToStorage(modelId, level); return level; } + // The active model can change WITHOUT a picker action: switching sessions, + // the snapshot adopting another session, or the catalog/default arriving + // late. Re-resolve the level for the new model so a pick made for one model + // is never submitted to — or rendered on — another (a foreign level used to + // leave the composer showing nothing selected with no way to switch). The + // picker path (setModel) applies the same resolution synchronously, so the + // watcher's re-resolution after it is an idempotent no-op. + watch( + () => currentModelId(), + (id, prevId) => { + if (id === undefined || id === prevId) return; + const model = modelById(id); + if (model === undefined) return; + rawState.thinking = thinkingLevelForModel(model); + }, + ); + /** Persist an explicit thinking pick as the daemon-wide default ([thinking] * in config.toml), mirroring the TUI's persistModelSelection, so sessions * created by other clients inherit it. Fire-and-forget: the session-level @@ -178,15 +216,13 @@ export function useModelProviderState( try { const api = getKimiWebApi(); models.value = await api.listModels(); - // No explicit preference: pin the active model's default level (from the - // server catalog) as a concrete value, so what the UI shows, what gets - // submitted, and what the session runs are always the same. In-memory - // only — localStorage stays reserved for levels the user actually - // picked, and a reload re-derives from the then-current model. - if (rawState.thinking === undefined) { - const active = modelById(currentModelId()); - if (active !== undefined) rawState.thinking = defaultThinkingLevelFor(active); - } + // Resolve the active model's level: the stored pick for THIS model when + // still declared, else the catalog default. In-memory only — localStorage + // stays reserved for levels the user actually picked. Always re-resolved + // (not just when unset) so a level carried over from another model can't + // outlive the catalog refresh that makes it invalid. + const active = modelById(currentModelId()); + if (active !== undefined) rawState.thinking = thinkingLevelForModel(active); } catch (err) { pushOperationFailure('loadModels', err); } @@ -221,7 +257,14 @@ export function useModelProviderState( ? rawState.sessions.find((s) => s.id === sid)?.model : undefined; const isSwitch = currentModelId() !== (targetModel?.id ?? modelId); - const nextThinking = thinkingLevelForModelSwitch(targetModel, prevThinking, isSwitch); + // On a real switch, restore the target model's own stored pick when still + // declared; otherwise its catalog default (see thinkingLevelForModelSwitch). + const nextThinking = thinkingLevelForModelSwitch( + targetModel, + prevThinking, + isSwitch, + targetModel === undefined ? undefined : loadThinkingForModel(targetModel.id), + ); if (!sid) { // New-session draft (onboarding composer): no backend session to update. // Remember the pick — startSessionAndSendPrompt applies it at create time. diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 97445231be..4cc1fd2c1d 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -22,8 +22,10 @@ import { loadUnread, loadWorkspaceOrder, loadWorkspaceSort, + safeGetJson, safeGetString, safeRemove, + safeSetJson, safeSetString, saveUnread, saveWorkspaceOrder, @@ -114,13 +116,18 @@ const SWARM_MODE_STORAGE_KEY = STORAGE_KEYS.swarmMode; const GOAL_MODE_STORAGE_KEY = STORAGE_KEYS.goalMode; const SESSION_NOT_FOUND_CODE = 40401; const ONBOARDED_STORAGE_KEY = STORAGE_KEYS.onboarded; -// A persisted thinking level may be any non-empty effort string: the reserved -// 'off'/'on', or a model-declared level (e.g. 'low'/'high'/'max'). Since the -// set of legal levels comes from each model's support_efforts, we can't -// whitelist values — only guard against corrupted localStorage with a charset -// + length check. An absent/invalid value means the user never picked a level; -// loadModels() then pins the active model's catalog default as the concrete -// in-memory value (see useModelProviderState). +// Thinking levels are persisted PER MODEL: `kimi-web.thinking` holds a JSON map +// of model id → level. A persisted level may be any non-empty effort string: +// the reserved 'off'/'on', or a model-declared level (e.g. 'low'/'high'/'max'). +// Since the set of legal levels comes from each model's support_efforts, we +// can't whitelist values — only guard against corrupted localStorage with a +// charset + length check, and let the resolution in useModelProviderState drop +// entries the model no longer declares. Per-model storage is what keeps a level +// picked for one model (e.g. 'low' on an effort model) from leaking onto a +// model that never declared it (e.g. a max-only always-on model) — the old +// global format did exactly that, leaving the composer showing nothing selected +// with no way to switch. A legacy plain-string value fails JSON.parse here and +// is dropped; the next explicit pick rewrites the key in the map format. const PERSISTED_THINKING_LEVEL_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,31}$/; // Appearance types + logic live in ./client/useAppearance; re-exported here so @@ -154,22 +161,28 @@ function savePermissionToStorage(mode: PermissionMode): void { } } -function loadThinkingFromStorage(): ThinkingLevel | undefined { - try { - const v = safeGetString(THINKING_STORAGE_KEY); - if (v && PERSISTED_THINKING_LEVEL_RE.test(v)) return v as ThinkingLevel; - } catch { - // ignore +function loadThinkingMap(): Record { + const parsed = safeGetJson(THINKING_STORAGE_KEY); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const out: Record = {}; + for (const [id, value] of Object.entries(parsed as Record)) { + if (typeof value === 'string' && PERSISTED_THINKING_LEVEL_RE.test(value)) { + out[id] = value as ThinkingLevel; + } } - return undefined; + return out; } -function saveThinkingToStorage(v: ThinkingLevel): void { - try { - safeSetString(THINKING_STORAGE_KEY, v); - } catch { - // ignore - } +function loadThinkingForModel(modelId: string): ThinkingLevel | undefined { + return loadThinkingMap()[modelId]; +} + +function saveThinkingToStorage(modelId: string, level: ThinkingLevel): void { + // Read-modify-write the whole map (same pattern as saveUnread) so a pick made + // in another tab for a different model isn't clobbered by this tab's write. + const map = loadThinkingMap(); + map[modelId] = level; + safeSetJson(THINKING_STORAGE_KEY, map); } // Plan / swarm / goal modes are per-session. Each is persisted as a compact @@ -323,10 +336,10 @@ export interface ExtendedState extends KimiClientState { workspaceName: string; connection: ConnectionState; permission: PermissionMode; - /** The thinking level shown and submitted. Undefined only transiently — - * before the model catalog loads or when the active model is unknown; - * loadModels() pins the active model's catalog default as a concrete - * in-memory value so display and submission always agree. */ + /** The thinking level shown and submitted. Resolved per model by + * useModelProviderState (the model's stored pick when still declared, else + * its catalog default) once the catalog/session is known — undefined only + * transiently before that, so display and submission always agree. */ thinking: ThinkingLevel | undefined; /** Plan-mode toggle per session. Bound to a session (not global) so toggling * it in one session does not affect another. */ @@ -405,7 +418,10 @@ const rawState: ExtendedState = reactive({ workspaceName: 'kimi-web', connection: 'disconnected' as ConnectionState, permission: loadPermissionFromStorage(), - thinking: loadThinkingFromStorage(), + // Resolved per model once the catalog/session is known (loadModels and the + // active-model watcher in useModelProviderState) — storage is keyed by model + // id, which isn't known this early. + thinking: undefined, planModeBySession: loadModeMapFromStorage(PLAN_MODE_STORAGE_KEY), swarmModeBySession: loadModeMapFromStorage(SWARM_MODE_STORAGE_KEY), goalModeBySession: loadModeMapFromStorage(GOAL_MODE_STORAGE_KEY), @@ -2160,6 +2176,7 @@ const modelProvider = useModelProviderState(rawState, { refreshSessionStatus, persistSessionProfile, activity, + loadThinkingForModel, saveThinkingToStorage, updateSession, updateSessionMessages, diff --git a/apps/kimi-web/src/lib/modelThinking.test.ts b/apps/kimi-web/src/lib/modelThinking.test.ts index e3d068a7fc..65b6c327c9 100644 --- a/apps/kimi-web/src/lib/modelThinking.test.ts +++ b/apps/kimi-web/src/lib/modelThinking.test.ts @@ -1,6 +1,6 @@ -import { computed } from 'vue'; +import { computed, nextTick, reactive } from 'vue'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { AppModel, AppSession } from '../api/types'; +import type { AppModel, AppSession, ThinkingLevel } from '../api/types'; import { useModelProviderState, type UseModelProviderStateDeps, @@ -12,6 +12,7 @@ import { effectiveThinkingLevel, effortLabel, isThinkingOn, + levelDeclaredBy, modelThinkingAvailability, segmentsFor, thinkingLevelForModelSwitch, @@ -100,6 +101,7 @@ describe('modelThinking', () => { const effortModel = model({ capabilities: ['thinking'], supportEfforts: ['low', 'high', 'max'], defaultEffort: 'high' }); const booleanModel = model({ capabilities: ['thinking'] }); const alwaysOnModel = model({ capabilities: ['always_thinking'] }); + const maxOnlyModel = model({ capabilities: ['always_thinking'], supportEfforts: ['max'], defaultEffort: 'max' }); const unsupportedModel = model({ capabilities: [] }); describe('thinkingLevelForModelSwitch', () => { @@ -128,6 +130,20 @@ describe('modelThinking', () => { expect(thinkingLevelForModelSwitch(undefined, 'max', true)).toBe('max'); expect(thinkingLevelForModelSwitch(undefined, undefined, true)).toBeUndefined(); }); + + it('restores the stored pick when the target model declares it', () => { + expect(thinkingLevelForModelSwitch(effortModel, 'off', true, 'max')).toBe('max'); + expect(thinkingLevelForModelSwitch(effortModel, 'high', true, 'off')).toBe('off'); + }); + + it('falls back to the target default when the stored pick is not declared', () => { + expect(thinkingLevelForModelSwitch(maxOnlyModel, 'low', true, 'low')).toBe('max'); + expect(thinkingLevelForModelSwitch(booleanModel, 'off', true, 'low')).toBe('on'); + }); + + it('ignores the stored pick when re-selecting the current model', () => { + expect(thinkingLevelForModelSwitch(effortModel, 'off', false, 'max')).toBe('off'); + }); }); describe('effectiveThinkingLevel', () => { @@ -163,6 +179,22 @@ describe('modelThinking', () => { }); }); + describe('levelDeclaredBy', () => { + it('accepts levels selectable for the model', () => { + expect(levelDeclaredBy(effortModel, 'low')).toBe(true); + expect(levelDeclaredBy(effortModel, 'off')).toBe(true); + expect(levelDeclaredBy(booleanModel, 'on')).toBe(true); + expect(levelDeclaredBy(alwaysOnModel, 'on')).toBe(true); + }); + + it('rejects levels the model does not declare', () => { + expect(levelDeclaredBy(booleanModel, 'low')).toBe(false); + expect(levelDeclaredBy(alwaysOnModel, 'off')).toBe(false); + expect(levelDeclaredBy(maxOnlyModel, 'low')).toBe(false); + expect(levelDeclaredBy(unsupportedModel, 'max')).toBe(false); + }); + }); + describe('commitLevel', () => { it('keeps off', () => { expect(commitLevel(effortModel, 'off')).toBe('off'); @@ -209,14 +241,31 @@ describe('useModelProviderState thinking on model selection', () => { maxContextSize: 128_000, capabilities: ['thinking'], }; + const maxOnlyAppModel: AppModel = { + id: 'provider/max-model', + provider: 'provider', + model: 'max-model', + maxContextSize: 128_000, + capabilities: ['always_thinking'], + supportEfforts: ['max'], + defaultEffort: 'max', + }; + + // Per-model thinking storage, wired into the deps the same way the facade + // wires localStorage: tests seed storedThinkingLevels and assert writes via + // saveThinkingToStorageMock. + const saveThinkingToStorageMock = vi.fn(); + let storedThinkingLevels: Record; beforeEach(() => { apiMock.updateSession.mockReset(); apiMock.updateSession.mockResolvedValue({}); apiMock.listModels.mockReset(); - apiMock.listModels.mockResolvedValue([effortAppModel, booleanAppModel]); + apiMock.listModels.mockResolvedValue([effortAppModel, booleanAppModel, maxOnlyAppModel]); apiMock.setConfig.mockReset(); apiMock.setConfig.mockResolvedValue({}); + saveThinkingToStorageMock.mockReset(); + storedThinkingLevels = {}; }); function createState(options: { @@ -237,7 +286,8 @@ describe('useModelProviderState thinking on model selection', () => { refreshSessionStatus: vi.fn().mockResolvedValue(undefined), persistSessionProfile: vi.fn().mockResolvedValue(undefined), activity: computed(() => 'idle'), - saveThinkingToStorage: vi.fn(), + loadThinkingForModel: (modelId) => storedThinkingLevels[modelId], + saveThinkingToStorage: saveThinkingToStorageMock, updateSession: (id, update) => { state.sessions = state.sessions.map((session) => session.id === id ? update(session) : session, @@ -246,7 +296,7 @@ describe('useModelProviderState thinking on model selection', () => { updateSessionMessages: vi.fn(), }; const provider = useModelProviderState(state, deps); - provider.models.value = [effortAppModel, booleanAppModel]; + provider.models.value = [effortAppModel, booleanAppModel, maxOnlyAppModel]; return provider; } @@ -261,6 +311,9 @@ describe('useModelProviderState thinking on model selection', () => { it('keeps thinking off when re-selecting an explicit new-session draft model', async () => { const state = createState({ defaultModel: booleanAppModel.id }); + // In the app the draft pick and its 'off' level both went through setModel, + // so storage already holds the model's pick when it is re-selected. + storedThinkingLevels = { [effortAppModel.id]: 'off' }; const provider = createModelProvider(state); provider.draftModel.value = effortAppModel.id; @@ -306,6 +359,7 @@ describe('useModelProviderState thinking on model selection', () => { it('keeps a stored preference when loading models', async () => { const state = createState({ defaultModel: effortAppModel.id }); + storedThinkingLevels = { [effortAppModel.id]: 'max' }; state.thinking = 'max'; const provider = createModelProvider(state); @@ -314,6 +368,64 @@ describe('useModelProviderState thinking on model selection', () => { expect(state.thinking).toBe('max'); }); + it('drops a stored level the active model does not declare', async () => { + // The reported bug: a 'low' picked for another model must not leak onto a + // max-only always-on model — resolution falls back to the model default. + const state = createState({ defaultModel: maxOnlyAppModel.id }); + storedThinkingLevels = { [maxOnlyAppModel.id]: 'low' }; + state.thinking = 'low'; + const provider = createModelProvider(state); + + await provider.loadModels(); + + expect(state.thinking).toBe('max'); + }); + + it('restores the target model stored pick on a switch', async () => { + const state = createState({ defaultModel: booleanAppModel.id }); + storedThinkingLevels = { [effortAppModel.id]: 'max' }; + const provider = createModelProvider(state); + + await provider.setModel(effortAppModel.id); + + expect(state.thinking).toBe('max'); + expect(apiMock.setConfig).toHaveBeenCalledWith({ thinking: { enabled: true, effort: 'max' } }); + }); + + it('persists the thinking pick under the current model id', () => { + const state = createState({ defaultModel: effortAppModel.id }); + const provider = createModelProvider(state); + + provider.setThinking('max'); + + expect(saveThinkingToStorageMock).toHaveBeenCalledWith(effortAppModel.id, 'max'); + }); + + it('re-resolves the level when the active session switches to another model', async () => { + const state = reactive( + createState({ + activeSession: { id: 'session-1', model: maxOnlyAppModel.id }, + defaultModel: maxOnlyAppModel.id, + }), + ) as ExtendedState; + state.sessions = [ + { id: 'session-1', model: maxOnlyAppModel.id }, + { id: 'session-2', model: effortAppModel.id }, + ] as AppSession[]; + state.thinking = 'max'; + storedThinkingLevels = { [effortAppModel.id]: 'low' }; + createModelProvider(state); + + state.activeSessionId = 'session-2'; + await nextTick(); + expect(state.thinking).toBe('low'); + + // Switching back resolves the max-only model's own level again. + state.activeSessionId = 'session-1'; + await nextTick(); + expect(state.thinking).toBe('max'); + }); + it('does not write the global thinking config for the loadModels default pin', async () => { const state = createState({ defaultModel: effortAppModel.id }); state.thinking = undefined; diff --git a/apps/kimi-web/src/lib/modelThinking.ts b/apps/kimi-web/src/lib/modelThinking.ts index 7013fb2cf9..fbcf33c132 100644 --- a/apps/kimi-web/src/lib/modelThinking.ts +++ b/apps/kimi-web/src/lib/modelThinking.ts @@ -70,6 +70,14 @@ export function isThinkingOn(level: ThinkingLevel): boolean { return level !== 'off'; } +/** True when the level is selectable for the model (one of its UI segments). */ +export function levelDeclaredBy( + model: ModelThinkingInfo | undefined, + level: string, +): boolean { + return segmentsFor(model).includes(level); +} + /** * Normalize a UI draft before it crosses the component boundary. 'on' never * leaks out of the control — it becomes the model's default level. @@ -114,19 +122,21 @@ export function thinkingLevelToConfig(level: ThinkingLevel): { /** * Thinking level to use when the user picks a model in the switcher. - * Mirrors the TUI model picker: switching onto a different model pre-selects - * that model's own default level; re-selecting the current model keeps the - * live level untouched (including "no preference"). The carried-over level is - * never coerced onto the target model — whatever is stored is submitted - * verbatim. + * Mirrors the TUI model picker: re-selecting the current model keeps the live + * level untouched (including "no preference"). Switching onto a different model + * restores that model's own stored pick when the model still declares it + * (per-model persistence), and otherwise pre-selects the model's default level. + * The carried-over level is never coerced onto the target model. */ export function thinkingLevelForModelSwitch( model: ModelThinkingInfo | undefined, currentLevel: ThinkingLevel | undefined, isSwitch: boolean, + storedLevel?: ThinkingLevel, ): ThinkingLevel | undefined { // Target model unknown (catalog not loaded yet): keep the current level // as-is rather than guessing at capabilities. if (!isSwitch || model === undefined) return currentLevel; + if (storedLevel !== undefined && levelDeclaredBy(model, storedLevel)) return storedLevel; return defaultThinkingLevelFor(model); } From 2251edd0c44c9e74be7f181a3fe797c5d89ec68f Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 17 Jul 2026 17:51:30 +0800 Subject: [PATCH 02/12] fix(web): resolve a submitted prompt's thinking from its own model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit submitPromptInternal and the steer path read the single active-session rawState.thinking, so a queue drain for a background session submitted the level of whichever session the user had switched to since enqueueing — the same cross-model leak on the submit path. Thinking now joins model and the per-session modes in being resolved from the prompt's own session model (its stored pick when declared, else the catalog default), falling back to the active value only when the model has left the catalog. --- .../client/useModelProviderState.ts | 12 ++++ .../composables/client/useWorkspaceState.ts | 14 ++-- apps/kimi-web/test/workspace-state.test.ts | 66 ++++++++++++++++++- 3 files changed, 85 insertions(+), 7 deletions(-) diff --git a/apps/kimi-web/src/composables/client/useModelProviderState.ts b/apps/kimi-web/src/composables/client/useModelProviderState.ts index 73cb225a2f..75941cf491 100644 --- a/apps/kimi-web/src/composables/client/useModelProviderState.ts +++ b/apps/kimi-web/src/composables/client/useModelProviderState.ts @@ -150,6 +150,17 @@ export function useModelProviderState( return defaultThinkingLevelFor(model); } + /** thinkingLevelForModel by model id, for paths that submit a prompt for a + * session other than the active one (queued drain, steer): the level must + * come from the prompt's OWN model, not from rawState.thinking, which always + * tracks the active session's model. Undefined when the id is not in the + * catalog (caller falls back to the active value, same as before). */ + function thinkingLevelForModelId(modelId: string | undefined): ThinkingLevel | undefined { + if (modelId === undefined) return undefined; + const model = modelById(modelId); + return model === undefined ? undefined : thinkingLevelForModel(model); + } + function applyThinkingLevel(level: ThinkingLevel | undefined): ThinkingLevel | undefined { // Whatever the user picked is what gets submitted to the daemon (same as // the TUI). Persisted under the CURRENT model id — per-model storage keeps @@ -492,6 +503,7 @@ export function useModelProviderState( loadModels, loadProviders, setModel, + thinkingLevelForModelId, toggleStarModel, activateSkill, addProvider, diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index a921951342..5fad08a2a7 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -1513,9 +1513,12 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta const result = await api.submitPrompt(sid, { content, model, - // Verbatim: the stored level is submitted as-is (same as the TUI) — - // no coercion against the prompt's target model. - thinking: rawState.thinking, + // Resolved against THIS prompt's model (its stored pick when declared, + // else its catalog default) — never the active-session rawState.thinking, + // which tracks whatever session the user is looking at now: a queue + // drain for a background session would otherwise submit the level of + // the session the user switched to since enqueueing. + thinking: modelProvider.thinkingLevelForModelId(model) ?? rawState.thinking, permissionMode: rawState.permission, planMode, swarmMode, @@ -1689,8 +1692,9 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta const result = await api.submitPrompt(sid, { content, model, - // Verbatim, same as a normal send (see submitPromptInternal). - thinking: rawState.thinking, + // Resolved against this prompt's own model, same as a normal send (see + // submitPromptInternal). + thinking: modelProvider.thinkingLevelForModelId(model) ?? rawState.thinking, permissionMode: rawState.permission, planMode: rawState.planModeBySession[sid] ?? false, swarmMode: rawState.swarmModeBySession[sid] ?? false, diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index 9b761a29e0..67e0d417df 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -117,7 +117,7 @@ function createDeps(): UseWorkspaceStateDeps { return { taskPoller: {}, sideChat: {}, - modelProvider: {}, + modelProvider: { thinkingLevelForModelId: () => undefined }, pushOperationFailure: vi.fn(), activity: computed(() => 'running'), sessionsKnownEmpty: new Set(), @@ -743,6 +743,7 @@ describe('useWorkspaceState — startSessionAndActivateSkill', () => { skillsBySession: ref({}), loadSkillsForSession: vi.fn(), activateSkill, + thinkingLevelForModelId: () => undefined, } as unknown as UseWorkspaceStateDeps['modelProvider'], mergedWorkspaces: computed(() => [workspace('wd_1', '/abs/path', 'A')]), }; @@ -897,6 +898,7 @@ describe('useWorkspaceState — createGoal from an empty composer', () => { draftModel: ref(null), skillsBySession: ref({}), loadSkillsForSession: vi.fn(), + thinkingLevelForModelId: () => undefined, } as unknown as UseWorkspaceStateDeps['modelProvider'], // Something the goal can land in + what's visible in the sidebar. mergedWorkspaces: computed(() => [workspace('wd_1', '/abs/path', 'A')]), @@ -1059,6 +1061,7 @@ describe('useWorkspaceState — startSessionAndOpenSideChat', () => { draftModel: ref(null), skillsBySession: ref({}), loadSkillsForSession: vi.fn(), + thinkingLevelForModelId: () => undefined, } as unknown as UseWorkspaceStateDeps['modelProvider'], mergedWorkspaces: computed(() => [workspace('wd_1', '/abs/path', 'A')]), }; @@ -1531,7 +1534,10 @@ describe('useWorkspaceState — snapshot prompt recovery', () => { function promptDeps(overrides: Partial = {}): UseWorkspaceStateDeps { return { ...createDeps(), - modelProvider: { models: ref([]) } as unknown as UseWorkspaceStateDeps['modelProvider'], + modelProvider: { + models: ref([]), + thinkingLevelForModelId: () => undefined, + } as unknown as UseWorkspaceStateDeps['modelProvider'], ...overrides, }; } @@ -1725,6 +1731,62 @@ describe('useWorkspaceState — snapshot prompt recovery', () => { expect(state.queuedBySession.sess_1).toEqual([{ text: 'queued', attachments: undefined }]); }); + // A background session's drained prompt must not inherit the thinking level + // of whichever session is active when the drain happens — the level is + // resolved from the prompt's OWN model, never the active-view global. + it('drains a queued prompt with the level of its own session model, not the active view', async () => { + const state = createState(); + state.sessions = [{ ...createSession(), id: 'sess_a', model: 'provider/model-a' }]; + state.activeSessionId = 'sess_b'; // the user has switched to another session + state.thinking = 'max'; // the global now tracks that session's max-only model + state.inFlightBySession = { sess_a: true }; + state.queuedBySession = { sess_a: [{ text: 'follow up', attachments: undefined }] }; + const thinkingLevelForModelId = vi.fn((id: string | undefined) => + id === 'provider/model-a' ? 'low' : undefined, + ); + const ws = useWorkspaceState( + state, + promptDeps({ + modelProvider: { + models: ref([]), + thinkingLevelForModelId, + } as unknown as UseWorkspaceStateDeps['modelProvider'], + }), + ); + + ws.handleSessionSnapshot('sess_a', { inFlightTurn: null, busy: true }); + + expect(thinkingLevelForModelId).toHaveBeenCalledWith('provider/model-a'); + expect(apiMock.submitPrompt).toHaveBeenCalledWith( + 'sess_a', + expect.objectContaining({ model: 'provider/model-a', thinking: 'low' }), + ); + }); + + it('falls back to the active level for a drained prompt whose model left the catalog', async () => { + const state = createState(); + state.sessions = [{ ...createSession(), id: 'sess_a', model: 'provider/gone-model' }]; + state.thinking = 'max'; + state.inFlightBySession = { sess_a: true }; + state.queuedBySession = { sess_a: [{ text: 'follow up', attachments: undefined }] }; + const ws = useWorkspaceState( + state, + promptDeps({ + modelProvider: { + models: ref([]), + thinkingLevelForModelId: () => undefined, + } as unknown as UseWorkspaceStateDeps['modelProvider'], + }), + ); + + ws.handleSessionSnapshot('sess_a', { inFlightTurn: null, busy: true }); + + expect(apiMock.submitPrompt).toHaveBeenCalledWith( + 'sess_a', + expect.objectContaining({ model: 'provider/gone-model', thinking: 'max' }), + ); + }); + it('clears local prompt state when busy disproves a stale snapshot turn', () => { const state = createState(); state.inFlightBySession = { sess_1: true }; From 9b6ab4d534f86bcffc54e99a198b4555f0a7c359 Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 17 Jul 2026 18:13:07 +0800 Subject: [PATCH 03/12] fix(web): keep model switches from persisting derived thinking defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setModel routed the resolved level through applyThinkingLevel, which writes per-model storage unconditionally — a switch to a model with no saved pick stored the catalog default as if it were an explicit choice, pinning the user to it across later default changes, and the rollback path did the same write for a switch that never happened. Model switches now update the in-memory level only; storage writes stay with setThinking, the explicit picker path. --- .../client/useModelProviderState.ts | 18 ++++++++---- apps/kimi-web/src/lib/modelThinking.test.ts | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/apps/kimi-web/src/composables/client/useModelProviderState.ts b/apps/kimi-web/src/composables/client/useModelProviderState.ts index 75941cf491..15c91f4b59 100644 --- a/apps/kimi-web/src/composables/client/useModelProviderState.ts +++ b/apps/kimi-web/src/composables/client/useModelProviderState.ts @@ -162,9 +162,12 @@ export function useModelProviderState( } function applyThinkingLevel(level: ThinkingLevel | undefined): ThinkingLevel | undefined { - // Whatever the user picked is what gets submitted to the daemon (same as - // the TUI). Persisted under the CURRENT model id — per-model storage keeps - // a pick from leaking onto models that never declared it. Only concrete + // The explicit-picker path (setThinking) — the ONLY writer of thinking + // storage. Model switches (setModel) and passive resolution update + // rawState.thinking in-memory instead, so a level the user never actually + // picked (e.g. a derived catalog default) is never mistaken for a stored + // choice. Persisted under the CURRENT model id — per-model storage keeps a + // pick from leaking onto models that never declared it. Only concrete // levels are persisted; "no preference" stays in-memory. rawState.thinking = level; const modelId = currentModelId(); @@ -279,8 +282,11 @@ export function useModelProviderState( if (!sid) { // New-session draft (onboarding composer): no backend session to update. // Remember the pick — startSessionAndSendPrompt applies it at create time. + // In-memory only: a model switch is not a thinking pick, so nothing is + // persisted (a derived default would otherwise masquerade as an explicit + // choice later). Storage writes stay with setThinking. draftModel.value = modelId; - applyThinkingLevel(nextThinking); + rawState.thinking = nextThinking; if (nextThinking !== prevThinking && nextThinking !== undefined) { persistGlobalThinking(nextThinking); } @@ -290,7 +296,7 @@ export function useModelProviderState( // one so we can roll back if the switch never reaches the daemon. updateSession(sid, (s) => ({ ...s, model: modelId })); if (nextThinking !== prevThinking) { - applyThinkingLevel(nextThinking); + rawState.thinking = nextThinking; } try { await getKimiWebApi().updateSession(sid, { @@ -304,7 +310,7 @@ export function useModelProviderState( // new one as if the switch succeeded, then surface the failure. updateSession(sid, (s) => ({ ...s, model: prevSessionModel ?? s.model })); if (nextThinking !== prevThinking) { - applyThinkingLevel(prevThinking); + rawState.thinking = prevThinking; } pushOperationFailure('setModel', err, { sessionId: sid }); return false; diff --git a/apps/kimi-web/src/lib/modelThinking.test.ts b/apps/kimi-web/src/lib/modelThinking.test.ts index 65b6c327c9..38af01adf0 100644 --- a/apps/kimi-web/src/lib/modelThinking.test.ts +++ b/apps/kimi-web/src/lib/modelThinking.test.ts @@ -347,6 +347,34 @@ describe('useModelProviderState thinking on model selection', () => { expect(state.thinking).toBe('high'); }); + it('does not persist a derived default on model switch — storage stays pick-only', async () => { + const state = createState({ defaultModel: booleanAppModel.id }); + const provider = createModelProvider(state); + + await provider.setModel(effortAppModel.id); + + // The in-memory level follows the switch (catalog default 'high')... + expect(state.thinking).toBe('high'); + // ...but nothing is written to storage: a level the user never explicitly + // picked must not masquerade as a stored choice (it would override a future + // catalog default change). Only setThinking writes. + expect(saveThinkingToStorageMock).not.toHaveBeenCalled(); + }); + + it('does not persist the rolled-back level when a switch fails', async () => { + const state = createState({ + activeSession: { id: 'session-1', model: booleanAppModel.id }, + defaultModel: booleanAppModel.id, + }); + apiMock.updateSession.mockRejectedValueOnce(new Error('daemon unreachable')); + const provider = createModelProvider(state); + + const switched = await provider.setModel(effortAppModel.id); + + expect(switched).toBe(false); + expect(saveThinkingToStorageMock).not.toHaveBeenCalled(); + }); + it('pins the catalog default in memory when no thinking preference exists', async () => { const state = createState({ defaultModel: effortAppModel.id }); state.thinking = undefined; From 084cbdde61490c3ba7cea931971a6610c6b50566 Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 17 Jul 2026 18:28:19 +0800 Subject: [PATCH 04/12] fix(web): resolve thinking per target session on the BTW and skill paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendSideChatPromptOn combined the captured parent's model with the active-session level, so a session switch during the startBtw await sent the BTW first turn at the wrong model's effort — resolve it from the parent's own model, falling back to the active value off-catalog, same as the other submit paths. activateSkill carries no thinking either, so the daemon ran skills at the session profile effort, which can predate the per-model restore the picker now shows. Persist the resolved level to the session profile first, mirroring the new-session skill path; that path itself now resolves against the new session's model instead of the raw active value. --- .../client/useModelProviderState.ts | 10 ++++++ .../src/composables/client/useSideChat.ts | 29 ++++++++++----- .../composables/client/useWorkspaceState.ts | 7 ++-- .../src/composables/useKimiWebClient.ts | 2 ++ apps/kimi-web/src/lib/modelThinking.test.ts | 30 +++++++++++++++- apps/kimi-web/test/side-chat.test.ts | 36 +++++++++++++++++-- 6 files changed, 99 insertions(+), 15 deletions(-) diff --git a/apps/kimi-web/src/composables/client/useModelProviderState.ts b/apps/kimi-web/src/composables/client/useModelProviderState.ts index 15c91f4b59..b3ad783fcd 100644 --- a/apps/kimi-web/src/composables/client/useModelProviderState.ts +++ b/apps/kimi-web/src/composables/client/useModelProviderState.ts @@ -379,6 +379,16 @@ export function useModelProviderState( } try { + // Skill activation carries only name/args — the daemon runs the turn at + // the SESSION PROFILE effort. Persist the level resolved for this + // session's own model first (awaited, mirroring the new-session skill + // path), so a profile that predates the per-model restore can't run the + // skill at a stale effort while the UI shows the restored level. + const skillModel = rawState.sessions.find((s) => s.id === sid)?.model; + await persistSessionProfile( + { thinking: thinkingLevelForModelId(skillModel) ?? rawState.thinking }, + sid, + ); await getKimiWebApi().activateSkill(sid, skillName, args); } catch (err) { if (guarded) { diff --git a/apps/kimi-web/src/composables/client/useSideChat.ts b/apps/kimi-web/src/composables/client/useSideChat.ts index 13ca4cf433..227f57b923 100644 --- a/apps/kimi-web/src/composables/client/useSideChat.ts +++ b/apps/kimi-web/src/composables/client/useSideChat.ts @@ -9,7 +9,7 @@ import { computed, ref } from 'vue'; import { getKimiWebApi } from '../../api'; -import type { AppMessage, KimiEventConnection } from '../../api/types'; +import type { AppMessage, KimiEventConnection, ThinkingLevel } from '../../api/types'; import { messagesToTurns } from '../messagesToTurns'; import type { ChatTurn } from '../../types'; import type { ExtendedState } from '../useKimiWebClient'; @@ -23,10 +23,20 @@ export interface UseSideChatDeps { nextOptimisticMsgId: () => string; connectEventsIfNeeded: () => void; getEventConn: () => KimiEventConnection | null; + /** Resolve the thinking level for an arbitrary model id (its stored pick + * when still declared, else its catalog default); undefined when the model + * is not in the catalog. */ + thinkingLevelForModelId: (modelId: string | undefined) => ThinkingLevel | undefined; } export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) { - const { pushOperationFailure, nextOptimisticMsgId, connectEventsIfNeeded, getEventConn } = deps; + const { + pushOperationFailure, + nextOptimisticMsgId, + connectEventsIfNeeded, + getEventConn, + thinkingLevelForModelId, + } = deps; const sideChatTargetBySession = ref>({}); @@ -198,11 +208,14 @@ export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) { }; appendSideChatMessage(agentId, userMsg); try { - // Carry the parent's current thinking level, model, and permission so a - // BTW first-turn reflects the same draft/runtime controls the UI shows — - // the parent session profile mirrors them, but the prompt itself is the - // only thing the daemon reads for this turn. Thinking goes verbatim - // (same as normal prompts and the TUI). + // Carry the parent's current model, thinking, and permission so a BTW + // first-turn reflects the same draft/runtime controls the UI shows — the + // parent session profile mirrors them, but the prompt itself is the only + // thing the daemon reads for this turn. Thinking is resolved against the + // PARENT's own model (stored pick when declared, else its default) — + // never the active-session rawState.thinking: startBtw above may have + // spanned a session switch that changed what the active view resolved + // to (see submitPromptInternal in useWorkspaceState). const promptSession = rawState.sessions.find((s) => s.id === sid); const model = (promptSession?.model && promptSession.model.length > 0 @@ -212,7 +225,7 @@ export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) { content: [{ type: 'text', text: trimmed }], agentId, model, - thinking: rawState.thinking, + thinking: thinkingLevelForModelId(model) ?? rawState.thinking, permissionMode: rawState.permission, planMode: rawState.planModeBySession[sid] ?? false, swarmMode: rawState.swarmModeBySession[sid] ?? false, diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 5fad08a2a7..2226634aae 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -1193,20 +1193,21 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta // there is nothing to persist for it. const planMode = rawState.planModeBySession[sid] ?? false; const swarmMode = rawState.swarmModeBySession[sid] ?? false; - // Thinking is persisted verbatim — whatever the user picked is what the - // first skill turn runs at (same as a normal prompt, and the TUI). const promptSession = rawState.sessions.find((s) => s.id === sid); const model = (promptSession?.model && promptSession.model.length > 0 ? promptSession.model : rawState.defaultModel) ?? undefined; + // Thinking is resolved for the new session's own model (its stored pick + // when declared, else the catalog default) — never the raw active value, + // which can drift while createDraftSession awaits (see submitPromptInternal). await persistSessionProfile( { model, planMode, swarmMode, permissionMode: rawState.permission, - thinking: rawState.thinking, + thinking: modelProvider.thinkingLevelForModelId(model) ?? rawState.thinking, }, sid, ); diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 4cc1fd2c1d..6f13256689 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -1958,6 +1958,8 @@ const sideChat = useSideChat(rawState, { nextOptimisticMsgId, connectEventsIfNeeded, getEventConn: () => eventConn, + // modelProvider is defined further below; deferred like eventConn above. + thinkingLevelForModelId: (modelId) => modelProvider.thinkingLevelForModelId(modelId), }); const activeAppTasks = computed(() => { diff --git a/apps/kimi-web/src/lib/modelThinking.test.ts b/apps/kimi-web/src/lib/modelThinking.test.ts index 38af01adf0..a51fcbfab5 100644 --- a/apps/kimi-web/src/lib/modelThinking.test.ts +++ b/apps/kimi-web/src/lib/modelThinking.test.ts @@ -24,6 +24,7 @@ const apiMock = vi.hoisted(() => ({ updateSession: vi.fn(), listModels: vi.fn(), setConfig: vi.fn(), + activateSkill: vi.fn(), })); vi.mock('../api', () => ({ @@ -255,6 +256,7 @@ describe('useModelProviderState thinking on model selection', () => { // wires localStorage: tests seed storedThinkingLevels and assert writes via // saveThinkingToStorageMock. const saveThinkingToStorageMock = vi.fn(); + const persistSessionProfileMock = vi.fn(); let storedThinkingLevels: Record; beforeEach(() => { @@ -264,7 +266,11 @@ describe('useModelProviderState thinking on model selection', () => { apiMock.listModels.mockResolvedValue([effortAppModel, booleanAppModel, maxOnlyAppModel]); apiMock.setConfig.mockReset(); apiMock.setConfig.mockResolvedValue({}); + apiMock.activateSkill.mockReset(); + apiMock.activateSkill.mockResolvedValue({}); saveThinkingToStorageMock.mockReset(); + persistSessionProfileMock.mockReset(); + persistSessionProfileMock.mockResolvedValue(undefined); storedThinkingLevels = {}; }); @@ -277,6 +283,7 @@ describe('useModelProviderState thinking on model selection', () => { sessions: options.activeSession ? [options.activeSession] : [], thinking: 'off', defaultModel: options.defaultModel, + inFlightBySession: {}, } as ExtendedState; } @@ -284,7 +291,7 @@ describe('useModelProviderState thinking on model selection', () => { const deps: UseModelProviderStateDeps = { pushOperationFailure: vi.fn(), refreshSessionStatus: vi.fn().mockResolvedValue(undefined), - persistSessionProfile: vi.fn().mockResolvedValue(undefined), + persistSessionProfile: persistSessionProfileMock, activity: computed(() => 'idle'), loadThinkingForModel: (modelId) => storedThinkingLevels[modelId], saveThinkingToStorage: saveThinkingToStorageMock, @@ -375,6 +382,27 @@ describe('useModelProviderState thinking on model selection', () => { expect(saveThinkingToStorageMock).not.toHaveBeenCalled(); }); + it('applies the resolved level to the session profile before activating a skill', async () => { + // Skill activation carries no thinking — the daemon runs at the session + // profile effort. The restored per-model level must be persisted there + // first, or the skill runs at a stale profile effort the UI no longer shows. + storedThinkingLevels = { [effortAppModel.id]: 'low' }; + const state = createState({ + activeSession: { id: 'session-1', model: effortAppModel.id }, + defaultModel: booleanAppModel.id, + }); + const provider = createModelProvider(state); + + await provider.activateSkill('gen-changesets'); + + expect(persistSessionProfileMock).toHaveBeenCalledWith({ thinking: 'low' }, 'session-1'); + expect(apiMock.activateSkill).toHaveBeenCalledWith('session-1', 'gen-changesets', undefined); + // The profile write precedes the activation, mirroring the new-session path. + const persistOrder = persistSessionProfileMock.mock.invocationCallOrder[0]!; + const activateOrder = apiMock.activateSkill.mock.invocationCallOrder[0]!; + expect(persistOrder).toBeLessThan(activateOrder); + }); + it('pins the catalog default in memory when no thinking preference exists', async () => { const state = createState({ defaultModel: effortAppModel.id }); state.thinking = undefined; diff --git a/apps/kimi-web/test/side-chat.test.ts b/apps/kimi-web/test/side-chat.test.ts index 4099737aa0..fe45257ed5 100644 --- a/apps/kimi-web/test/side-chat.test.ts +++ b/apps/kimi-web/test/side-chat.test.ts @@ -66,6 +66,7 @@ describe('useSideChat — sendSideChatPromptOn', () => { nextOptimisticMsgId: () => 'msg_opt_btw', connectEventsIfNeeded: vi.fn(), getEventConn: () => null, + thinkingLevelForModelId: () => undefined, }); await sideChat.openSideChatOn('sess_1', 'what changed?'); @@ -85,9 +86,10 @@ describe('useSideChat — sendSideChatPromptOn', () => { expect(pushOperationFailure).not.toHaveBeenCalled(); }); - it('submits the stored thinking level verbatim, even when the parent model does not declare it', async () => { - // Thinking levels are never coerced onto the prompt's model (same as - // normal prompts and the TUI): a stale effort like 'max' is sent as-is. + it('falls back to the active level when the parent model has left the catalog', async () => { + // thinkingLevelForModelId returns undefined for a model the catalog no + // longer lists — the submit then keeps the active-session level (same + // fallback as the normal prompt paths). apiMock.startBtw.mockReset(); apiMock.submitPrompt.mockReset(); apiMock.startBtw.mockResolvedValue({ agentId: 'agent_btw_1' }); @@ -100,6 +102,7 @@ describe('useSideChat — sendSideChatPromptOn', () => { nextOptimisticMsgId: () => 'msg_opt_btw', connectEventsIfNeeded: vi.fn(), getEventConn: () => null, + thinkingLevelForModelId: () => undefined, }); await sideChat.openSideChatOn('sess_1', 'what changed?'); @@ -109,4 +112,31 @@ describe('useSideChat — sendSideChatPromptOn', () => { expect.objectContaining({ thinking: 'max' }), ); }); + + it('resolves thinking from the parent model, not the level of the session the user switched to', async () => { + // startBtw spans an await during which the user can switch sessions; the + // BTW prompt must still carry the PARENT model's level ('low'), never the + // active view's ('max'). + apiMock.startBtw.mockReset(); + apiMock.submitPrompt.mockReset(); + apiMock.startBtw.mockResolvedValue({ agentId: 'agent_btw_1' }); + apiMock.submitPrompt.mockResolvedValue({ promptId: 'pr_btw', userMessageId: 'msg_opt_btw' }); + + const state = createState(); + state.thinking = 'max'; // the user is now viewing a max-only session elsewhere + const sideChat = useSideChat(state, { + pushOperationFailure: vi.fn(), + nextOptimisticMsgId: () => 'msg_opt_btw', + connectEventsIfNeeded: vi.fn(), + getEventConn: () => null, + thinkingLevelForModelId: (id) => (id === 'kimi-code' ? 'low' : undefined), + }); + + await sideChat.openSideChatOn('sess_1', 'what changed?'); + + expect(apiMock.submitPrompt).toHaveBeenCalledWith( + 'sess_1', + expect.objectContaining({ model: 'kimi-code', thinking: 'low' }), + ); + }); }); From 198b2507a083b9e849d0af1bdd23f212f36d60ca Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 17 Jul 2026 18:38:06 +0800 Subject: [PATCH 05/12] fix(web): keep per-model thinking picks in memory as the runtime truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolver re-read localStorage on every submission, letting storage — not the displayed state — decide what the daemon receives: with storage unavailable (policy/quota) an explicit pick reached the UI while every submit path fell back to the catalog default, and a pick made in another tab silently changed what this tab submits mid-session. Per-model picks now live in an in-memory map hydrated from localStorage at startup; explicit picks update it first and persist best-effort (read-modify-write merge, so concurrent tabs' entries still survive). localStorage is only hydration plus persistence — another tab's pick can no longer alter this tab's runtime level. --- .../kimi-web/src/composables/useKimiWebClient.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 6f13256689..06cc5a4b92 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -173,13 +173,23 @@ function loadThinkingMap(): Record { return out; } +// The RUNTIME source of truth for per-model picks is this in-memory map, +// hydrated from localStorage once at startup. Explicit picks update it first +// (see saveThinkingToStorage), so a pick takes effect even when persistence is +// unavailable (storage policy/quota — safeSetJson swallows those failures), +// and so a pick made later in ANOTHER tab can never change what this tab +// displays or submits mid-session. localStorage is hydration + best-effort +// persistence only: written with a read-modify-write merge (same pattern as +// saveUnread) so concurrent tabs' entries survive, and re-read from scratch on +// the next startup. +const thinkingPicks: Record = loadThinkingMap(); + function loadThinkingForModel(modelId: string): ThinkingLevel | undefined { - return loadThinkingMap()[modelId]; + return thinkingPicks[modelId]; } function saveThinkingToStorage(modelId: string, level: ThinkingLevel): void { - // Read-modify-write the whole map (same pattern as saveUnread) so a pick made - // in another tab for a different model isn't clobbered by this tab's write. + thinkingPicks[modelId] = level; const map = loadThinkingMap(); map[modelId] = level; safeSetJson(THINKING_STORAGE_KEY, map); From 17a3550c940b72253f90931401bb64eb98e66551 Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 17 Jul 2026 18:47:34 +0800 Subject: [PATCH 06/12] fix(web): carry the legacy global thinking pick forward as a fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-map installs stored a single global level as a raw string; the map parser dropped it, silently resetting the user's explicit preference to the catalog default on upgrade. The legacy value is now carried as a fallback for models without their own entry — validated against each model's catalog at resolution, so effort models keep the user's pick while a max-only model still falls through to its default and can never be trapped by it. --- .../src/composables/useKimiWebClient.ts | 22 ++++++++-- apps/kimi-web/src/lib/modelThinking.test.ts | 40 ++++++++++++++++++- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 06cc5a4b92..a19ea7d97e 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -126,8 +126,10 @@ const ONBOARDED_STORAGE_KEY = STORAGE_KEYS.onboarded; // picked for one model (e.g. 'low' on an effort model) from leaking onto a // model that never declared it (e.g. a max-only always-on model) — the old // global format did exactly that, leaving the composer showing nothing selected -// with no way to switch. A legacy plain-string value fails JSON.parse here and -// is dropped; the next explicit pick rewrites the key in the map format. +// with no way to switch. The legacy pre-map format (a single global level, +// stored raw) is not discarded: it becomes a fallback for any model that +// declares it (see hydrateThinkingPicks), so an existing user's preference +// survives the upgrade while a max-only model still can't be trapped by it. const PERSISTED_THINKING_LEVEL_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,31}$/; // Appearance types + logic live in ./client/useAppearance; re-exported here so @@ -173,6 +175,14 @@ function loadThinkingMap(): Record { return out; } +// Legacy pre-map format: a single global level stored raw via safeSetString +// (not JSON). It fails JSON.parse in loadThinkingMap above — detect it from the +// raw string instead of dropping the user's preference on upgrade. +function loadLegacyThinkingValue(): ThinkingLevel | undefined { + const raw = safeGetString(THINKING_STORAGE_KEY); + return raw && PERSISTED_THINKING_LEVEL_RE.test(raw) ? (raw as ThinkingLevel) : undefined; +} + // The RUNTIME source of truth for per-model picks is this in-memory map, // hydrated from localStorage once at startup. Explicit picks update it first // (see saveThinkingToStorage), so a pick takes effect even when persistence is @@ -183,9 +193,15 @@ function loadThinkingMap(): Record { // saveUnread) so concurrent tabs' entries survive, and re-read from scratch on // the next startup. const thinkingPicks: Record = loadThinkingMap(); +const legacyThinkingPick: ThinkingLevel | undefined = + Object.keys(thinkingPicks).length === 0 ? loadLegacyThinkingValue() : undefined; function loadThinkingForModel(modelId: string): ThinkingLevel | undefined { - return thinkingPicks[modelId]; + // Per-model entries win. A legacy pre-map global pick stays as a fallback for + // models without their own entry — useModelProviderState validates it against + // each model's catalog entry, so it only ever applies to models that declare + // it (a max-only model still falls through to its default). + return thinkingPicks[modelId] ?? legacyThinkingPick; } function saveThinkingToStorage(modelId: string, level: ThinkingLevel): void { diff --git a/apps/kimi-web/src/lib/modelThinking.test.ts b/apps/kimi-web/src/lib/modelThinking.test.ts index a51fcbfab5..7fa93449b4 100644 --- a/apps/kimi-web/src/lib/modelThinking.test.ts +++ b/apps/kimi-web/src/lib/modelThinking.test.ts @@ -258,6 +258,7 @@ describe('useModelProviderState thinking on model selection', () => { const saveThinkingToStorageMock = vi.fn(); const persistSessionProfileMock = vi.fn(); let storedThinkingLevels: Record; + let legacyThinkingPick: ThinkingLevel | undefined; beforeEach(() => { apiMock.updateSession.mockReset(); @@ -272,6 +273,7 @@ describe('useModelProviderState thinking on model selection', () => { persistSessionProfileMock.mockReset(); persistSessionProfileMock.mockResolvedValue(undefined); storedThinkingLevels = {}; + legacyThinkingPick = undefined; }); function createState(options: { @@ -293,7 +295,7 @@ describe('useModelProviderState thinking on model selection', () => { refreshSessionStatus: vi.fn().mockResolvedValue(undefined), persistSessionProfile: persistSessionProfileMock, activity: computed(() => 'idle'), - loadThinkingForModel: (modelId) => storedThinkingLevels[modelId], + loadThinkingForModel: (modelId) => storedThinkingLevels[modelId] ?? legacyThinkingPick, saveThinkingToStorage: saveThinkingToStorageMock, updateSession: (id, update) => { state.sessions = state.sessions.map((session) => @@ -403,6 +405,42 @@ describe('useModelProviderState thinking on model selection', () => { expect(persistOrder).toBeLessThan(activateOrder); }); + it('treats a legacy pre-map pick as a fallback only for models that declare it', async () => { + // Upgrade migration: the facade serves the old global pick for models + // without their own entry; validation keeps it off models that can't run it. + legacyThinkingPick = 'low'; + + const effortState = createState({ + activeSession: { id: 'session-1', model: effortAppModel.id }, + defaultModel: effortAppModel.id, + }); + const effortProvider = createModelProvider(effortState); + await effortProvider.loadModels(); + expect(effortState.thinking).toBe('low'); + + const maxOnlyState = createState({ + activeSession: { id: 'session-1', model: maxOnlyAppModel.id }, + defaultModel: maxOnlyAppModel.id, + }); + const maxOnlyProvider = createModelProvider(maxOnlyState); + await maxOnlyProvider.loadModels(); + expect(maxOnlyState.thinking).toBe('max'); + }); + + it('lets a per-model entry win over the legacy pre-map pick', async () => { + legacyThinkingPick = 'low'; + storedThinkingLevels = { [effortAppModel.id]: 'high' }; + const state = createState({ + activeSession: { id: 'session-1', model: effortAppModel.id }, + defaultModel: effortAppModel.id, + }); + const provider = createModelProvider(state); + + await provider.loadModels(); + + expect(state.thinking).toBe('high'); + }); + it('pins the catalog default in memory when no thinking preference exists', async () => { const state = createState({ defaultModel: effortAppModel.id }); state.thinking = undefined; From 0b844e7bd93374df9201008befdb6d11aaf05f41 Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 17 Jul 2026 19:02:41 +0800 Subject: [PATCH 07/12] fix(web): keep the legacy thinking fallback across the first map rewrite The first explicit pick after an upgrade rewrote the raw legacy value into a map containing only that one model, so the next reload saw a nonempty map and dropped the legacy fallback for every other model. The migrated value now lives inside the map under a '*' key that no real model id can collide with: per-model entries override it, and rewrites persist it alongside them instead of deleting it. --- .../src/composables/useKimiWebClient.ts | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index a19ea7d97e..e99cc72ffc 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -183,6 +183,12 @@ function loadLegacyThinkingValue(): ThinkingLevel | undefined { return raw && PERSISTED_THINKING_LEVEL_RE.test(raw) ? (raw as ThinkingLevel) : undefined; } +// Map key for the migrated legacy value — never a real model id. Kept INSIDE +// the map (not a sidecar variable) so the first per-model write, which rewrites +// the raw legacy string into map format, carries the fallback forward instead +// of deleting it for every model without its own entry. +const LEGACY_THINKING_PICK_KEY = '*'; + // The RUNTIME source of truth for per-model picks is this in-memory map, // hydrated from localStorage once at startup. Explicit picks update it first // (see saveThinkingToStorage), so a pick takes effect even when persistence is @@ -193,21 +199,26 @@ function loadLegacyThinkingValue(): ThinkingLevel | undefined { // saveUnread) so concurrent tabs' entries survive, and re-read from scratch on // the next startup. const thinkingPicks: Record = loadThinkingMap(); -const legacyThinkingPick: ThinkingLevel | undefined = - Object.keys(thinkingPicks).length === 0 ? loadLegacyThinkingValue() : undefined; +if (Object.keys(thinkingPicks).length === 0) { + const legacy = loadLegacyThinkingValue(); + if (legacy !== undefined) thinkingPicks[LEGACY_THINKING_PICK_KEY] = legacy; +} function loadThinkingForModel(modelId: string): ThinkingLevel | undefined { - // Per-model entries win. A legacy pre-map global pick stays as a fallback for - // models without their own entry — useModelProviderState validates it against - // each model's catalog entry, so it only ever applies to models that declare - // it (a max-only model still falls through to its default). - return thinkingPicks[modelId] ?? legacyThinkingPick; + // Per-model entries win; the '*' entry is the migrated legacy pre-map global + // pick — useModelProviderState validates it against each model's catalog + // entry, so it only ever applies to models that declare it (a max-only model + // still falls through to its default). + return thinkingPicks[modelId] ?? thinkingPicks[LEGACY_THINKING_PICK_KEY]; } function saveThinkingToStorage(modelId: string, level: ThinkingLevel): void { thinkingPicks[modelId] = level; + // Read-modify-write: merge this tab's in-memory entries (including any '*' + // legacy fallback) over the stored map, so concurrent tabs' entries survive + // and the legacy value is rewritten in map format rather than dropped. const map = loadThinkingMap(); - map[modelId] = level; + for (const [id, pick] of Object.entries(thinkingPicks)) map[id] = pick; safeSetJson(THINKING_STORAGE_KEY, map); } From 818bfb5f7449f565e4a97ceab478655155da4b73 Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 17 Jul 2026 19:19:36 +0800 Subject: [PATCH 08/12] fix(web): persist only the changed thinking pick on write Overlaying the whole in-memory map on write could revert a newer pick made in another tab for a model this tab still held a stale copy of. Write the changed entry alone (delta-style, like saveUnread), carrying only the migrated legacy '*' fallback along so it survives the first rewrite into map format. --- apps/kimi-web/src/composables/useKimiWebClient.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index e99cc72ffc..72cee8fc9a 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -214,11 +214,15 @@ function loadThinkingForModel(modelId: string): ThinkingLevel | undefined { function saveThinkingToStorage(modelId: string, level: ThinkingLevel): void { thinkingPicks[modelId] = level; - // Read-modify-write: merge this tab's in-memory entries (including any '*' - // legacy fallback) over the stored map, so concurrent tabs' entries survive - // and the legacy value is rewritten in map format rather than dropped. + // Delta write (same pattern as saveUnread): overlay only the CHANGED entry — + // overlaying this tab's whole in-memory snapshot would revert another tab's + // newer pick for any model this tab still holds a stale copy of. The one + // extra entry carried along is the migrated legacy '*' fallback, so the + // first write rewrites it into map format rather than dropping it. const map = loadThinkingMap(); - for (const [id, pick] of Object.entries(thinkingPicks)) map[id] = pick; + const legacy = thinkingPicks[LEGACY_THINKING_PICK_KEY]; + if (legacy !== undefined) map[LEGACY_THINKING_PICK_KEY] = legacy; + map[modelId] = level; safeSetJson(THINKING_STORAGE_KEY, map); } From 94383b31e5ea6165d3ba31393972d66fe5fe3ea6 Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 17 Jul 2026 19:27:59 +0800 Subject: [PATCH 09/12] fix(web): abort skill activation when the thinking profile persist fails persistSessionProfile surfaces failures itself and resolves, so awaiting it never blocked a following activation: a failed /profile write still launched the skill at the session's stale effort. It now resolves a success flag; both activation paths (existing session and new-session draft) gate on it and skip activating when the persist fails, without reporting a second, synthetic error. --- .../client/useModelProviderState.ts | 20 ++++++++++++---- .../composables/client/useWorkspaceState.ts | 10 ++++++-- .../src/composables/useKimiWebClient.ts | 19 ++++++++------- apps/kimi-web/src/lib/modelThinking.test.ts | 19 ++++++++++++++- apps/kimi-web/test/workspace-state.test.ts | 23 ++++++++++--------- 5 files changed, 65 insertions(+), 26 deletions(-) diff --git a/apps/kimi-web/src/composables/client/useModelProviderState.ts b/apps/kimi-web/src/composables/client/useModelProviderState.ts index b3ad783fcd..bf7f22e6f4 100644 --- a/apps/kimi-web/src/composables/client/useModelProviderState.ts +++ b/apps/kimi-web/src/composables/client/useModelProviderState.ts @@ -29,6 +29,11 @@ import type { ExtendedState } from '../useKimiWebClient'; const STARRED_MODELS_STORAGE_KEY = STORAGE_KEYS.starredModels; +/** Sentinel thrown to abort a skill activation when the prerequisite profile + * persist failed — persistSessionProfile already surfaced that failure, so + * the catch skips activating without reporting a second, synthetic error. */ +const PROFILE_PERSIST_FAILED = Symbol('profilePersistFailed'); + function loadStarredModelsFromStorage(): string[] { try { const raw = safeGetString(STARRED_MODELS_STORAGE_KEY); @@ -68,7 +73,10 @@ export interface UseModelProviderStateDeps { opts?: { title?: string; message?: string; sessionId?: string }, ) => void; refreshSessionStatus: (sessionId: string) => Promise; - persistSessionProfile: (patch: PersistSessionProfilePatch, sessionId?: string) => Promise; + /** Persist profile fields to the daemon. Resolves false (after surfacing the + * failure itself) when the daemon rejected the patch — awaited callers that + * order strictly after the profile must NOT proceed on false. */ + persistSessionProfile: (patch: PersistSessionProfilePatch, sessionId?: string) => Promise; activity: ComputedRef; /** Read the persisted thinking pick for a model (undefined = never picked * for that model). Storage is keyed by model id. */ @@ -383,19 +391,23 @@ export function useModelProviderState( // the SESSION PROFILE effort. Persist the level resolved for this // session's own model first (awaited, mirroring the new-session skill // path), so a profile that predates the per-model restore can't run the - // skill at a stale effort while the UI shows the restored level. + // skill at a stale effort while the UI shows the restored level. When + // the persist fails (it surfaces the error itself), activating would + // launch the skill at exactly that stale effort — abort instead. const skillModel = rawState.sessions.find((s) => s.id === sid)?.model; - await persistSessionProfile( + const persisted = await persistSessionProfile( { thinking: thinkingLevelForModelId(skillModel) ?? rawState.thinking }, sid, ); + if (!persisted) throw PROFILE_PERSIST_FAILED; await getKimiWebApi().activateSkill(sid, skillName, args); } catch (err) { if (guarded) { rawState.inFlightBySession = { ...rawState.inFlightBySession, [sid]: false }; updateSessionMessages(sid, (msgs) => msgs.filter((m) => m.id !== tempId)); } - pushOperationFailure('activateSkill', err, { sessionId: sid }); + // The persist failure was already surfaced by persistSessionProfile. + if (err !== PROFILE_PERSIST_FAILED) pushOperationFailure('activateSkill', err, { sessionId: sid }); } finally { // The daemon answered the activation (accepted or rejected) — the // pending window in which a snapshot can't reflect this turn is over. diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 2226634aae..3eb8986635 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -238,7 +238,10 @@ export interface UseWorkspaceStateDeps { hasLoadedMessages: (sessionId: string) => boolean; refreshSessionStatus: (sessionId: string) => Promise; refreshSessionGoal: (sessionId: string) => Promise; - persistSessionProfile: (patch: PersistSessionProfilePatch, sessionId?: string) => Promise; + /** Persist profile fields to the daemon. Resolves false (after surfacing the + * failure itself) when the daemon rejected the patch — awaited callers that + * order strictly after the profile must NOT proceed on false. */ + persistSessionProfile: (patch: PersistSessionProfilePatch, sessionId?: string) => Promise; mergedWorkspaces: ComputedRef; /** Sidebar-facing workspaces in the user's (dragged) display order. */ workspacesView: ComputedRef; @@ -1201,7 +1204,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta // Thinking is resolved for the new session's own model (its stored pick // when declared, else the catalog default) — never the raw active value, // which can drift while createDraftSession awaits (see submitPromptInternal). - await persistSessionProfile( + const persisted = await persistSessionProfile( { model, planMode, @@ -1211,6 +1214,9 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta }, sid, ); + // The persist surfaces its own failure; activating at a stale profile + // effort is worse than not activating (the finally still re-arms below). + if (!persisted) return; await modelProvider.activateSkill(skillName, args, sid); } catch (err) { pushOperationFailure('startSessionAndActivateSkill', err); diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 72cee8fc9a..db5281071a 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -775,12 +775,13 @@ async function refreshSessionGoal(sessionId: string): Promise { * session and immediately persisting its draft modes, so a concurrent session * switch can't write the patch to the wrong session. * - * Returns the update promise. Failures are surfaced via pushOperationFailure - * (the UI already updated optimistically, so the user must be told when the - * daemon did not apply the change); the promise itself never rejects. Most - * callers fire-and-forget via `void persistSessionProfile(...)`; call sites - * that must order strictly after the profile (e.g. a skill activation that - * can't carry its own modes) await it. */ + * Resolves false when the daemon did not apply the patch (also surfaced via + * pushOperationFailure — the UI already updated optimistically, so the user + * must be told); true on success. Most callers fire-and-forget via + * `void persistSessionProfile(...)`; call sites that must order strictly + * after the profile (e.g. a skill activation that can't carry its own modes) + * await it and must NOT proceed on false — awaiting alone enforces nothing, + * since the promise never rejects. */ function persistSessionProfile(patch: { model?: string; permissionMode?: string; @@ -789,16 +790,18 @@ function persistSessionProfile(patch: { goalObjective?: string; goalControl?: 'pause' | 'resume' | 'cancel'; thinking?: string; -}, sessionId?: string): Promise { +}, sessionId?: string): Promise { const sid = sessionId ?? rawState.activeSessionId; - if (!sid) return Promise.resolve(); + if (!sid) return Promise.resolve(false); // Promise.resolve wrap: tolerate a sync/undefined return (e.g. test mocks). return Promise.resolve(getKimiWebApi().updateSession(sid, patch)) .then(() => refreshSessionStatus(sid)) + .then(() => true) .catch((err) => { // Local state already reflects the change; tell the user (and the log) // that the daemon did not persist it. pushOperationFailure('persistSessionProfile', err, { sessionId: sid }); + return false; }); } diff --git a/apps/kimi-web/src/lib/modelThinking.test.ts b/apps/kimi-web/src/lib/modelThinking.test.ts index 7fa93449b4..ca97d3db3b 100644 --- a/apps/kimi-web/src/lib/modelThinking.test.ts +++ b/apps/kimi-web/src/lib/modelThinking.test.ts @@ -271,7 +271,7 @@ describe('useModelProviderState thinking on model selection', () => { apiMock.activateSkill.mockResolvedValue({}); saveThinkingToStorageMock.mockReset(); persistSessionProfileMock.mockReset(); - persistSessionProfileMock.mockResolvedValue(undefined); + persistSessionProfileMock.mockResolvedValue(true); storedThinkingLevels = {}; legacyThinkingPick = undefined; }); @@ -405,6 +405,23 @@ describe('useModelProviderState thinking on model selection', () => { expect(persistOrder).toBeLessThan(activateOrder); }); + it('does not activate the skill when the thinking profile update fails', async () => { + // persistSessionProfile resolves false after surfacing the failure itself: + // activating would run the skill at the stale profile effort, so it must + // not happen — and activateSkill must not report a second, synthetic error. + persistSessionProfileMock.mockResolvedValue(false); + const state = createState({ + activeSession: { id: 'session-1', model: effortAppModel.id }, + defaultModel: booleanAppModel.id, + }); + const provider = createModelProvider(state); + + await provider.activateSkill('gen-changesets'); + + expect(apiMock.activateSkill).not.toHaveBeenCalled(); + expect(state.inFlightBySession['session-1']).toBe(false); + }); + it('treats a legacy pre-map pick as a fallback only for models that declare it', async () => { // Upgrade migration: the facade serves the old global pick for models // without their own entry; validation keeps it off models that can't run it. diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index 67e0d417df..17582405ea 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -135,7 +135,7 @@ function createDeps(): UseWorkspaceStateDeps { hasLoadedMessages: vi.fn(), refreshSessionStatus: vi.fn(), refreshSessionGoal: vi.fn(), - persistSessionProfile: vi.fn(), + persistSessionProfile: vi.fn().mockResolvedValue(true), mergedWorkspaces: computed(() => []), workspacesView: computed(() => []), status: computed(() => ({})), @@ -779,8 +779,8 @@ describe('useWorkspaceState — startSessionAndActivateSkill', () => { // the draft. We persist them to the new session's profile and must WAIT for // it; otherwise :activate can race ahead of applyAgentState and the first // skill turn runs at daemon defaults while the UI shows otherwise. - let resolveProfile!: () => void; - const profileGate = new Promise((r) => { + let resolveProfile!: (persisted: boolean) => void; + const profileGate = new Promise((r) => { resolveProfile = r; }); const activateSkill = vi.fn().mockResolvedValue(undefined); @@ -806,18 +806,18 @@ describe('useWorkspaceState — startSessionAndActivateSkill', () => { ); expect(activateSkill).not.toHaveBeenCalled(); - resolveProfile(); + resolveProfile(true); await pending; expect(activateSkill).toHaveBeenCalledWith('pre-changelog', undefined, 'sess_new'); }); - it('persists the stored thinking level verbatim, even when the new session model does not declare it', async () => { - // Thinking levels are never coerced onto the session model (same as the - // first-prompt path and the TUI): a carried-over effort like 'max' is - // persisted and sent as-is. + it('falls back to persisting the active level when the new session model cannot be resolved', async () => { + // thinkingLevelForModelId returns undefined (model not resolvable here) — + // the persist then keeps the active-session level verbatim, same as the + // fallback on the normal prompt paths. const activateSkill2 = vi.fn().mockResolvedValue(undefined); - const persistSessionProfile2 = vi.fn().mockResolvedValue(undefined); + const persistSessionProfile2 = vi.fn().mockResolvedValue(true); const state2 = createState(); state2.thinking = 'max'; const deps2: UseWorkspaceStateDeps = { @@ -830,8 +830,9 @@ describe('useWorkspaceState — startSessionAndActivateSkill', () => { }), draftModes: { planMode: true, swarmMode: false, goalMode: false }, }; - // 'kimi-code' declares efforts ['low','medium','high'] — 'max' isn't in the - // list, and must still be persisted verbatim. + // 'kimi-code' declares efforts ['low','medium','high']. The resolver mock + // still returns undefined below, so the persisted level stays the active + // one ('max') via the catalog-miss fallback. (deps2.modelProvider as unknown as { models: unknown }).models = ref([ { id: 'kimi-code', From 2175508baa927df7633a6a41c0bdf3808cdd464c Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 17 Jul 2026 19:40:03 +0800 Subject: [PATCH 10/12] refactor(web): persist the new-session skill profile's thinking once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startSessionAndActivateSkill persisted the resolved thinking and then activateSkill persisted it again unconditionally — a redundant profile update and status refresh whose transient failure would false-veto an activation whose prerequisite profile was already applied. Thinking is now written by activateSkill alone (the single, gated writer); the draft patch carries only model, plan/swarm and permission. --- .../composables/client/useWorkspaceState.ts | 22 ++++++----- apps/kimi-web/test/workspace-state.test.ts | 38 +++++++------------ 2 files changed, 25 insertions(+), 35 deletions(-) diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 3eb8986635..5f5cdbfa5b 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -1188,12 +1188,14 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta if (!sid) return; // Unlike a plain prompt, skill activation only carries `args`, so the // daemon never sees the prompt-time controls the user may have changed on - // the draft (plan/swarm, plus permission via /auto|/yolo and thinking via - // /thinking). Persist them onto this new session's profile and await it - // before activating, otherwise the first skill turn can start before - // applyAgentState and run at daemon defaults while the UI shows otherwise. - // Goal mode is a one-shot flag consumed per send, not a profile field, so - // there is nothing to persist for it. + // the draft (plan/swarm, plus permission via /auto|/yolo). Persist them + // onto this new session's profile and await it before activating, + // otherwise the first skill turn can start before applyAgentState and + // run at daemon defaults while the UI shows otherwise. Thinking is NOT + // persisted here — activateSkill resolves and persists it for this + // session's model (gated) immediately before activating. Goal mode is a + // one-shot flag consumed per send, not a profile field, so there is + // nothing to persist for it. const planMode = rawState.planModeBySession[sid] ?? false; const swarmMode = rawState.swarmModeBySession[sid] ?? false; const promptSession = rawState.sessions.find((s) => s.id === sid); @@ -1201,16 +1203,16 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta (promptSession?.model && promptSession.model.length > 0 ? promptSession.model : rawState.defaultModel) ?? undefined; - // Thinking is resolved for the new session's own model (its stored pick - // when declared, else the catalog default) — never the raw active value, - // which can drift while createDraftSession awaits (see submitPromptInternal). + // No thinking in this patch: activateSkill itself resolves and persists + // the level for this session's model (single writer, gated) right before + // activating — a second write here would be a redundant profile update + // whose transient failure could false-veto a ready activation. const persisted = await persistSessionProfile( { model, planMode, swarmMode, permissionMode: rawState.permission, - thinking: modelProvider.thinkingLevelForModelId(model) ?? rawState.thinking, }, sid, ); diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index 17582405ea..5ea1a8615c 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -775,9 +775,9 @@ describe('useWorkspaceState — startSessionAndActivateSkill', () => { it('awaits the profile POST before activating, so draft controls apply first', async () => { // Skill activation only carries `args`, so the daemon never sees the per- - // prompt controls (plan/swarm plus permission and thinking) the user set on - // the draft. We persist them to the new session's profile and must WAIT for - // it; otherwise :activate can race ahead of applyAgentState and the first + // prompt controls (plan/swarm plus permission) the user set on the draft. + // We persist them to the new session's profile and must WAIT for it; + // otherwise :activate can race ahead of applyAgentState and the first // skill turn runs at daemon defaults while the UI shows otherwise. let resolveProfile!: (persisted: boolean) => void; const profileGate = new Promise((r) => { @@ -801,7 +801,7 @@ describe('useWorkspaceState — startSessionAndActivateSkill', () => { // Activation must NOT have started while /profile is still pending. await new Promise((r) => setTimeout(r, 0)); expect(persistSessionProfile).toHaveBeenCalledWith( - { model: undefined, planMode: true, swarmMode: true, permissionMode: 'auto', thinking: 'high' }, + { model: undefined, planMode: true, swarmMode: true, permissionMode: 'auto' }, 'sess_new', ); expect(activateSkill).not.toHaveBeenCalled(); @@ -812,10 +812,11 @@ describe('useWorkspaceState — startSessionAndActivateSkill', () => { expect(activateSkill).toHaveBeenCalledWith('pre-changelog', undefined, 'sess_new'); }); - it('falls back to persisting the active level when the new session model cannot be resolved', async () => { - // thinkingLevelForModelId returns undefined (model not resolvable here) — - // the persist then keeps the active-session level verbatim, same as the - // fallback on the normal prompt paths. + it('does not write thinking in the draft profile patch — activateSkill persists it once', async () => { + // activateSkill resolves and persists the level itself (gated) right + // before activating. Duplicating the write in THIS patch would be a + // redundant profile update whose transient failure could veto an + // otherwise-ready activation, so the draft patch must not carry it. const activateSkill2 = vi.fn().mockResolvedValue(undefined); const persistSessionProfile2 = vi.fn().mockResolvedValue(true); const state2 = createState(); @@ -830,27 +831,14 @@ describe('useWorkspaceState — startSessionAndActivateSkill', () => { }), draftModes: { planMode: true, swarmMode: false, goalMode: false }, }; - // 'kimi-code' declares efforts ['low','medium','high']. The resolver mock - // still returns undefined below, so the persisted level stays the active - // one ('max') via the catalog-miss fallback. - (deps2.modelProvider as unknown as { models: unknown }).models = ref([ - { - id: 'kimi-code', - model: 'kimi-code', - provider: 'kimi', - displayName: 'kimi-code', - capabilities: ['thinking'], - supportEfforts: ['low', 'medium', 'high'], - }, - ]); const ws2 = useWorkspaceState(state2, deps2); await ws2.startSessionAndActivateSkill('wd_1', 'pre-changelog'); - expect(persistSessionProfile2).toHaveBeenCalledWith( - expect.objectContaining({ thinking: 'max' }), - 'sess_new', - ); + expect(persistSessionProfile2).toHaveBeenCalledOnce(); + const patch = persistSessionProfile2.mock.calls[0]![0] as Record; + expect(patch).toMatchObject({ model: 'kimi-code', planMode: true, swarmMode: false }); + expect('thinking' in patch).toBe(false); expect(activateSkill2).toHaveBeenCalledWith('pre-changelog', undefined, 'sess_new'); }); From 608ad968243d7e09eb2a8c139a59239d00e510ea Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 17 Jul 2026 19:42:45 +0800 Subject: [PATCH 11/12] fix(web): throw an Error instance for the profile-persist sentinel oxlint --type-aware (only-throw-error) rejects throwing a Symbol; the identity-based sentinel works the same as a shared Error instance. --- .../kimi-web/src/composables/client/useModelProviderState.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/kimi-web/src/composables/client/useModelProviderState.ts b/apps/kimi-web/src/composables/client/useModelProviderState.ts index bf7f22e6f4..d3ba184b54 100644 --- a/apps/kimi-web/src/composables/client/useModelProviderState.ts +++ b/apps/kimi-web/src/composables/client/useModelProviderState.ts @@ -31,8 +31,9 @@ const STARRED_MODELS_STORAGE_KEY = STORAGE_KEYS.starredModels; /** Sentinel thrown to abort a skill activation when the prerequisite profile * persist failed — persistSessionProfile already surfaced that failure, so - * the catch skips activating without reporting a second, synthetic error. */ -const PROFILE_PERSIST_FAILED = Symbol('profilePersistFailed'); + * the catch skips activating without reporting a second, synthetic error. + * (An actual Error instance: oxlint only-throw-error.) */ +const PROFILE_PERSIST_FAILED = new Error('profile persist failed'); function loadStarredModelsFromStorage(): string[] { try { From 1837681f3ffa8e1f4bf3c6b65589addb784e8c23 Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 17 Jul 2026 19:48:14 +0800 Subject: [PATCH 12/12] fix(web): resolve an empty session model through the default before skills session.model can be '' transiently (daemon profile echo), so activateSkill fell back to the raw active-view level; in the new-session flow a concurrent switch could persist another model's effort onto the target session. Normalize '' through the configured default_model first, same as the prompt/BTW/steer paths. --- .../composables/client/useModelProviderState.ts | 6 +++++- apps/kimi-web/src/lib/modelThinking.test.ts | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/kimi-web/src/composables/client/useModelProviderState.ts b/apps/kimi-web/src/composables/client/useModelProviderState.ts index d3ba184b54..059d6cb67a 100644 --- a/apps/kimi-web/src/composables/client/useModelProviderState.ts +++ b/apps/kimi-web/src/composables/client/useModelProviderState.ts @@ -395,7 +395,11 @@ export function useModelProviderState( // skill at a stale effort while the UI shows the restored level. When // the persist fails (it surfaces the error itself), activating would // launch the skill at exactly that stale effort — abort instead. - const skillModel = rawState.sessions.find((s) => s.id === sid)?.model; + // Session models can be '' transiently (daemon profile echo) — treat + // that as "unset" and resolve through the configured default, same as + // the prompt/BTW/steer paths, before selecting the thinking level. + const rawModel = rawState.sessions.find((s) => s.id === sid)?.model; + const skillModel = (rawModel && rawModel.length > 0 ? rawModel : rawState.defaultModel) ?? undefined; const persisted = await persistSessionProfile( { thinking: thinkingLevelForModelId(skillModel) ?? rawState.thinking }, sid, diff --git a/apps/kimi-web/src/lib/modelThinking.test.ts b/apps/kimi-web/src/lib/modelThinking.test.ts index ca97d3db3b..6ae0bcc9ae 100644 --- a/apps/kimi-web/src/lib/modelThinking.test.ts +++ b/apps/kimi-web/src/lib/modelThinking.test.ts @@ -422,6 +422,23 @@ describe('useModelProviderState thinking on model selection', () => { expect(state.inFlightBySession['session-1']).toBe(false); }); + it('resolves an empty session model through the default model before activating a skill', async () => { + // The daemon's profile echo can leave session.model '' — the same fallback + // the prompt/BTW/steer paths apply must hold here too, or the profile gets + // the raw active level instead of the target session model's pick. + storedThinkingLevels = { [effortAppModel.id]: 'low' }; + const state = createState({ + activeSession: { id: 'session-1', model: '' }, + defaultModel: effortAppModel.id, + }); + const provider = createModelProvider(state); + + await provider.activateSkill('gen-changesets'); + + expect(persistSessionProfileMock).toHaveBeenCalledWith({ thinking: 'low' }, 'session-1'); + expect(apiMock.activateSkill).toHaveBeenCalledWith('session-1', 'gen-changesets', undefined); + }); + it('treats a legacy pre-map pick as a fallback only for models that declare it', async () => { // Upgrade migration: the facade serves the old global pick for models // without their own entry; validation keeps it off models that can't run it.