diff --git a/.changeset/web-thinking-level-verbatim.md b/.changeset/web-thinking-level-verbatim.md new file mode 100644 index 0000000000..c0abe84ea2 --- /dev/null +++ b/.changeset/web-thinking-level-verbatim.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Align thinking-level handling with the CLI: submit the selected level verbatim instead of silently downgrading it, pin the model's catalog default when nothing was chosen, pre-select the target model's default on model switches, and persist explicit picks as the daemon-wide default so new sessions inherit them. diff --git a/apps/kimi-web/AGENTS.md b/apps/kimi-web/AGENTS.md index 9cdebe95bb..3e7dc6299d 100644 --- a/apps/kimi-web/AGENTS.md +++ b/apps/kimi-web/AGENTS.md @@ -45,7 +45,6 @@ The browser web UI for Kimi Code — a peer to the TUI in `apps/kimi-code`. It t All via `pnpm --filter @moonshot-ai/kimi-web …`: - `dev` — Vite dev server (port `WEB_PORT`, default 5175; proxies `/api/v1` to `KIMI_SERVER_URL`, default `http://127.0.0.1:58627`). -- `dev:stub` — offline stub daemon (`dev/stub-daemon.mjs`). - `build` — production build into `dist/`. - `typecheck` — `vue-tsc --noEmit`. - `test` — `vitest run` (pure logic tests only; no jsdom / component tests). diff --git a/apps/kimi-web/README.md b/apps/kimi-web/README.md index bc4aca3f3b..0519a441ef 100644 --- a/apps/kimi-web/README.md +++ b/apps/kimi-web/README.md @@ -8,13 +8,10 @@ to a local **server** over REST + WebSocket. Vue 3 + Vite + TypeScript. ## Quick start ```bash -# 1) Against a REAL server (the server must be running and reachable) +# Against a REAL server (the server must be running and reachable) WEB_PORT=5197 KIMI_SERVER_URL=http://192.168.97.91:58627 pnpm -C apps/kimi-web run dev # …or from the repo root: pnpm dev:web (uses the defaults below) -# 2) Offline / no server — a stub that fakes the server API + event stream -pnpm -C apps/kimi-web run dev:stub # then run dev in another shell - # checks pnpm -C apps/kimi-web run typecheck # vue-tsc --noEmit pnpm -C apps/kimi-web run test # vitest (pure logic only) diff --git a/apps/kimi-web/package.json b/apps/kimi-web/package.json index 300d1848d2..85dc3ac22a 100644 --- a/apps/kimi-web/package.json +++ b/apps/kimi-web/package.json @@ -6,7 +6,6 @@ "type": "module", "scripts": { "dev": "vite", - "dev:stub": "node dev/stub-daemon.mjs", "build": "vite build", "typecheck": "vue-tsc --noEmit", "test": "vitest run", diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 8e2e336348..d71852bc8b 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -38,7 +38,7 @@ import type { SwarmMember } from './composables/swarmGroups'; import ServerAuthDialog from './components/ServerAuthDialog.vue'; import { initServerAuth, onAuthRequired } from './api/daemon/serverAuth'; import type { AppConfig, ThinkingLevel } from './api/types'; -import { coerceThinkingForModel, commitLevel, segmentsFor } from './lib/modelThinking'; +import { commitLevel, effectiveThinkingLevel, segmentsFor } from './lib/modelThinking'; import { stripSkillPrefix } from './lib/slashCommands'; import Button from './components/ui/Button.vue'; import IconButton from './components/ui/IconButton.vue'; @@ -112,19 +112,26 @@ usePageTitle({ running, showAuthGate }); // The /thinking slash command has no popover anchor, so it steps to the next // segment for the active model (effort models cycle through their declared // levels; boolean models flip on/off; unsupported stays off). -function nextThinkingLevel(current: ThinkingLevel): ThinkingLevel { +function nextThinkingLevel(current: ThinkingLevel | undefined): ThinkingLevel { // Identity is the model id — display/model names can collide across providers. const model = client.models.value.find((m) => m.id === client.status.value.modelId); const segs = segmentsFor(model); - // Coerce the stored level against the active model before indexing, so a - // stale value (e.g. 'on' from a boolean model) doesn't resolve to index -1 - // and jump to 'off' instead of advancing from the model's default effort. - const coerced = coerceThinkingForModel(model, current); - const idx = segs.indexOf(coerced); + // No stored preference means the model default is in effect — cycle from + // there; a level the model doesn't declare (indexOf → -1) starts the cycle + // at the first segment. + const idx = segs.indexOf(effectiveThinkingLevel(model, current)); const next = segs[(idx + 1) % segs.length] ?? segs[0] ?? 'off'; return commitLevel(model, next); } +// Status panel (/status) renders current client state only — show the +// effective thinking level so "no preference" reads as the model default that +// will actually run, not a blank. +const statusPanelThinking = computed(() => { + const model = client.models.value.find((m) => m.id === client.status.value.modelId); + return effectiveThinkingLevel(model, client.thinking.value); +}); + // First-run onboarding (language + welcome greeting). Shown until the user // finishes it once; re-openable from the settings popover. const showOnboarding = ref(!client.onboarded.value); @@ -958,7 +965,7 @@ function openPr(url: string): void { ); const thinkingAvailability = computed(() => modelThinkingAvailability(currentModel.value)); const thinkingSegments = computed(() => segmentsFor(currentModel.value)); -// The persisted level can be stale relative to the active model (e.g. a -// boolean 'on'/'off' carried over when selecting another session). Coerce it -// against the current model before deriving display state so an always-on -// model never shows "thinking: off" and an effort model shows its concrete -// level instead of the bare "thinking" tag. -const coercedThinkingLevel = computed(() => - coerceThinkingForModel(currentModel.value, props.thinking ?? 'off'), -); -// Runtime level clamped to the segments this model actually offers, so a -// carried-over value never highlights a segment that doesn't exist here. +// 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. +const thinkingLevel = computed(() => effectiveThinkingLevel(currentModel.value, props.thinking)); const activeThinkingSegment = computed(() => { const segs = thinkingSegments.value; - const level = coercedThinkingLevel.value; - if (segs.includes(level)) return level; - if (segs.includes('on')) return 'on'; - return segs[0] ?? 'off'; -}); -const thinkingOn = computed(() => { - if (thinkingAvailability.value === 'always-on') return true; - if (thinkingAvailability.value === 'unsupported') return false; - return isThinkingOn(coercedThinkingLevel.value); + return segs.includes(thinkingLevel.value) ? thinkingLevel.value : ''; }); +const thinkingOn = computed(() => isThinkingOn(thinkingLevel.value)); // Single-segment (always-on boolean) or unsupported models can't be changed. const thinkingReadonly = computed( () => thinkingAvailability.value === 'unsupported' || thinkingSegments.value.length <= 1, @@ -643,7 +632,7 @@ const thinkingReadonly = computed( const thinkingSuffix = computed(() => { if (!thinkingOn.value) return ''; const hasEfforts = (currentModel.value?.supportEfforts?.length ?? 0) > 0; - const level = coercedThinkingLevel.value; + const level = thinkingLevel.value; if (hasEfforts && level !== 'on') return t('composer.thinkingSuffixEffort', { level }); return t('composer.thinkingSuffix'); }); diff --git a/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue b/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue index c29f18cd72..a3c78524e6 100644 --- a/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue +++ b/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue @@ -13,8 +13,8 @@ import type { AppModel, AppSession, ThinkingLevel } from '../../api/types'; import type { ColorScheme } from '../../composables/useKimiWebClient'; import { useKimiWebClient } from '../../composables/useKimiWebClient'; import { - coerceThinkingForModel, commitLevel, + effectiveThinkingLevel, effortLabel, modelThinkingAvailability, segmentsFor, @@ -78,20 +78,14 @@ const currentModel = computed(() => ); const thinkingAvailability = computed(() => modelThinkingAvailability(currentModel.value)); const thinkingSegments = computed(() => segmentsFor(currentModel.value)); -// The persisted level can be stale relative to the active model (e.g. 'on' -// from a boolean model, or 'off' while viewing an always-on effort model). -// Coerce it before computing the active segment so the mobile sheet shows and -// selects the same model-aware default the composer and prompt submission use. -const coercedThinkingLevel = computed(() => - coerceThinkingForModel(currentModel.value, props.thinking ?? 'off'), -); -// Runtime level clamped to the segments this model actually offers. +// 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. +const thinkingLevel = computed(() => effectiveThinkingLevel(currentModel.value, props.thinking)); const activeThinkingSegment = computed(() => { const segs = thinkingSegments.value; - const level = coercedThinkingLevel.value; - if (segs.includes(level)) return level; - if (segs.includes('on')) return 'on'; - return segs[0] ?? 'off'; + return segs.includes(thinkingLevel.value) ? thinkingLevel.value : ''; }); const thinkingOptions = computed(() => thinkingSegments.value.map((seg) => ({ value: seg, label: effortLabel(seg) })), @@ -274,8 +268,8 @@ watch( {{ activeThinkingSegment === 'off' ? t('status.planOff') : effortLabel(activeThinkingSegment) }} + :class="{ dim: thinkingLevel === 'off' }" + >{{ thinkingLevel === 'off' ? t('status.planOff') : effortLabel(thinkingLevel) }} diff --git a/apps/kimi-web/src/composables/client/useModelProviderState.ts b/apps/kimi-web/src/composables/client/useModelProviderState.ts index 6af64e6c72..5a05921824 100644 --- a/apps/kimi-web/src/composables/client/useModelProviderState.ts +++ b/apps/kimi-web/src/composables/client/useModelProviderState.ts @@ -17,7 +17,11 @@ import type { ThinkingLevel, } from '../../api/types'; import { safeGetString, safeSetString, STORAGE_KEYS } from '../../lib/storage'; -import { coerceThinkingForModel, thinkingLevelForModelSwitch } from '../../lib/modelThinking'; +import { + defaultThinkingLevelFor, + thinkingLevelForModelSwitch, + thinkingLevelToConfig, +} from '../../lib/modelThinking'; import { beginLocalTurn, settleLocalTurn } from './useWorkspaceState'; import type { ActivityState } from '../../types'; import type { ExtendedState } from '../useKimiWebClient'; @@ -129,15 +133,24 @@ export function useModelProviderState( return modelById(rawModel)?.id ?? rawModel ?? undefined; } - function activeThinkingModel(): AppModel | undefined { - return modelById(currentModelId()); + 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. + rawState.thinking = level; + if (level !== undefined) saveThinkingToStorage(level); + return level; } - function applyThinkingLevel(level: ThinkingLevel): ThinkingLevel { - const next = coerceThinkingForModel(activeThinkingModel(), level); - rawState.thinking = next; - saveThinkingToStorage(next); - return next; + /** 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 + * and local values have already been applied. Never called for derived + * values (e.g. the loadModels default pin) — only for user actions. */ + function persistGlobalThinking(level: ThinkingLevel): void { + void getKimiWebApi() + .setConfig({ thinking: thinkingLevelToConfig(level) }) + .catch((error: unknown) => pushOperationFailure('setConfig', error)); } async function loadSkillsForSession(sessionId: string): Promise { @@ -167,7 +180,15 @@ export function useModelProviderState( try { const api = getKimiWebApi(); models.value = await api.listModels(); - applyThinkingLevel(rawState.thinking); + // 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); + } } catch (err) { pushOperationFailure('loadModels', err); } @@ -221,14 +242,16 @@ export function useModelProviderState( // Remember the pick — startSessionAndSendPrompt applies it at create time. draftModel.value = modelId; applyThinkingLevel(nextThinking); + if (nextThinking !== prevThinking && nextThinking !== undefined) { + persistGlobalThinking(nextThinking); + } return true; } // Optimistic: show the chosen model immediately, but remember the previous // one so we can roll back if the switch never reaches the daemon. updateSession(sid, (s) => ({ ...s, model: modelId })); if (nextThinking !== prevThinking) { - rawState.thinking = nextThinking; - saveThinkingToStorage(nextThinking); + applyThinkingLevel(nextThinking); } try { await getKimiWebApi().updateSession(sid, { @@ -242,12 +265,16 @@ 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) { - rawState.thinking = prevThinking; - saveThinkingToStorage(prevThinking); + applyThinkingLevel(prevThinking); } pushOperationFailure('setModel', err, { sessionId: sid }); return false; } + // The switch reached the daemon: also persist the thinking pick as the + // daemon-wide default (mirrors the TUI). Skipped on rollback above. + if (nextThinking !== prevThinking && nextThinking !== undefined) { + persistGlobalThinking(nextThinking); + } // refreshSessionStatus folds the authoritative current model from /status // back into the session (the profile echo can return ''). Best-effort: a // failure here does not mean the switch failed, so it must not roll back. @@ -419,6 +446,7 @@ export function useModelProviderState( function setThinking(level: ThinkingLevel): void { const next = applyThinkingLevel(level); void persistSessionProfile({ thinking: next }); + if (next !== undefined) persistGlobalThinking(next); } return { diff --git a/apps/kimi-web/src/composables/client/useSideChat.ts b/apps/kimi-web/src/composables/client/useSideChat.ts index f5f2329def..13ca4cf433 100644 --- a/apps/kimi-web/src/composables/client/useSideChat.ts +++ b/apps/kimi-web/src/composables/client/useSideChat.ts @@ -9,11 +9,9 @@ import { computed, ref } from 'vue'; import { getKimiWebApi } from '../../api'; -import type { AppMessage, AppModel } from '../../api/types'; -import type { KimiEventConnection } from '../../api/types'; +import type { AppMessage, KimiEventConnection } from '../../api/types'; import { messagesToTurns } from '../messagesToTurns'; import type { ChatTurn } from '../../types'; -import { coerceThinkingForModel } from '../../lib/modelThinking'; import type { ExtendedState } from '../useKimiWebClient'; export interface UseSideChatDeps { @@ -25,10 +23,6 @@ export interface UseSideChatDeps { nextOptimisticMsgId: () => string; connectEventsIfNeeded: () => void; getEventConn: () => KimiEventConnection | null; - /** Provider model catalog — used to coerce thinking against the parent - * session's model the same way normal prompts do (so a value carried over - * from another model isn't submitted raw). */ - models: () => AppModel[]; } export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) { @@ -207,27 +201,18 @@ export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) { // 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. + // only thing the daemon reads for this turn. Thinking goes verbatim + // (same as normal prompts 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; - // Coerce thinking against the parent model the same way a normal prompt - // does (coercePromptThinking in useWorkspaceState): a level carried over - // from another/default model would otherwise be submitted raw and run - // differently from what the UI shows. - const promptModel = - model === undefined - ? undefined - : deps.models().find( - (m) => m.model === model || m.id === model || m.displayName === model, - ); const result = await getKimiWebApi().submitPrompt(sid, { content: [{ type: 'text', text: trimmed }], agentId, model, - thinking: coerceThinkingForModel(promptModel, rawState.thinking), + thinking: 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 d3fd9847ee..cc7fde10a7 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -34,7 +34,6 @@ import { STORAGE_KEYS, } from '../../lib/storage'; import { parseDiff } from '../../lib/parseDiff'; -import { coerceThinkingForModel } from '../../lib/modelThinking'; import { sessionExportTraceToJsonl, traceKeyEvent } from '../../debug/trace'; import { readSessionIdFromLocation, sessionUrl } from '../../lib/sessionRoute'; import type { SessionUrlMode } from '../../lib/sessionRoute'; @@ -1073,11 +1072,8 @@ 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; - // Coerce thinking against the new session's model the same way the - // first-prompt path does (coercePromptThinking below): a value carried - // over from another/default model (e.g. 'max' from an effort model) would - // otherwise be persisted verbatim, and the first skill turn would run at - // a level the UI wouldn't send for this model. + // 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 @@ -1089,7 +1085,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta planMode, swarmMode, permissionMode: rawState.permission, - thinking: coercePromptThinking(model), + thinking: rawState.thinking, }, sid, ); @@ -1309,22 +1305,6 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta } } - // Coerce the persisted thinking level against the prompt's target model before - // submitting, so a stale value carried over from another session (e.g. 'max' - // from an effort model) isn't sent to a model that doesn't declare it. The - // composer already renders the coerced value; this keeps the submitted level - // in sync with what's displayed. Falls back to the raw level when the model - // catalog hasn't loaded yet (coerceThinkingForModel preserves it). - function coercePromptThinking(model: string | undefined) { - const promptModel = - model === undefined - ? undefined - : modelProvider.models.value.find( - (m) => m.model === model || m.id === model || m.displayName === model, - ); - return coerceThinkingForModel(promptModel, rawState.thinking); - } - /** Internal: submit a prompt to a specific session, bypassing the queue check. Returns true when the daemon accepted the prompt. */ async function submitPromptInternal(sid: string, text: string, attachments?: PromptAttachment[]): Promise { @@ -1397,7 +1377,9 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta const result = await api.submitPrompt(sid, { content, model, - thinking: coercePromptThinking(model), + // Verbatim: the stored level is submitted as-is (same as the TUI) — + // no coercion against the prompt's target model. + thinking: rawState.thinking, permissionMode: rawState.permission, planMode, swarmMode, @@ -1537,7 +1519,8 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta const result = await api.submitPrompt(sid, { content, model, - thinking: coercePromptThinking(model), + // Verbatim, same as a normal send (see submitPromptInternal). + thinking: 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 40f86c0502..0d27dd91c5 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -110,8 +110,9 @@ const ONBOARDED_STORAGE_KEY = STORAGE_KEYS.onboarded; // '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. coerceThinkingForModel adapts the loaded value to the active -// model once the catalog is available. +// + 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). 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 @@ -145,14 +146,14 @@ function savePermissionToStorage(mode: PermissionMode): void { } } -function loadThinkingFromStorage(): ThinkingLevel { +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 } - return 'high'; + return undefined; } function saveThinkingToStorage(v: ThinkingLevel): void { @@ -303,7 +304,11 @@ export interface ExtendedState extends KimiClientState { workspaceName: string; connection: ConnectionState; permission: PermissionMode; - thinking: ThinkingLevel; + /** 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. */ + thinking: ThinkingLevel | undefined; /** Plan-mode toggle per session. Bound to a session (not global) so toggling * it in one session does not affect another. */ planModeBySession: Record; @@ -1854,7 +1859,6 @@ const sideChat = useSideChat(rawState, { nextOptimisticMsgId, connectEventsIfNeeded, getEventConn: () => eventConn, - models: () => modelProvider.models.value, }); const activeAppTasks = computed(() => { @@ -1945,7 +1949,7 @@ function clearDangerousBypassAuth(): void { } const permission = computed(() => rawState.permission); -const thinking = computed(() => rawState.thinking); +const thinking = computed(() => rawState.thinking); // Mode toggles reflect the ACTIVE session (or the draft when no session is // open). Each session keeps its own value in the *BySession maps above. const planMode = computed(() => { diff --git a/apps/kimi-web/src/lib/modelThinking.test.ts b/apps/kimi-web/src/lib/modelThinking.test.ts index 6ea0577da6..eca0fef15d 100644 --- a/apps/kimi-web/src/lib/modelThinking.test.ts +++ b/apps/kimi-web/src/lib/modelThinking.test.ts @@ -7,19 +7,22 @@ import { } from '../composables/client/useModelProviderState'; import type { ExtendedState } from '../composables/useKimiWebClient'; import { - coerceThinkingForModel, commitLevel, defaultThinkingLevelFor, + effectiveThinkingLevel, effortLabel, isThinkingOn, modelThinkingAvailability, segmentsFor, thinkingLevelForModelSwitch, + thinkingLevelToConfig, } from './modelThinking'; import type { ModelThinkingInfo } from './modelThinking'; const apiMock = vi.hoisted(() => ({ updateSession: vi.fn(), + listModels: vi.fn(), + setConfig: vi.fn(), })); vi.mock('../api', () => ({ @@ -94,74 +97,49 @@ describe('modelThinking', () => { }); }); - describe('coerceThinkingForModel', () => { - it('keeps the requested level before models are loaded', () => { - expect(coerceThinkingForModel(undefined, 'high')).toBe('high'); - }); - - it('forces unsupported models to off', () => { - expect(coerceThinkingForModel(model({ capabilities: [] }), 'on')).toBe('off'); - }); - - it('forces always-on models to their default level', () => { - expect(coerceThinkingForModel(model({ capabilities: ['always_thinking'] }), 'off')).toBe('on'); - }); - - it('keeps off for boolean toggle models', () => { - expect(coerceThinkingForModel(model({ capabilities: ['thinking'] }), 'off')).toBe('off'); - }); - - it('normalizes non-off levels to on for boolean toggle models', () => { - expect(coerceThinkingForModel(model({ capabilities: ['thinking'] }), 'high')).toBe('on'); - }); - - it('keeps declared effort levels', () => { - expect(coerceThinkingForModel(model({ capabilities: ['thinking'], supportEfforts: ['low', 'high', 'max'] }), 'high')).toBe('high'); - }); - - it('falls back to default effort for undeclared effort levels', () => { - expect(coerceThinkingForModel(model({ capabilities: ['thinking'], supportEfforts: ['low', 'high', 'max'] }), 'medium')).toBe('high'); - }); - - it('keeps off for effort models by default', () => { - expect(coerceThinkingForModel(model({ capabilities: ['thinking'], supportEfforts: ['low', 'high', 'max'] }), 'off')).toBe('off'); - }); - }); - const effortModel = model({ capabilities: ['thinking'], supportEfforts: ['low', 'high', 'max'], defaultEffort: 'high' }); const booleanModel = model({ capabilities: ['thinking'] }); const alwaysOnModel = model({ capabilities: ['always_thinking'] }); const unsupportedModel = model({ capabilities: [] }); describe('thinkingLevelForModelSwitch', () => { - - it('auto-enables default effort when switching onto an effort model from off', () => { + it('pre-selects the target model default effort on a switch', () => { expect(thinkingLevelForModelSwitch(effortModel, 'off', true)).toBe('high'); + expect(thinkingLevelForModelSwitch(effortModel, 'max', true)).toBe('high'); + expect(thinkingLevelForModelSwitch(effortModel, undefined, true)).toBe('high'); }); - it('keeps off when re-selecting the current effort model', () => { + it('keeps the current level when re-selecting the same model', () => { expect(thinkingLevelForModelSwitch(effortModel, 'off', false)).toBe('off'); + expect(thinkingLevelForModelSwitch(effortModel, 'max', false)).toBe('max'); + expect(thinkingLevelForModelSwitch(effortModel, undefined, false)).toBeUndefined(); }); - it('coerces carried-over levels for effort models during a switch', () => { - expect(thinkingLevelForModelSwitch(effortModel, 'high', true)).toBe('high'); - expect(thinkingLevelForModelSwitch(effortModel, 'medium', true)).toBe('high'); + it('pre-selects on for boolean and always-on models on a switch', () => { + expect(thinkingLevelForModelSwitch(booleanModel, 'off', true)).toBe('on'); + expect(thinkingLevelForModelSwitch(alwaysOnModel, 'off', true)).toBe('on'); }); - it('does not auto-enable for boolean models', () => { - expect(thinkingLevelForModelSwitch(booleanModel, 'off', true)).toBe('off'); + it('pre-selects off for unsupported models on a switch', () => { + expect(thinkingLevelForModelSwitch(unsupportedModel, 'high', true)).toBe('off'); }); - it('still coerces boolean models to on when carried level is non-off', () => { - expect(thinkingLevelForModelSwitch(booleanModel, 'high', true)).toBe('on'); + it('keeps the current level when the target model is unknown', () => { + expect(thinkingLevelForModelSwitch(undefined, 'max', true)).toBe('max'); + expect(thinkingLevelForModelSwitch(undefined, undefined, true)).toBeUndefined(); }); + }); - it('forces always-on models on even during re-selection', () => { - expect(thinkingLevelForModelSwitch(alwaysOnModel, 'off', false)).toBe('on'); + describe('effectiveThinkingLevel', () => { + it('returns the stored level when set', () => { + expect(effectiveThinkingLevel(effortModel, 'max')).toBe('max'); + expect(effectiveThinkingLevel(effortModel, 'off')).toBe('off'); }); - it('forces unsupported models off during a switch', () => { - expect(thinkingLevelForModelSwitch(unsupportedModel, 'high', true)).toBe('off'); + it('falls back to the model default when there is no preference', () => { + expect(effectiveThinkingLevel(effortModel, undefined)).toBe('high'); + expect(effectiveThinkingLevel(booleanModel, undefined)).toBe('on'); + expect(effectiveThinkingLevel(unsupportedModel, undefined)).toBe('off'); }); }); @@ -198,6 +176,20 @@ describe('modelThinking', () => { expect(commitLevel(effortModel, 'max')).toBe('max'); }); }); + + describe('thinkingLevelToConfig', () => { + it('disables thinking for off', () => { + expect(thinkingLevelToConfig('off')).toEqual({ enabled: false }); + }); + + it('records only enabled for boolean on', () => { + expect(thinkingLevelToConfig('on')).toEqual({ enabled: true }); + }); + + it('records concrete efforts as the global default', () => { + expect(thinkingLevelToConfig('max')).toEqual({ enabled: true, effort: 'max' }); + }); + }); }); describe('useModelProviderState thinking on model selection', () => { @@ -221,6 +213,10 @@ describe('useModelProviderState thinking on model selection', () => { beforeEach(() => { apiMock.updateSession.mockReset(); apiMock.updateSession.mockResolvedValue({}); + apiMock.listModels.mockReset(); + apiMock.listModels.mockResolvedValue([effortAppModel, booleanAppModel]); + apiMock.setConfig.mockReset(); + apiMock.setConfig.mockResolvedValue({}); }); function createState(options: { @@ -298,4 +294,75 @@ describe('useModelProviderState thinking on model selection', () => { 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; + const provider = createModelProvider(state); + + await provider.loadModels(); + + expect(state.thinking).toBe('high'); + }); + + it('keeps a stored preference when loading models', async () => { + const state = createState({ defaultModel: effortAppModel.id }); + state.thinking = 'max'; + const provider = createModelProvider(state); + + await provider.loadModels(); + + 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; + const provider = createModelProvider(state); + + await provider.loadModels(); + + expect(apiMock.setConfig).not.toHaveBeenCalled(); + }); + + it('persists the thinking pick as the global default on setThinking', async () => { + const state = createState({ defaultModel: effortAppModel.id }); + const provider = createModelProvider(state); + + provider.setThinking('max'); + + expect(apiMock.setConfig).toHaveBeenCalledWith({ thinking: { enabled: true, effort: 'max' } }); + }); + + it('persists the thinking pick as the global default on a model switch', async () => { + const state = createState({ defaultModel: booleanAppModel.id }); + const provider = createModelProvider(state); + + await provider.setModel(effortAppModel.id); + + expect(apiMock.setConfig).toHaveBeenCalledWith({ thinking: { enabled: true, effort: 'high' } }); + }); + + it('does not write the global thinking config when re-selecting the current model', async () => { + const state = createState({ defaultModel: effortAppModel.id }); + const provider = createModelProvider(state); + + await provider.setModel(effortAppModel.id); + + expect(apiMock.setConfig).not.toHaveBeenCalled(); + }); + + it('does not write the global thinking config when the session switch fails', async () => { + apiMock.updateSession.mockRejectedValue(new Error('daemon unreachable')); + const state = createState({ + activeSession: { id: 'session-1', model: booleanAppModel.id }, + defaultModel: booleanAppModel.id, + }); + const provider = createModelProvider(state); + + const switched = await provider.setModel(effortAppModel.id); + + expect(switched).toBe(false); + expect(apiMock.setConfig).not.toHaveBeenCalled(); + }); }); diff --git a/apps/kimi-web/src/lib/modelThinking.ts b/apps/kimi-web/src/lib/modelThinking.ts index 19f02583d8..7013fb2cf9 100644 --- a/apps/kimi-web/src/lib/modelThinking.ts +++ b/apps/kimi-web/src/lib/modelThinking.ts @@ -70,36 +70,6 @@ export function isThinkingOn(level: ThinkingLevel): boolean { return level !== 'off'; } -/** - * Coerce a carried-over level against a new model's capabilities when switching - * models, so the level stays valid for the target: - * - unsupported → 'off' - * - always-on + 'off' → default level (always-on can't be off) - * - effort model + undeclared level → default level - * - effort model + declared level → requested - * - boolean model + non-'off' → 'on' - */ -export function coerceThinkingForModel( - model: ModelThinkingInfo | undefined, - requested: ThinkingLevel, -): ThinkingLevel { - // Model catalog (and thus the active model) is not known yet on early app - // load — keep the requested/persisted level as-is. loadModels() re-runs this - // coercion once models are available, so an effort like 'high' is not - // rewritten to the boolean 'on' and silently lost. - if (model === undefined) return requested; - const availability = modelThinkingAvailability(model); - if (availability === 'unsupported') return 'off'; - if (requested === 'off') { - return availability === 'always-on' ? defaultThinkingLevelFor(model) : 'off'; - } - const efforts = effortsOf(model); - if (efforts.length > 0) { - return efforts.includes(requested) ? requested : defaultThinkingLevelFor(model); - } - return 'on'; -} - /** * Normalize a UI draft before it crosses the component boundary. 'on' never * leaks out of the control — it becomes the model's default level. @@ -113,20 +83,50 @@ export function commitLevel( return draft; } +/** + * The level that effectively applies when the stored level is `undefined` + * (no explicit preference): the model's own default. Submitting a prompt with + * no thinking override lets the daemon resolve the same value, so this is what + * the UI displays and what `/thinking` cycles from. + */ +export function effectiveThinkingLevel( + model: ModelThinkingInfo | undefined, + level: ThinkingLevel | undefined, +): ThinkingLevel { + return level ?? defaultThinkingLevelFor(model); +} + +/** + * Project a thinking level onto the daemon's `[thinking]` config section — + * the same mapping the TUI persists (thinkingEffortToConfig): 'off' disables + * thinking, a concrete effort records it as the global default, and boolean + * 'on' records only `enabled` (boolean models resolve back to 'on' at + * runtime). + */ +export function thinkingLevelToConfig(level: ThinkingLevel): { + enabled: boolean; + effort?: string; +} { + if (level === 'off') return { enabled: false }; + if (level === 'on') return { enabled: true }; + return { enabled: true, effort: level }; +} + /** * Thinking level to use when the user picks a model in the switcher. - * Mirrors the TUI model picker: switching onto a different effort-capable - * model from 'off' pre-selects the model's default effort, so the user sees - * the effort control immediately; re-selecting the current model or moving - * to a boolean/unsupported model just coerces the carried-over level. + * 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. */ export function thinkingLevelForModelSwitch( model: ModelThinkingInfo | undefined, - currentLevel: ThinkingLevel, + currentLevel: ThinkingLevel | undefined, isSwitch: boolean, -): ThinkingLevel { - if (isSwitch && currentLevel === 'off' && (model?.supportEfforts?.length ?? 0) > 0) { - return defaultThinkingLevelFor(model); - } - return coerceThinkingForModel(model, currentLevel); +): 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; + return defaultThinkingLevelFor(model); } diff --git a/apps/kimi-web/test/lib-logic.test.ts b/apps/kimi-web/test/lib-logic.test.ts index e79bd2cd1a..25b7ec7440 100644 --- a/apps/kimi-web/test/lib-logic.test.ts +++ b/apps/kimi-web/test/lib-logic.test.ts @@ -19,7 +19,6 @@ import { parseWorkspacePathInput, } from '../src/lib/workspacePathInput'; import { - coerceThinkingForModel, commitLevel, defaultThinkingLevelFor, effortLabel, @@ -558,35 +557,6 @@ describe('modelThinking', () => { }); }); - describe('coerceThinkingForModel', () => { - it('undefined model preserves the requested level (catalog not loaded yet)', () => { - expect(coerceThinkingForModel(undefined, 'high')).toBe('high'); - expect(coerceThinkingForModel(undefined, 'max')).toBe('max'); - expect(coerceThinkingForModel(undefined, 'on')).toBe('on'); - expect(coerceThinkingForModel(undefined, 'off')).toBe('off'); - }); - it('unsupported model → off', () => { - expect(coerceThinkingForModel(unsupportedModel(), 'high')).toBe('off'); - }); - it('always-on + off → default level', () => { - expect( - coerceThinkingForModel(effortModel({ capabilities: ['thinking', 'always_thinking'] }), 'off'), - ).toBe('high'); - }); - it('effort model + undeclared level → default', () => { - expect(coerceThinkingForModel(effortModel(), 'xhigh')).toBe('high'); - }); - it('effort model + declared level → kept', () => { - expect(coerceThinkingForModel(effortModel(), 'max')).toBe('max'); - }); - it('boolean model + non-off level → on', () => { - expect(coerceThinkingForModel(booleanModel(), 'high')).toBe('on'); - }); - it('toggle + off → off', () => { - expect(coerceThinkingForModel(booleanModel(), 'off')).toBe('off'); - }); - }); - describe('effortLabel', () => { it('capitalizes the first letter', () => { expect(effortLabel('max')).toBe('Max'); diff --git a/apps/kimi-web/test/side-chat.test.ts b/apps/kimi-web/test/side-chat.test.ts index e3dd4bde4b..aee0073b44 100644 --- a/apps/kimi-web/test/side-chat.test.ts +++ b/apps/kimi-web/test/side-chat.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest'; import { createInitialState } from '../src/api/daemon/eventReducer'; import { useSideChat } from '../src/composables/client/useSideChat'; -import type { AppModel } from '../src/api/types'; import type { ExtendedState } from '../src/composables/useKimiWebClient'; const apiMock = vi.hoisted(() => ({ @@ -67,7 +66,6 @@ describe('useSideChat — sendSideChatPromptOn', () => { nextOptimisticMsgId: () => 'msg_opt_btw', connectEventsIfNeeded: vi.fn(), getEventConn: () => null, - models: () => [], }); await sideChat.openSideChatOn('sess_1', 'what changed?'); @@ -87,11 +85,9 @@ describe('useSideChat — sendSideChatPromptOn', () => { expect(pushOperationFailure).not.toHaveBeenCalled(); }); - it('coerces a stale thinking level against the parent model', async () => { - // Regression for: switching the parent session from an effort model to one - // that doesn't support thinking leaves rawState.thinking at a stale effort - // (e.g. 'max'). Normal prompts coerce this; BTW prompts must too, otherwise - // the first BTW turn runs at a level the UI wouldn't send. + 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. apiMock.startBtw.mockReset(); apiMock.submitPrompt.mockReset(); apiMock.startBtw.mockResolvedValue({ agentId: 'agent_btw_1' }); @@ -99,29 +95,18 @@ describe('useSideChat — sendSideChatPromptOn', () => { const state = createState(); state.thinking = 'max'; - // 'kimi-code' here doesn't declare thinking → 'unsupported' → coerced to 'off'. - const models: AppModel[] = [ - { - id: 'kimi-code', - model: 'kimi-code', - provider: 'kimi', - displayName: 'kimi-code', - capabilities: [], - } as unknown as AppModel, - ]; const sideChat = useSideChat(state, { pushOperationFailure: vi.fn(), nextOptimisticMsgId: () => 'msg_opt_btw', connectEventsIfNeeded: vi.fn(), getEventConn: () => null, - models: () => models, }); await sideChat.openSideChatOn('sess_1', 'what changed?'); expect(apiMock.submitPrompt).toHaveBeenCalledWith( 'sess_1', - expect.objectContaining({ thinking: 'off' }), + expect.objectContaining({ thinking: 'max' }), ); }); }); diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index 9502a94c97..ef96f5967f 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -809,12 +809,10 @@ describe('useWorkspaceState — startSessionAndActivateSkill', () => { expect(activateSkill).toHaveBeenCalledWith('pre-changelog', undefined, 'sess_new'); }); - it('coerces a stale thinking level against the new session model before persisting', async () => { - // Regression for: rawState.thinking can be stale relative to the new - // session's model (e.g. 'max' carried over from an effort model). Persisting - // the raw value would make the first skill turn run at a level the UI - // wouldn't send for this model; we must coerce it like the first-prompt - // path does. + 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. const activateSkill2 = vi.fn().mockResolvedValue(undefined); const persistSessionProfile2 = vi.fn().mockResolvedValue(undefined); const state2 = createState(); @@ -829,8 +827,8 @@ describe('useWorkspaceState — startSessionAndActivateSkill', () => { }), draftModes: { planMode: true, swarmMode: false, goalMode: false }, }; - // 'kimi-code' declares efforts ['low','medium','high']; 'max' isn't in the - // list so coercion picks the default (middle) level → 'medium'. + // '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', @@ -845,10 +843,8 @@ describe('useWorkspaceState — startSessionAndActivateSkill', () => { await ws2.startSessionAndActivateSkill('wd_1', 'pre-changelog'); - // Effort model default level = middle of supportEfforts: 'medium'. - // Confirms the raw carry-over 'max' was coerced, not persisted verbatim. expect(persistSessionProfile2).toHaveBeenCalledWith( - expect.objectContaining({ thinking: 'medium' }), + expect.objectContaining({ thinking: 'max' }), 'sess_new', ); expect(activateSkill2).toHaveBeenCalledWith('pre-changelog', undefined, 'sess_new');