Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/web-thinking-effort-autoenable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Auto-enable the default thinking effort when switching to a model that supports effort levels in the web UI.
24 changes: 18 additions & 6 deletions apps/kimi-web/src/composables/client/useModelProviderState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { ref, type ComputedRef } from 'vue';
import { getKimiWebApi } from '../../api';
import type { AppMessage, AppModel, AppProvider, AppSession, AppSkill, ThinkingLevel } from '../../api/types';
import { safeGetString, safeSetString, STORAGE_KEYS } from '../../lib/storage';
import { coerceThinkingForModel } from '../../lib/modelThinking';
import { coerceThinkingForModel, thinkingLevelForModelSwitch } from '../../lib/modelThinking';
import type { ActivityState } from '../../types';
import type { ExtendedState } from '../useKimiWebClient';

Expand Down Expand Up @@ -109,11 +109,19 @@ export function useModelProviderState(
);
}

function activeThinkingModel(): AppModel | undefined {
function currentModelId(): string | undefined {
const activeSession = rawState.activeSessionId
? rawState.sessions.find((s) => s.id === rawState.activeSessionId)
: undefined;
return modelById(activeSession?.model ?? draftModel.value ?? rawState.defaultModel);
const rawModel =
activeSession === undefined
? draftModel.value ?? rawState.defaultModel
: activeSession.model || rawState.defaultModel;
return modelById(rawModel)?.id ?? rawModel ?? undefined;
}

function activeThinkingModel(): AppModel | undefined {
return modelById(currentModelId());
}

function applyThinkingLevel(level: ThinkingLevel): ThinkingLevel {
Expand Down Expand Up @@ -192,8 +200,13 @@ export function useModelProviderState(
*/
async function setModel(modelId: string): Promise<boolean> {
const sid = rawState.activeSessionId;
const nextThinking = coerceThinkingForModel(modelById(modelId), rawState.thinking);
const targetModel = modelById(modelId);
const prevThinking = rawState.thinking;
const prevSessionModel = sid
? rawState.sessions.find((s) => s.id === sid)?.model
: undefined;
const isSwitch = currentModelId() !== (targetModel?.id ?? modelId);
const nextThinking = thinkingLevelForModelSwitch(targetModel, prevThinking, isSwitch);
if (!sid) {
// New-session draft (onboarding composer): no backend session to update.
// Remember the pick — startSessionAndSendPrompt applies it at create time.
Expand All @@ -203,7 +216,6 @@ export function useModelProviderState(
}
// Optimistic: show the chosen model immediately, but remember the previous
// one so we can roll back if the switch never reaches the daemon.
const prevModel = rawState.sessions.find((s) => s.id === sid)?.model;
updateSession(sid, (s) => ({ ...s, model: modelId }));
if (nextThinking !== prevThinking) {
rawState.thinking = nextThinking;
Expand All @@ -219,7 +231,7 @@ export function useModelProviderState(
// not fail it — but when the daemon is unreachable the request throws here.
// Roll the picker back to the real model so the UI can't keep showing the
// new one as if the switch succeeded, then surface the failure.
updateSession(sid, (s) => ({ ...s, model: prevModel ?? s.model }));
updateSession(sid, (s) => ({ ...s, model: prevSessionModel ?? s.model }));
if (nextThinking !== prevThinking) {
rawState.thinking = prevThinking;
saveThinkingToStorage(prevThinking);
Expand Down
301 changes: 301 additions & 0 deletions apps/kimi-web/src/lib/modelThinking.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
import { computed } from 'vue';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AppModel, AppSession } from '../api/types';
import {
useModelProviderState,
type UseModelProviderStateDeps,
} from '../composables/client/useModelProviderState';
import type { ExtendedState } from '../composables/useKimiWebClient';
import {
coerceThinkingForModel,
commitLevel,
defaultThinkingLevelFor,
effortLabel,
isThinkingOn,
modelThinkingAvailability,
segmentsFor,
thinkingLevelForModelSwitch,
} from './modelThinking';
import type { ModelThinkingInfo } from './modelThinking';

const apiMock = vi.hoisted(() => ({
updateSession: vi.fn(),
}));

vi.mock('../api', () => ({
getKimiWebApi: () => apiMock,
}));

function model(partial: ModelThinkingInfo): ModelThinkingInfo {
return partial;
}

describe('modelThinking', () => {
describe('modelThinkingAvailability', () => {
it('defaults to toggle when model is unknown', () => {
expect(modelThinkingAvailability(undefined)).toBe('toggle');
});

it('detects always_thinking capability', () => {
expect(modelThinkingAvailability(model({ capabilities: ['always_thinking'] }))).toBe('always-on');
});

it('detects thinking capability', () => {
expect(modelThinkingAvailability(model({ capabilities: ['thinking'] }))).toBe('toggle');
});

it('detects adaptive thinking', () => {
expect(modelThinkingAvailability(model({ adaptiveThinking: true }))).toBe('toggle');
});

it('marks models without thinking support as unsupported', () => {
expect(modelThinkingAvailability(model({ capabilities: ['vision'] }))).toBe('unsupported');
});
});

describe('defaultThinkingLevelFor', () => {
it('returns off for unsupported models', () => {
expect(defaultThinkingLevelFor(model({ capabilities: [] }))).toBe('off');
});

it('returns the declared default effort for effort models', () => {
expect(defaultThinkingLevelFor(model({ capabilities: ['thinking'], supportEfforts: ['low', 'high', 'max'], defaultEffort: 'high' }))).toBe('high');
});

it('falls back to the middle effort when no default is declared', () => {
expect(defaultThinkingLevelFor(model({ capabilities: ['thinking'], supportEfforts: ['low', 'high', 'max'] }))).toBe('high');
expect(defaultThinkingLevelFor(model({ capabilities: ['thinking'], supportEfforts: ['low', 'high'] }))).toBe('high');
});

it('returns on for boolean thinking models', () => {
expect(defaultThinkingLevelFor(model({ capabilities: ['thinking'] }))).toBe('on');
});
});

describe('segmentsFor', () => {
it('shows off/on for boolean toggle models', () => {
expect(segmentsFor(model({ capabilities: ['thinking'] }))).toEqual(['on', 'off']);
});

it('shows only on for always-on models', () => {
expect(segmentsFor(model({ capabilities: ['always_thinking'] }))).toEqual(['on']);
});

it('shows only off for unsupported models', () => {
expect(segmentsFor(model({ capabilities: [] }))).toEqual(['off']);
});

it('prefixes off to effort lists for toggle effort models', () => {
expect(segmentsFor(model({ capabilities: ['thinking'], supportEfforts: ['low', 'high', 'max'] }))).toEqual(['off', 'low', 'high', 'max']);
});

it('omits off for always-on effort models', () => {
expect(segmentsFor(model({ capabilities: ['always_thinking'], supportEfforts: ['low', 'high'] }))).toEqual(['low', 'high']);
});
});

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', () => {
expect(thinkingLevelForModelSwitch(effortModel, 'off', true)).toBe('high');
});

it('keeps off when re-selecting the current effort model', () => {
expect(thinkingLevelForModelSwitch(effortModel, 'off', false)).toBe('off');
});

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('does not auto-enable for boolean models', () => {
expect(thinkingLevelForModelSwitch(booleanModel, 'off', true)).toBe('off');
});

it('still coerces boolean models to on when carried level is non-off', () => {
expect(thinkingLevelForModelSwitch(booleanModel, 'high', true)).toBe('on');
});

it('forces always-on models on even during re-selection', () => {
expect(thinkingLevelForModelSwitch(alwaysOnModel, 'off', false)).toBe('on');
});

it('forces unsupported models off during a switch', () => {
expect(thinkingLevelForModelSwitch(unsupportedModel, 'high', true)).toBe('off');
});
});

describe('effortLabel', () => {
it('capitalizes effort names', () => {
expect(effortLabel('off')).toBe('Off');
expect(effortLabel('high')).toBe('High');
expect(effortLabel('max')).toBe('Max');
});

it('returns empty string as-is', () => {
expect(effortLabel('')).toBe('');
});
});

describe('isThinkingOn', () => {
it('returns false for off only', () => {
expect(isThinkingOn('off')).toBe(false);
expect(isThinkingOn('on')).toBe(true);
expect(isThinkingOn('high')).toBe(true);
});
});

describe('commitLevel', () => {
it('keeps off', () => {
expect(commitLevel(effortModel, 'off')).toBe('off');
});

it('resolves on to the model default', () => {
expect(commitLevel(effortModel, 'on')).toBe('high');
});

it('passes concrete efforts through', () => {
expect(commitLevel(effortModel, 'max')).toBe('max');
});
});
});

describe('useModelProviderState thinking on model selection', () => {
const effortAppModel: AppModel = {
id: 'provider/effort-model',
provider: 'provider',
model: 'effort-model',
maxContextSize: 128_000,
capabilities: ['thinking'],
supportEfforts: ['low', 'high', 'max'],
defaultEffort: 'high',
};
const booleanAppModel: AppModel = {
id: 'provider/boolean-model',
provider: 'provider',
model: 'boolean-model',
maxContextSize: 128_000,
capabilities: ['thinking'],
};

beforeEach(() => {
apiMock.updateSession.mockReset();
apiMock.updateSession.mockResolvedValue({});
});

function createState(options: {
activeSession?: Pick<AppSession, 'id' | 'model'>;
defaultModel: string;
}): ExtendedState {
return {
activeSessionId: options.activeSession?.id ?? null,
sessions: options.activeSession ? [options.activeSession] : [],
thinking: 'off',
defaultModel: options.defaultModel,
} as ExtendedState;
}

function createModelProvider(state: ExtendedState) {
const deps: UseModelProviderStateDeps = {
pushOperationFailure: vi.fn(),
refreshSessionStatus: vi.fn().mockResolvedValue(undefined),
persistSessionProfile: vi.fn().mockResolvedValue(undefined),
activity: computed(() => 'idle'),
inFlightPromptSessions: new Set(),
saveThinkingToStorage: vi.fn(),
updateSession: (id, update) => {
state.sessions = state.sessions.map((session) =>
session.id === id ? update(session) : session,
);
},
updateSessionMessages: vi.fn(),
};
const provider = useModelProviderState(state, deps);
provider.models.value = [effortAppModel, booleanAppModel];
return provider;
}

it('keeps thinking off when re-selecting the default model in a new-session draft', async () => {
const state = createState({ defaultModel: effortAppModel.id });
const provider = createModelProvider(state);

await provider.setModel(effortAppModel.id);

expect(state.thinking).toBe('off');
});

it('keeps thinking off when re-selecting an explicit new-session draft model', async () => {
const state = createState({ defaultModel: booleanAppModel.id });
const provider = createModelProvider(state);
provider.draftModel.value = effortAppModel.id;

await provider.setModel(effortAppModel.id);

expect(state.thinking).toBe('off');
});

it('keeps thinking off when an active session inherits the selected default model', async () => {
const state = createState({
activeSession: { id: 'session-1', model: '' },
defaultModel: effortAppModel.id,
});
const provider = createModelProvider(state);

await provider.setModel(effortAppModel.id);

expect(state.thinking).toBe('off');
expect(apiMock.updateSession).toHaveBeenCalledWith('session-1', {
model: effortAppModel.id,
thinking: undefined,
});
});

it('enables the default effort when switching from a different model', async () => {
const state = createState({ defaultModel: booleanAppModel.id });
const provider = createModelProvider(state);

await provider.setModel(effortAppModel.id);

expect(state.thinking).toBe('high');
});
});
18 changes: 18 additions & 0 deletions apps/kimi-web/src/lib/modelThinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,21 @@ export function commitLevel(
if (draft === 'on') return defaultThinkingLevelFor(model);
return draft;
}

/**
* 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.
*/
export function thinkingLevelForModelSwitch(
model: ModelThinkingInfo | undefined,
currentLevel: ThinkingLevel,
isSwitch: boolean,
): ThinkingLevel {
if (isSwitch && currentLevel === 'off' && (model?.supportEfforts?.length ?? 0) > 0) {
return defaultThinkingLevelFor(model);
}
return coerceThinkingForModel(model, currentLevel);
}
Loading