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..059d6cb67a 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'; @@ -28,6 +29,12 @@ 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. + * (An actual Error instance: oxlint only-throw-error.) */ +const PROFILE_PERSIST_FAILED = new Error('profile persist failed'); + function loadStarredModelsFromStorage(): string[] { try { const raw = safeGetString(STARRED_MODELS_STORAGE_KEY); @@ -67,9 +74,16 @@ 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; - 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 +103,7 @@ export function useModelProviderState( refreshSessionStatus, persistSessionProfile, activity, + loadThinkingForModel, saveThinkingToStorage, updateSession, updateSessionMessages, @@ -131,15 +146,61 @@ 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); + } + + /** 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 { - // 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. + // 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; - 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 +239,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,12 +280,22 @@ 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. + // 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); } @@ -236,7 +305,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, { @@ -250,7 +319,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; @@ -319,13 +388,31 @@ 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. When + // the persist fails (it surfaces the error itself), activating would + // launch the skill at exactly that stale effort — abort instead. + // 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, + ); + 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. @@ -449,6 +536,7 @@ export function useModelProviderState( loadModels, loadProviders, setModel, + thinkingLevelForModelId, toggleStarModel, activateSkill, addProvider, 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 a921951342..5f5cdbfa5b 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; @@ -1185,31 +1188,37 @@ 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; - // 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; - await persistSessionProfile( + // 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: rawState.thinking, }, 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); @@ -1513,9 +1522,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 +1701,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/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 97445231be..db5281071a 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,20 @@ 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. 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 @@ -154,22 +163,67 @@ 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 - } +// 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; +} + +// 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 +// 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(); +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; 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; + // 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(); + const legacy = thinkingPicks[LEGACY_THINKING_PICK_KEY]; + if (legacy !== undefined) map[LEGACY_THINKING_PICK_KEY] = legacy; + map[modelId] = level; + safeSetJson(THINKING_STORAGE_KEY, map); } // Plan / swarm / goal modes are per-session. Each is persisted as a compact @@ -323,10 +377,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 +459,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), @@ -718,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; @@ -732,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; }); } @@ -1942,6 +2002,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(() => { @@ -2160,6 +2222,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..6ae0bcc9ae 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, @@ -23,6 +24,7 @@ const apiMock = vi.hoisted(() => ({ updateSession: vi.fn(), listModels: vi.fn(), setConfig: vi.fn(), + activateSkill: vi.fn(), })); vi.mock('../api', () => ({ @@ -100,6 +102,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 +131,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 +180,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 +242,38 @@ 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(); + const persistSessionProfileMock = vi.fn(); + let storedThinkingLevels: Record; + let legacyThinkingPick: ThinkingLevel | undefined; 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({}); + apiMock.activateSkill.mockReset(); + apiMock.activateSkill.mockResolvedValue({}); + saveThinkingToStorageMock.mockReset(); + persistSessionProfileMock.mockReset(); + persistSessionProfileMock.mockResolvedValue(true); + storedThinkingLevels = {}; + legacyThinkingPick = undefined; }); function createState(options: { @@ -228,6 +285,7 @@ describe('useModelProviderState thinking on model selection', () => { sessions: options.activeSession ? [options.activeSession] : [], thinking: 'off', defaultModel: options.defaultModel, + inFlightBySession: {}, } as ExtendedState; } @@ -235,9 +293,10 @@ 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'), - saveThinkingToStorage: vi.fn(), + loadThinkingForModel: (modelId) => storedThinkingLevels[modelId] ?? legacyThinkingPick, + saveThinkingToStorage: saveThinkingToStorageMock, updateSession: (id, update) => { state.sessions = state.sessions.map((session) => session.id === id ? update(session) : session, @@ -246,7 +305,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 +320,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; @@ -294,6 +356,125 @@ 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('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('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('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. + 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; @@ -306,6 +487,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 +496,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); } 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' }), + ); + }); }); diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index 9b761a29e0..5ea1a8615c 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(), @@ -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(() => ({})), @@ -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')]), }; @@ -774,12 +775,12 @@ 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!: () => void; - const profileGate = new Promise((r) => { + let resolveProfile!: (persisted: boolean) => void; + const profileGate = new Promise((r) => { resolveProfile = r; }); const activateSkill = vi.fn().mockResolvedValue(undefined); @@ -800,23 +801,24 @@ 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(); - 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('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(undefined); + const persistSessionProfile2 = vi.fn().mockResolvedValue(true); const state2 = createState(); state2.thinking = 'max'; const deps2: UseWorkspaceStateDeps = { @@ -829,26 +831,14 @@ 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. - (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'); }); @@ -897,6 +887,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 +1050,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 +1523,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 +1720,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 };