diff --git a/.changeset/thinking-model-overhaul.md b/.changeset/thinking-model-overhaul.md new file mode 100644 index 000000000..8dc843ebd --- /dev/null +++ b/.changeset/thinking-model-overhaul.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": major +"@moonshot-ai/kimi-code-sdk": major +--- + +Consolidate thinking configuration into `[thinking] enabled` / `effort`, removing the top-level `default_thinking` field and `thinking.mode`. Migrate: `default_thinking = true` → `[thinking] enabled = true`; `default_thinking = false` or `mode = "off"` → `enabled = false`; `mode = "on"` / `mode = "auto"` → delete the line. Effort levels now come from each model's declared `support_efforts` instead of a fixed enum. diff --git a/apps/kimi-code/src/cli/sub/provider.ts b/apps/kimi-code/src/cli/sub/provider.ts index cd30c1957..8ed4546aa 100644 --- a/apps/kimi-code/src/cli/sub/provider.ts +++ b/apps/kimi-code/src/cli/sub/provider.ts @@ -340,7 +340,7 @@ export async function handleCatalogAdd( // already-configured provider would lose the user's previously-set default // even when `--default-model` is not supplied. const previousDefaultModel = config.defaultModel; - const previousDefaultThinking = config.defaultThinking; + const previousThinking = config.thinking; if (config.providers[providerId] !== undefined) { config = await harness.removeProvider(providerId); @@ -348,7 +348,7 @@ export async function handleCatalogAdd( const baseUrl = catalogBaseUrl(entry, wire); // `applyCatalogProvider` always overwrites both `defaultModel` and - // `defaultThinking`. The values we pass here are temporary; we restore + // `[thinking]`. The values we pass here are temporary; we restore // a consistent state in the post-apply block below. applyCatalogProvider(config, { providerId, @@ -373,18 +373,18 @@ export async function handleCatalogAdd( config.defaultModel = stillResolves ? previousDefaultModel : undefined; } - // Always restore `defaultThinking` from what was there before — including - // `undefined`. Persisting `false` when the user never set it would make - // `resolveThinkingLevel` (agent-core/src/agent/config/thinking.ts) treat - // it as an explicit "off" request and silently disable thinking, even - // for thinking-capable models. - config.defaultThinking = previousDefaultThinking; + // Always restore `[thinking]` from what was there before — including + // `undefined`. Persisting `enabled: false` when the user never set it would + // make `resolveThinkingEffort` (agent-core/src/agent/config/thinking.ts) treat + // it as an explicit "off" request and silently disable thinking, even for + // thinking-capable models. + config.thinking = previousThinking; await harness.setConfig({ providers: config.providers, models: config.models, defaultModel: config.defaultModel, - defaultThinking: config.defaultThinking, + thinking: config.thinking, }); const displayName = entry.name ?? providerId; diff --git a/apps/kimi-code/src/tui/commands/auth.ts b/apps/kimi-code/src/tui/commands/auth.ts index 8064c089b..6a6c46d97 100644 --- a/apps/kimi-code/src/tui/commands/auth.ts +++ b/apps/kimi-code/src/tui/commands/auth.ts @@ -158,7 +158,7 @@ async function handleOpenPlatformLogin( platform, models, selectedModel: selection.model, - thinking: selection.thinking, + thinking: selection.thinking !== 'off', apiKey, }); @@ -166,7 +166,7 @@ async function handleOpenPlatformLogin( providers: config.providers, models: config.models, defaultModel: config.defaultModel, - defaultThinking: config.defaultThinking, + thinking: config.thinking, }); await host.authFlow.refreshConfigAfterLogin(); diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 42e87b294..fd0aeb964 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -1,15 +1,19 @@ import type { ExperimentalFeatureState, FlagId, + ModelAlias, PermissionMode, Session, + ThinkingEffort, } from '@moonshot-ai/kimi-code-sdk'; import { EditorSelectorComponent } from '../components/dialogs/editor-selector'; +import { EffortSelectorComponent } from '../components/dialogs/effort-selector'; import { ExperimentsSelectorComponent, type ExperimentalFeatureDraftChange, } from '../components/dialogs/experiments-selector'; +import { modelDisplayName, segmentsFor } from '../components/dialogs/model-selector'; import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector'; import { PermissionSelectorComponent } from '../components/dialogs/permission-selector'; import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector'; @@ -20,6 +24,7 @@ import type { ThemeName } from '#/tui/theme'; import { currentTheme, isBuiltInTheme, lightColors, loadCustomThemeMerged } from '#/tui/theme'; import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; import { formatErrorMessage } from '../utils/event-payload'; +import { thinkingEffortToConfig } from '../utils/thinking-config'; import { showUsage } from './info'; import { setExperimentalFeatures } from './experimental-flags'; import type { SlashCommandHost } from './dispatch'; @@ -29,6 +34,7 @@ import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- const MODEL_PICKER_REFRESH_TIMEOUT_MS = 2_000; +const REFRESH_MODELS_ON_PICKER_OPEN = true; export async function handlePlanCommand(host: SlashCommandHost, args: string): Promise { const session = host.session; @@ -212,6 +218,55 @@ export async function handleModelCommand(host: SlashCommandHost, args: string): showModelPicker(host, alias); } +export async function handleEffortCommand(host: SlashCommandHost, args: string): Promise { + const alias = host.state.appState.model; + const model = host.state.appState.availableModels[alias]; + if (model === undefined) { + host.showError('No model selected. Run /model to select one first.'); + return; + } + const segments = segmentsFor(model); + const arg = args.trim().toLowerCase(); + if (arg.length === 0) { + showEffortPicker(host, model, segments); + return; + } + if (!segments.includes(arg)) { + host.showError( + `Unsupported thinking effort "${arg}" for ${alias}. Available: ${segments.join(', ')}`, + ); + return; + } + await performModelSwitch(host, alias, arg, true); +} + +function showEffortPicker( + host: SlashCommandHost, + model: ModelAlias, + segments: readonly string[], +): void { + const liveEffort = host.state.appState.thinkingEffort; + const currentValue = segments.includes(liveEffort) ? liveEffort : (segments[0] ?? 'off'); + const alias = host.state.appState.model; + host.mountEditorReplacement( + new EffortSelectorComponent({ + efforts: segments, + currentValue, + onSelect: (effort) => { + host.restoreEditor(); + void performModelSwitch(host, alias, effort, true); + }, + onSessionOnlySelect: (effort) => { + host.restoreEditor(); + void performModelSwitch(host, alias, effort, false); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + // --------------------------------------------------------------------------- // Pickers & config apply // --------------------------------------------------------------------------- @@ -233,6 +288,10 @@ function showEditorPicker(host: SlashCommandHost): void { } async function refreshModelsForPicker(host: SlashCommandHost): Promise { + // TODO: re-enable once refreshing the model catalog no longer rebuilds (and + // thus overwrites) manually configured model capabilities such as + // support_efforts / default_effort on every picker open. + if (!REFRESH_MODELS_ON_PICKER_OPEN) return; try { const result = await withTimeout( host.authFlow.refreshOAuthProviderModels(), @@ -308,7 +367,7 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string = models: host.state.appState.availableModels, currentValue: host.state.appState.model, selectedValue, - currentThinking: host.state.appState.thinking, + currentThinkingEffort: host.state.appState.thinkingEffort, onSelect: ({ alias, thinking }) => { host.restoreEditor(); void performModelSwitch(host, alias, thinking, true); @@ -327,7 +386,7 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string = async function performModelSwitch( host: SlashCommandHost, alias: string, - thinking: boolean, + effort: ThinkingEffort, persist: boolean, ): Promise { if (host.state.appState.streamingPhase !== 'idle') { @@ -335,21 +394,23 @@ async function performModelSwitch( return; } - const level = thinking ? 'on' : 'off'; const prevModel = host.state.appState.model; - const prevThinking = host.state.appState.thinking; - const runtimeChanged = alias !== prevModel || thinking !== prevThinking; + const prevEffort = host.state.appState.thinkingEffort; + const modelChanged = alias !== prevModel; + const effortChanged = effort !== prevEffort; + const runtimeChanged = modelChanged || effortChanged; + const displayName = modelDisplayName(alias, host.state.appState.availableModels[alias]); const session = host.session; try { if (session === undefined && runtimeChanged) { - await host.authFlow.activateModelAfterLogin(alias, thinking); + await host.authFlow.activateModelAfterLogin(alias, effort); } else if (session !== undefined) { if (alias !== prevModel) { await session.setModel(alias); } - if (thinking !== prevThinking) { - await session.setThinking(level); + if (effort !== prevEffort) { + await session.setThinking(effort); } } } catch (error) { @@ -358,48 +419,61 @@ async function performModelSwitch( return; } - host.setAppState({ model: alias, thinking }); + host.setAppState({ model: alias, thinkingEffort: effort }); if (session === undefined && runtimeChanged) { if (alias !== prevModel) { host.track('model_switch', { model: alias }); } - if (thinking !== prevThinking) { - host.track('thinking_toggle', { enabled: thinking }); + if (effort !== prevEffort) { + host.track('thinking_toggle', { effort }); } } let persisted = false; if (persist) { try { - persisted = await persistModelSelection(host, alias, thinking); + persisted = await persistModelSelection(host, alias, effort); } catch (error) { const msg = formatErrorMessage(error); - host.showError(`Switched to ${alias}, but failed to save default: ${msg}`); + host.showError(`Switched to ${displayName}, but failed to save default: ${msg}`); return; } } let status: string; - if (runtimeChanged) { + if (modelChanged) { status = persist - ? `Switched to ${alias} with thinking ${level}.` - : `Switched to ${alias} with thinking ${level} for this session only.`; + ? `Switched to ${displayName} with thinking ${effort}.` + : `Switched to ${displayName} with thinking ${effort} for this session only.`; + } else if (effortChanged) { + status = persist + ? `Thinking set to ${effort}.` + : `Thinking set to ${effort} for this session only.`; } else if (persist && persisted) { - status = `Saved ${alias} with thinking ${level} as default.`; + status = `Saved ${displayName} with thinking ${effort} as default.`; } else { - status = `Already using ${alias} with thinking ${level}.`; + status = `Already using ${displayName} with thinking ${effort}.`; } host.showStatus(status, 'success'); } -async function persistModelSelection(host: SlashCommandHost, alias: string, thinking: boolean): Promise { +async function persistModelSelection( + host: SlashCommandHost, + alias: string, + effort: ThinkingEffort, +): Promise { const config = await host.harness.getConfig({ reload: true }); - if (config.defaultModel === alias && config.defaultThinking === thinking) { + const patch = thinkingEffortToConfig(effort); + if ( + config.defaultModel === alias && + config.thinking?.enabled === patch.enabled && + config.thinking?.effort === patch.effort + ) { return false; } await host.harness.setConfig({ defaultModel: alias, - defaultThinking: thinking, + thinking: patch, }); return true; } diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 02226507e..d1c011a13 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -25,6 +25,7 @@ import { handleAutoCommand, handleCompactCommand, handleEditorCommand, + handleEffortCommand, handleModelCommand, handlePlanCommand, handleThemeCommand, @@ -65,6 +66,7 @@ export { handleAutoCommand, handleCompactCommand, handleEditorCommand, + handleEffortCommand, handleModelCommand, handlePlanCommand, handleThemeCommand, @@ -270,6 +272,9 @@ async function handleBuiltInSlashCommand( case 'model': await handleModelCommand(host, args); return; + case 'effort': + await handleEffortCommand(host, args); + return; case 'provider': await handleProviderCommand(host); return; diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index d54f9fb2a..278938c27 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -132,7 +132,7 @@ export async function showStatusReport(host: SlashCommandHost): Promise { workDir: appState.workDir, sessionId: appState.sessionId, sessionTitle: appState.sessionTitle, - thinking: appState.thinking, + thinkingEffort: appState.thinkingEffort, permissionMode: appState.permissionMode, planMode: appState.planMode, contextUsage: appState.contextUsage, diff --git a/apps/kimi-code/src/tui/commands/prompts.ts b/apps/kimi-code/src/tui/commands/prompts.ts index 0bdbb8899..5b0fd5533 100644 --- a/apps/kimi-code/src/tui/commands/prompts.ts +++ b/apps/kimi-code/src/tui/commands/prompts.ts @@ -4,6 +4,7 @@ import { type Catalog, type CatalogModel, type ModelAlias, + type ThinkingEffort, } from '@moonshot-ai/kimi-code-sdk'; import { capabilitiesForModel } from '@moonshot-ai/kimi-code-oauth'; import type { @@ -166,7 +167,7 @@ export async function promptModelSelectionForOpenPlatform( host: SlashCommandHost, models: ManagedKimiCodeModelInfo[], platform: OpenPlatformDefinition, -): Promise<{ model: ManagedKimiCodeModelInfo; thinking: boolean } | undefined> { +): Promise<{ model: ManagedKimiCodeModelInfo; thinking: ThinkingEffort } | undefined> { const modelDict: Record = {}; for (const m of models) { modelDict[`${platform.id}/${m.id}`] = { @@ -187,7 +188,7 @@ export async function promptModelSelectionForCatalog( host: SlashCommandHost, providerId: string, models: CatalogModel[], -): Promise<{ model: CatalogModel; thinking: boolean } | undefined> { +): Promise<{ model: CatalogModel; thinking: ThinkingEffort } | undefined> { const modelDict: Record = {}; for (const m of models) { modelDict[`${providerId}/${m.id}`] = catalogModelToAlias(providerId, m); @@ -201,7 +202,7 @@ export async function promptModelSelectionForCatalog( export function runModelSelector( host: SlashCommandHost, modelDict: Record, -): Promise<{ alias: string; thinking: boolean } | undefined> { +): Promise<{ alias: string; thinking: ThinkingEffort } | undefined> { return new Promise((resolve) => { const firstAlias = Object.keys(modelDict)[0] ?? ''; const caps = modelDict[firstAlias]?.capabilities ?? []; @@ -209,7 +210,7 @@ export function runModelSelector( const selector = new ModelSelectorComponent({ models: modelDict, currentValue: firstAlias, - currentThinking: initialThinking, + currentThinkingEffort: initialThinking ? 'on' : 'off', searchable: true, onSelect: ({ alias, thinking }) => { host.restoreEditor(); diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts index 242252bfb..eb416a54e 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -13,6 +13,7 @@ import { fetchCatalog, inferWireType, type Catalog, + type ThinkingEffort, } from '@moonshot-ai/kimi-code-sdk'; import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; @@ -27,6 +28,7 @@ import { import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector'; import { DEFAULT_OAUTH_PROVIDER_NAME } from '../constant/kimi-tui'; import { formatErrorMessage } from '../utils/event-payload'; +import { thinkingEffortToConfig } from '../utils/thinking-config'; import { promptApiKey, promptCatalogProviderSelection, @@ -233,7 +235,7 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { models: mergedModels, currentValue: host.state.appState.model, selectedValue: Object.keys(mergedModels).find((a) => a.startsWith(`${providerId}/`)), - currentThinking: host.state.appState.thinking, + currentThinkingEffort: host.state.appState.thinkingEffort, initialTabId: providerId, onSelect: ({ alias, thinking }) => { host.restoreEditor(); @@ -251,15 +253,15 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { async function setDefaultModel( host: SlashCommandHost, alias: string, - thinking: boolean, + effort: ThinkingEffort, ): Promise { await host.harness.setConfig({ defaultModel: alias, - defaultThinking: thinking, + thinking: thinkingEffortToConfig(effort), }); await host.authFlow.refreshConfigAfterLogin(); host.track('model_switch', { model: alias }); - host.showStatus(`Default model set to ${alias} with thinking ${thinking ? 'on' : 'off'}.`); + host.showStatus(`Default model set to ${alias} with thinking ${effort}.`); } async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise { @@ -323,7 +325,7 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise models: stateModels, currentValue: host.state.appState.model, selectedValue: firstNewAlias, - currentThinking: host.state.appState.thinking, + currentThinkingEffort: host.state.appState.thinkingEffort, initialTabId: firstNewProvider, onSelect: ({ alias, thinking }) => { host.restoreEditor(); diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 966e5ceb7..107335617 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -184,6 +184,13 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 100, availability: 'always', }, + { + name: 'effort', + aliases: ['thinking'], + description: 'Switch thinking effort', + priority: 95, + availability: 'always', + }, { name: 'provider', aliases: ['providers'], diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index e77f1a18c..293113ad6 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -262,7 +262,17 @@ export class FooterComponent implements Component { const model = modelDisplayName(state); if (model) { - const thinkingLabel = state.thinking ? ' thinking' : ''; + const effort = state.thinkingEffort; + const currentModel = state.availableModels[state.model]; + // Only effort-capable models (those declaring support_efforts) show the + // concrete effort; legacy boolean models keep the plain "thinking" suffix. + const hasEfforts = (currentModel?.supportEfforts?.length ?? 0) > 0; + const thinkingLabel = + effort !== 'off' + ? hasEfforts && effort !== 'on' + ? ` thinking:${effort}` + : ' thinking' + : ''; const modelLabel = `${model}${thinkingLabel}`; let renderedModelLabel = chalk.hex(colors.text)(modelLabel); if (isRainbowDancing()) { diff --git a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts index c85339d90..23e92c8f8 100644 --- a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts @@ -49,6 +49,9 @@ export interface ChoicePickerOptions { /** Items per page. Lists longer than this paginate. */ readonly pageSize?: number; readonly onSelect: (value: string) => void; + /** When provided, Alt+S invokes this with the selected value instead of + * onSelect — used to apply the choice to the current session only. */ + readonly onSessionOnlySelect?: (value: string) => void; readonly onCancel: () => void; } @@ -99,6 +102,11 @@ export class ChoicePickerComponent extends Container implements Focusable { this.opts.onCancel(); return; } + if (matchesKey(data, Key.alt('s')) && this.opts.onSessionOnlySelect !== undefined) { + const chosen = this.list.selected(); + if (chosen !== undefined) this.opts.onSessionOnlySelect(chosen.value); + return; + } // Left/Right page through the list (this picker has no horizontal control). if (matchesKey(data, Key.left)) { this.list.pageUp(); diff --git a/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts b/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts new file mode 100644 index 000000000..498c4191e --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts @@ -0,0 +1,94 @@ +import { + Container, + Key, + matchesKey, + truncateToWidth, + type Focusable, +} from '@earendil-works/pi-tui'; + +import type { ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; + +import { currentTheme } from '#/tui/theme'; + +import { effortLabel } from './model-selector'; + +export interface EffortSelectorOptions { + readonly title?: string; + /** Selectable thinking efforts for the current model (e.g. ["off","low","high","max"]). */ + readonly efforts: readonly ThinkingEffort[]; + /** Currently active effort (highlighted). */ + readonly currentValue: ThinkingEffort; + readonly onSelect: (effort: ThinkingEffort) => void; + /** When provided, Alt+S applies the choice to the current session only. */ + readonly onSessionOnlySelect?: (effort: ThinkingEffort) => void; + readonly onCancel: () => void; +} + +/** + * Horizontal segmented picker for the `/effort` command. + * + * Mirrors the thinking control rendered under `/model` (see + * `renderThinkingControl` in model-selector.ts): a single row of segments, + * the active one wrapped in `[ ]`. ←/→ step the active segment, Enter + * commits, and Alt+S (when provided) applies session-only. + */ +export class EffortSelectorComponent extends Container implements Focusable { + focused = false; + private readonly opts: EffortSelectorOptions; + private activeIndex: number; + + constructor(opts: EffortSelectorOptions) { + super(); + this.opts = opts; + const idx = opts.efforts.indexOf(opts.currentValue); + this.activeIndex = Math.max(idx, 0); + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + if (matchesKey(data, Key.left)) { + this.activeIndex = Math.max(0, this.activeIndex - 1); + return; + } + if (matchesKey(data, Key.right)) { + this.activeIndex = Math.min(this.opts.efforts.length - 1, this.activeIndex + 1); + return; + } + if (matchesKey(data, Key.alt('s')) && this.opts.onSessionOnlySelect !== undefined) { + this.opts.onSessionOnlySelect(this.opts.efforts[this.activeIndex]!); + return; + } + if (matchesKey(data, Key.enter)) { + this.opts.onSelect(this.opts.efforts[this.activeIndex]!); + return; + } + } + + override render(width: number): string[] { + const hintParts = ['←→ switch', 'Enter select']; + if (this.opts.onSessionOnlySelect !== undefined) hintParts.push('Alt+S session-only'); + hintParts.push('Esc cancel'); + + const lines: string[] = [ + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ` ${this.opts.title ?? 'Select thinking effort'}`), + currentTheme.fg('textMuted', ` ${hintParts.join(' · ')}`), + '', + ]; + + const segments = this.opts.efforts.map((effort, index) => { + const label = effortLabel(effort); + return index === this.activeIndex + ? currentTheme.boldFg('primary', `[ ${label} ]`) + : currentTheme.fg('text', ` ${label} `); + }); + lines.push(` ${segments.join(' ')}`); + + lines.push(''); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width)); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index c319c83a4..c2308c18a 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -1,4 +1,4 @@ -import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import type { ModelAlias, ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; import { Container, Key, @@ -30,7 +30,10 @@ interface ModelChoice { export interface ModelSelection { readonly alias: string; - readonly thinking: boolean; + /** Chosen thinking effort: 'off', or a concrete effort such as 'low' / + * 'high' / 'max'. Boolean 'on' is normalized to the model's default effort + * before the selection is committed (see commitEffort). */ + readonly thinking: ThinkingEffort; } export function modelDisplayName(alias: string, model: ModelAlias | undefined): string { @@ -56,7 +59,9 @@ export interface ModelSelectorOptions { readonly models: Record; readonly currentValue: string; readonly selectedValue?: string; - readonly currentThinking: boolean; + /** Live thinking effort of the currently active model (e.g. 'off', 'on', + * 'high'). Used to highlight the active segment for the current model. */ + readonly currentThinkingEffort: ThinkingEffort; /** When true, typed characters filter the list (fuzzy) and a search line is shown. */ readonly searchable?: boolean; /** Items per page. Lists longer than this paginate (PgUp/PgDn). */ @@ -79,18 +84,62 @@ function createModelChoices(models: Record): readonly ModelC }); } -function thinkingAvailability(model: ModelAlias): ThinkingAvailability { +export function thinkingAvailability(model: ModelAlias): ThinkingAvailability { const caps = model.capabilities ?? []; if (caps.includes('always_thinking')) return 'always-on'; if (caps.includes('thinking') || model.adaptiveThinking === true) return 'toggle'; return 'unsupported'; } -function effectiveThinking(model: ModelAlias, thinkingDraft: boolean): boolean { +export function effortsOf(model: ModelAlias): readonly string[] { + return model.supportEfforts ?? []; +} + +/** + * Ordered list of selectable thinking efforts for a model. Effort-capable models + * expose their declared efforts (with an 'off' entry when the model is not + * always-on); legacy boolean models expose 'on'/'off'; single-segment lists + * mean the control is effectively locked. + */ +export function segmentsFor(model: ModelAlias): readonly string[] { + const efforts = effortsOf(model); const availability = thinkingAvailability(model); - if (availability === 'always-on') return true; - if (availability === 'unsupported') return false; - return thinkingDraft; + if (efforts.length > 0) { + return availability === 'always-on' ? efforts : ['off', ...efforts]; + } + if (availability === 'always-on') return ['on']; + if (availability === 'unsupported') return ['off']; + return ['on', 'off']; +} + +export function effortLabel(effort: string): string { + if (effort.length === 0) return effort; + return effort.charAt(0).toUpperCase() + effort.slice(1); +} + +/** + * Default thinking effort for a model: declared `default_effort`, else the + * middle `support_efforts` entry, else `'on'` for boolean models, `'off'` when + * thinking is unsupported. + */ +function defaultThinkingEffortFor(model: ModelAlias): ThinkingEffort { + if (thinkingAvailability(model) === 'unsupported') return 'off'; + const efforts = effortsOf(model); + if (efforts.length > 0) { + return model.defaultEffort ?? efforts[Math.floor(efforts.length / 2)]!; + } + return 'on'; +} + +/** + * Normalize a draft effort before committing a selection. A boolean `'on'` + * never leaks past the UI boundary — it becomes the model's default effort + * (a concrete effort for effort-capable models, `'on'` only for genuine + * boolean models). + */ +function commitEffort(choice: ModelChoice, draft: ThinkingEffort): ThinkingEffort { + if (draft === 'on') return defaultThinkingEffortFor(choice.model); + return draft; } /** @@ -105,8 +154,8 @@ export class ModelSelectorComponent extends Container implements Focusable { focused = false; private readonly opts: ModelSelectorOptions; private readonly list: SearchableList; - /** Per-model thinking override set by ←/→; absent → the capability default. */ - private readonly thinkingOverrides = new Map(); + /** Per-model thinking-effort override set by ←/→; absent → the default. */ + private readonly thinkingOverrides = new Map(); constructor(opts: ModelSelectorOptions) { super(); @@ -124,15 +173,31 @@ export class ModelSelectorComponent extends Container implements Focusable { } /** - * Thinking draft for a model: an explicit ←/→ override when set, otherwise - * the live thinking state for the active model, otherwise On for any other - * thinking-capable model (a capable model should default to thinking on). + * Thinking effort for a model: an explicit ←/→ override when set, otherwise + * the live effort for the active model, otherwise the model's default effort + * (effort-capable) or 'on' (other thinking-capable models). */ - private draftFor(choice: ModelChoice): boolean { + private draftFor(choice: ModelChoice): string { const override = this.thinkingOverrides.get(choice.alias); if (override !== undefined) return override; - if (choice.alias === this.opts.currentValue) return this.opts.currentThinking; - return thinkingAvailability(choice.model) !== 'unsupported'; + if (choice.alias === this.opts.currentValue) return this.opts.currentThinkingEffort; + const efforts = effortsOf(choice.model); + if (efforts.length > 0) { + // A model with support_efforts but no default_effort defaults to the + // middle entry of its supported efforts. + const def = choice.model.defaultEffort ?? efforts[Math.floor(efforts.length / 2)]; + if (def !== undefined && efforts.includes(def)) return def; + return efforts[0]!; + } + return thinkingAvailability(choice.model) !== 'unsupported' ? 'on' : 'off'; + } + + /** Draft coerced onto the model's segment list so rendering/selection never + * reference a effort the model cannot actually select. */ + private effectiveEffort(choice: ModelChoice): string { + const draft = this.draftFor(choice); + const segments = segmentsFor(choice.model); + return segments.includes(draft) ? draft : segments[0]!; } handleInput(data: string): void { @@ -147,11 +212,27 @@ export class ModelSelectorComponent extends Container implements Focusable { return; } - // Left/Right toggle the thinking draft for models that support it. + // Left/Right move the active thinking effort within the model's segments. if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) { const selected = this.selectedChoice(); - if (selected !== undefined && thinkingAvailability(selected.model) === 'toggle') { - this.thinkingOverrides.set(selected.alias, !this.draftFor(selected)); + if (selected !== undefined) { + const segments = segmentsFor(selected.model); + if (segments.length > 1) { + const current = this.effectiveEffort(selected); + const idx = segments.indexOf(current); + // The two-segment case is the legacy boolean On/Off control: both + // arrows flip it. With more segments (efforts), ←/→ step. + let next: number; + if (segments.length === 2) { + next = idx === 0 ? 1 : 0; + } else { + const delta = matchesKey(data, Key.left) ? -1 : 1; + next = Math.max(0, Math.min(segments.length - 1, idx + delta)); + } + if (next !== idx) { + this.thinkingOverrides.set(selected.alias, segments[next]!); + } + } } return; } @@ -161,7 +242,7 @@ export class ModelSelectorComponent extends Container implements Focusable { if (selected === undefined) return; this.opts.onSelect({ alias: selected.alias, - thinking: effectiveThinking(selected.model, this.draftFor(selected)), + thinking: commitEffort(selected, this.effectiveEffort(selected)), }); return; } @@ -171,7 +252,7 @@ export class ModelSelectorComponent extends Container implements Focusable { if (selected === undefined) return; this.opts.onSessionOnlySelect({ alias: selected.alias, - thinking: effectiveThinking(selected.model, this.draftFor(selected)), + thinking: commitEffort(selected, this.effectiveEffort(selected)), }); } } @@ -255,8 +336,8 @@ export class ModelSelectorComponent extends Container implements Focusable { lines.push(''); const selected = this.selectedChoice(); if (selected !== undefined) { - const availability = thinkingAvailability(selected.model); - const thinkingHeader = availability === 'toggle' ? ' Thinking (←→ to switch)' : ' Thinking'; + const canSwitch = segmentsFor(selected.model).length > 1; + const thinkingHeader = canSwitch ? ' Thinking (←→ to switch)' : ' Thinking'; lines.push(currentTheme.fg('textMuted', thinkingHeader)); lines.push(this.renderThinkingControl(selected)); } @@ -279,16 +360,26 @@ export class ModelSelectorComponent extends Container implements Focusable { const unavailable = (label: string): string => currentTheme.fg('textMuted', ` ${label} (Unsupported) `); - // On stays left and Off right in all three states so the control never - // shifts while the cursor moves across models. + // Non-effort always-on / unsupported models keep the original On/Off layout + // so the control never shifts while moving across legacy models. + const efforts = effortsOf(choice.model); const availability = thinkingAvailability(choice.model); - if (availability === 'always-on') { + if (efforts.length === 0 && availability === 'always-on') { return ` ${segment('On', true)} ${unavailable('Off')}`; } - if (availability === 'unsupported') { + if (efforts.length === 0 && availability === 'unsupported') { return ` ${unavailable('On')} ${segment('Off', true)}`; } - const draft = this.draftFor(choice); - return ` ${segment('On', draft)} ${segment('Off', !draft)}`; + + const segments = segmentsFor(choice.model); + const active = this.effectiveEffort(choice); + const rendered = segments.map((effort) => segment(effortLabel(effort), effort === active)); + // Always-on models (including effort-capable ones) additionally surface an + // unsupported Off so it's explicit that thinking cannot be disabled — same + // shape as the legacy always-on control. + if (availability === 'always-on') { + rendered.push(unavailable('Off')); + } + return ` ${rendered.join(' ')}`; } } diff --git a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts index e7287e734..3680eecbf 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts @@ -39,7 +39,7 @@ export interface TabbedModelSelectorOptions { readonly models: Record; readonly currentValue: string; readonly selectedValue?: string; - readonly currentThinking: boolean; + readonly currentThinkingEffort: string; /** When set, the tab for this provider id is initially active instead of the * tab derived from `currentValue`. */ readonly initialTabId?: string; @@ -179,7 +179,7 @@ function makeSelector( models: subset, currentValue: opts.currentValue, ...(selectedValue !== undefined ? { selectedValue } : {}), - currentThinking: opts.currentThinking, + currentThinkingEffort: opts.currentThinkingEffort, searchable: true, providerSwitchHint: true, onSelect: opts.onSelect, diff --git a/apps/kimi-code/src/tui/components/messages/status-panel.ts b/apps/kimi-code/src/tui/components/messages/status-panel.ts index 9007b8f97..a87c3da21 100644 --- a/apps/kimi-code/src/tui/components/messages/status-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/status-panel.ts @@ -5,7 +5,7 @@ * separate from the TUI orchestration layer. */ -import type { ModelAlias, PermissionMode, SessionStatus } from '@moonshot-ai/kimi-code-sdk'; +import type { ModelAlias, PermissionMode, SessionStatus, ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; import { PRODUCT_NAME } from '#/constant/app'; import { currentTheme } from '#/tui/theme'; @@ -30,7 +30,7 @@ export interface StatusReportOptions { readonly workDir: string; readonly sessionId: string; readonly sessionTitle: string | null; - readonly thinking: boolean; + readonly thinkingEffort: ThinkingEffort; readonly permissionMode: PermissionMode; readonly planMode: boolean; readonly contextUsage: number; @@ -54,9 +54,8 @@ function formatModelStatus(options: StatusReportOptions): string { const model = options.status?.model ?? options.model; if (model.trim().length === 0) return 'not set'; - const thinking = (options.status?.thinkingLevel ?? (options.thinking ? 'on' : 'off')) === 'off' - ? 'off' - : 'on'; + const effort = options.status?.thinkingEffort ?? options.thinkingEffort; + const thinking = effort === 'off' ? 'off' : 'on'; return `${displayModelName(model, options.availableModels)} (thinking ${thinking})`; } diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index 03199b65a..451cffbee 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -50,7 +50,7 @@ export class AuthFlowController { this.host.setAppState({ sessionId: '', model: '', - thinking: false, + thinkingEffort: 'off', contextTokens: 0, maxContextTokens: 0, contextUsage: 0, @@ -60,13 +60,12 @@ export class AuthFlowController { this.host.setStartupReady(); } - async activateModelAfterLogin(model: string, thinking?: boolean): Promise { + async activateModelAfterLogin(model: string, effort?: string): Promise { const { host } = this; - const level = thinking === undefined ? undefined : thinking ? 'on' : 'off'; if (host.session !== undefined) { await host.session.setModel(model); - if (level !== undefined) { - await host.session.setThinking(level); + if (effort !== undefined) { + await host.session.setThinking(effort); } return; } @@ -74,7 +73,7 @@ export class AuthFlowController { const options: MutableCreateSessionOptions = { workDir: host.state.appState.workDir, model, - thinking: level, + thinking: effort, permission: host.options.startup.auto ? 'auto' : host.options.startup.yolo @@ -122,16 +121,13 @@ export class AuthFlowController { return; } - await this.activateModelAfterLogin(defaultModel, config.defaultThinking); + await this.activateModelAfterLogin(defaultModel, config.thinking?.enabled === false ? 'off' : undefined); const appStatePatch: Partial = { availableModels, availableProviders, model: defaultModel, maxContextTokens: selected.maxContextSize, }; - if (config.defaultThinking !== undefined) { - appStatePatch.thinking = config.defaultThinking; - } host.setAppState(appStatePatch); } @@ -141,7 +137,7 @@ export class AuthFlowController { availableModels: config.models ?? {}, availableProviders: config.providers ?? {}, model: '', - thinking: false, + thinkingEffort: 'off', maxContextTokens: 0, contextUsage: 0, contextTokens: 0, diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index f2cee5888..51110a810 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -150,6 +150,11 @@ import { import { formatBashOutputForDisplay } from './utils/shell-output'; import { nextTranscriptId } from './utils/transcript-id'; +// TODO: re-enable once refreshing the model catalog no longer rebuilds (and +// thus overwrites) manually configured model capabilities such as +// support_efforts / default_effort on startup. +const REFRESH_PROVIDER_MODELS_ON_STARTUP = true; + export type { TUIState } from './tui-state'; export { createTUIState } from './tui-state'; export type { @@ -203,7 +208,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { planMode: input.cliOptions.plan, inputMode: 'prompt', swarmMode: false, - thinking: false, + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 0, @@ -571,6 +576,7 @@ export class KimiTUI { } private async refreshProviderModelsInBackground(): Promise { + if (!REFRESH_PROVIDER_MODELS_ON_STARTUP) return; try { const result = await this.authFlow.refreshProviderModels(); for (const c of result.changed) { @@ -1343,8 +1349,7 @@ export class KimiTUI { const options: MutableCreateSessionOptions = { workDir: this.state.appState.workDir, model, - thinking: - this.session === undefined ? undefined : this.state.appState.thinking ? 'on' : 'off', + thinking: this.session === undefined ? undefined : this.state.appState.thinkingEffort, permission: this.state.appState.permissionMode, planMode: this.state.appState.planMode ? true : undefined, }; @@ -1368,7 +1373,7 @@ export class KimiTUI { this.setAppState({ sessionId: session.id, model: status.model ?? '', - thinking: status.thinkingLevel !== 'off', + thinkingEffort: status.thinkingEffort, permissionMode: status.permission, planMode: status.planMode, swarmMode: status.swarmMode ?? false, diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 6d96eb2d5..ec440e3be 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -5,6 +5,7 @@ import type { PermissionMode, ProviderConfig, PromptPart, + ThinkingEffort, ToolInputDisplay, } from '@moonshot-ai/kimi-code-sdk'; @@ -33,7 +34,10 @@ export interface AppState { /** 'bash' when the editor is in `!` shell-command mode. */ inputMode: 'prompt' | 'bash'; swarmMode: boolean; - thinking: boolean; + /** Live thinking effort of the active session (e.g. 'off', 'on', 'high'); + * mirrors the runtime. The single source of truth for the thinking state in + * the TUI. */ + thinkingEffort: ThinkingEffort; contextUsage: number; contextTokens: number; maxContextTokens: number; diff --git a/apps/kimi-code/src/tui/utils/refresh-providers.ts b/apps/kimi-code/src/tui/utils/refresh-providers.ts index a25c4b7cf..314a91447 100644 --- a/apps/kimi-code/src/tui/utils/refresh-providers.ts +++ b/apps/kimi-code/src/tui/utils/refresh-providers.ts @@ -230,7 +230,8 @@ function restoreDefaultSelection( // A refresh may have just learned that the default model cannot disable // thinking — never restore a stale thinking-off selection onto it. const capabilities = config.models[defaultModel]?.capabilities ?? []; - config.defaultThinking = capabilities.includes('always_thinking') ? true : defaultThinking; + const enabled = capabilities.includes('always_thinking') ? true : defaultThinking; + config.thinking = { ...config.thinking, ...(enabled !== undefined ? { enabled } : {}) }; } // `apply*` may leave `defaultModel` pointing at an alias that no longer exists @@ -240,16 +241,16 @@ function restoreDefaultSelection( function clampDanglingDefault(config: KimiConfig): void { if (config.defaultModel !== undefined && config.models?.[config.defaultModel] === undefined) { config.defaultModel = undefined; - config.defaultThinking = undefined; + config.thinking = undefined; } } -function clearDefaultThinkingWhenDefaultRemoved( +function clearThinkingWhenDefaultRemoved( config: KimiConfig, previousDefaultModel: string | undefined, ): void { if (previousDefaultModel !== undefined && config.defaultModel === undefined) { - config.defaultThinking = undefined; + config.thinking = undefined; } } @@ -319,9 +320,9 @@ export async function refreshAllProviderModels( next, preserveUserProviderAliases(config, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys), ); - restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); + restoreDefaultSelection(next, config.defaultModel, config.thinking?.enabled); clampDanglingDefault(next); - clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel); + clearThinkingWhenDefaultRemoved(next, config.defaultModel); if (providerModelsEqual(config, next, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys)) { unchanged.push(KIMI_CODE_PROVIDER_NAME); @@ -335,7 +336,7 @@ export async function refreshAllProviderModels( providers: next.providers, models: next.models, defaultModel: next.defaultModel, - defaultThinking: next.defaultThinking, + thinking: next.thinking, }); changed.push({ providerId: KIMI_CODE_PROVIDER_NAME, @@ -393,9 +394,9 @@ export async function refreshAllProviderModels( `${providerId}/`, ); restoreProviderAliases(next, preserveUserProviderAliases(config, providerId, refreshedAliasKeys)); - restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); + restoreDefaultSelection(next, config.defaultModel, config.thinking?.enabled); clampDanglingDefault(next); - clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel); + clearThinkingWhenDefaultRemoved(next, config.defaultModel); if (providerModelsEqual(config, next, providerId, refreshedAliasKeys)) { unchanged.push(providerId); @@ -409,7 +410,7 @@ export async function refreshAllProviderModels( providers: next.providers, models: next.models, defaultModel: next.defaultModel, - defaultThinking: next.defaultThinking, + thinking: next.thinking, }); changed.push({ providerId, @@ -530,9 +531,9 @@ export async function refreshAllProviderModels( } if (changedProviders.length > 0 || hasUnreportedConfigChange) { - restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); + restoreDefaultSelection(next, config.defaultModel, config.thinking?.enabled); clampDanglingDefault(next); - clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel); + clearThinkingWhenDefaultRemoved(next, config.defaultModel); for (const providerId of providersToRemoveBeforeSet) { await host.removeProvider(providerId); } @@ -540,7 +541,7 @@ export async function refreshAllProviderModels( providers: next.providers, models: next.models, defaultModel: next.defaultModel, - defaultThinking: next.defaultThinking, + thinking: next.thinking, }); for (const change of changedProviders) { changed.push({ diff --git a/apps/kimi-code/src/tui/utils/thinking-config.ts b/apps/kimi-code/src/tui/utils/thinking-config.ts new file mode 100644 index 000000000..b0db34337 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/thinking-config.ts @@ -0,0 +1,18 @@ +import type { ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; + +/** Whether a thinking effort represents "thinking enabled" (anything but 'off'). */ +export function isThinkingOn(effort: ThinkingEffort): boolean { + return effort !== 'off'; +} + +/** + * Project a thinking effort to the `[thinking]` config patch persisted to + * config.toml. `'off'` disables thinking; any other effort enables it and + * records the effort as the global effort preference. + */ +export function thinkingEffortToConfig(effort: ThinkingEffort): { + enabled: boolean; + effort?: string; +} { + return effort === 'off' ? { enabled: false } : { enabled: true, effort: effort }; +} diff --git a/apps/kimi-code/test/cli/provider.test.ts b/apps/kimi-code/test/cli/provider.test.ts index 56768a78c..2e4aeece4 100644 --- a/apps/kimi-code/test/cli/provider.test.ts +++ b/apps/kimi-code/test/cli/provider.test.ts @@ -668,7 +668,7 @@ describe('kimi provider catalog add', () => { }, }, defaultModel: 'other/main', - defaultThinking: true, + thinking: { enabled: true }, } as unknown as KimiConfig; const { harness, current, setConfigCalls } = makeHarness(initial); const { deps, stdout, exitCodes } = makeDeps(harness); @@ -692,7 +692,7 @@ describe('kimi provider catalog add', () => { // The unrelated provider's model survives, and remains the default. expect(finalConfig.models?.['other/main']).toBeDefined(); expect(finalConfig.defaultModel).toBe('other/main'); - expect(finalConfig.defaultThinking).toBe(true); + expect(finalConfig.thinking?.enabled).toBe(true); // The patch sent over `setConfig` must explicitly carry the preserved default. expect(setConfigCalls[0]?.defaultModel).toBe('other/main'); expect(stdout.join('')).toContain('Imported Anthropic (anthropic)'); @@ -760,7 +760,7 @@ describe('kimi provider catalog add', () => { }, }, defaultModel: 'anthropic/claude-opus-4-7', - defaultThinking: true, + thinking: { enabled: true }, } as unknown as KimiConfig; const { harness, current } = makeHarness(initial); const { deps, exitCodes } = makeDeps(harness); @@ -773,19 +773,19 @@ describe('kimi provider catalog add', () => { expect(current().providers['anthropic']?.apiKey).toBe('sk-rotated'); // Previous default and thinking flag must survive the re-import. expect(current().defaultModel).toBe('anthropic/claude-opus-4-7'); - expect(current().defaultThinking).toBe(true); + expect(current().thinking?.enabled).toBe(true); }); - it('preserves default_thinking when --default-model is supplied to a thinking-capable model', async () => { + it('preserves thinking.enabled when --default-model is supplied to a thinking-capable model', async () => { // Regression test for the codex P2: `applyCatalogProvider` always - // assigns `defaultThinking` from `options.thinking`. Hardcoding `false` + // assigns `thinking.enabled` from `options.thinking`. Hardcoding `false` // silently disabled thinking even when the user previously had it on // and is just importing a known provider. The handler now threads the // previous value through. mockRegistryFetch(CATALOG_BODY); const initial: KimiConfig = { providers: {}, - defaultThinking: true, + thinking: { enabled: true }, } as unknown as KimiConfig; const { harness, current, setConfigCalls } = makeHarness(initial); const { deps, exitCodes } = makeDeps(harness); @@ -799,20 +799,20 @@ describe('kimi provider catalog add', () => { expect(exitCodes).toEqual([]); expect(current().defaultModel).toBe('anthropic/claude-opus-4-7'); - expect(current().defaultThinking).toBe(true); - expect(setConfigCalls[0]?.defaultThinking).toBe(true); + expect(current().thinking?.enabled).toBe(true); + expect(setConfigCalls[0]?.thinking?.enabled).toBe(true); }); - it('does not persist default_thinking=false for first-time setup with --default-model', async () => { + it('does not persist thinking.enabled=false for first-time setup with --default-model', async () => { // Regression test for codex P2 follow-up: previously the handler fell - // back to `false` when `defaultThinking` was unset, but - // `resolveThinkingLevel` treats `defaultThinking === false` as an + // back to `false` when `thinking.enabled` was unset, but + // `resolveThinkingEffort` treats `thinking.enabled === false` as an // explicit "off" request. A fresh `kimi provider catalog add // anthropic --default-model claude-opus-4-7` must NOT silently disable - // thinking — it should leave `defaultThinking` unset so the runtime + // thinking — it should leave `thinking.enabled` unset so the runtime // uses the per-model default. mockRegistryFetch(CATALOG_BODY); - // Note: `defaultThinking` is omitted on purpose to model a fresh user. + // Note: `thinking.enabled` is omitted on purpose to model a fresh user. const { harness, current, setConfigCalls } = makeHarness({ providers: {}, } as KimiConfig); @@ -829,8 +829,8 @@ describe('kimi provider catalog add', () => { expect(current().defaultModel).toBe('anthropic/claude-opus-4-7'); // Must NOT be `false`. `undefined` lets the runtime resolver pick the // per-model default; `false` would force `'off'`. - expect(current().defaultThinking).toBeUndefined(); - expect(setConfigCalls[0]?.defaultThinking).toBeUndefined(); + expect(current().thinking?.enabled).toBeUndefined(); + expect(setConfigCalls[0]?.thinking?.enabled).toBeUndefined(); }); it('drops a stale default_model when the catalog refresh no longer contains it', async () => { diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index 6b43ab2f7..3f652852c 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; import { setRainbowDance, type RainbowDanceController } from '#/tui/easter-eggs/dance'; import { currentTheme, darkColors, lightColors } from '#/tui/theme'; +import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; import type { AppState } from '#/tui/types'; const TRUECOLOR_PATTERN = /\[38;2;(\d+);(\d+);(\d+)m/g; @@ -39,7 +40,7 @@ const appState: AppState = { sessionTitle: null, model: 'kimi-k2', permissionMode: 'manual', - thinking: false, + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 0, @@ -105,4 +106,41 @@ describe('FooterComponent', () => { currentTheme.setPalette(darkColors); } }); + + it('shows the effort for an effort-capable model', () => { + const effortModel: ModelAlias = { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 262144, + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'high', + }; + const state: AppState = { + ...appState, + thinkingEffort: 'max', + availableModels: { 'kimi-k2': effortModel }, + }; + const footer = new FooterComponent(state); + + expect(footer.render(120).join('\n')).toContain('thinking:max'); + }); + + it('does not show the effort for a legacy boolean model', () => { + const plainModel: ModelAlias = { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 262144, + capabilities: ['thinking'], + }; + const state: AppState = { + ...appState, + thinkingEffort: 'high', + availableModels: { 'kimi-k2': plainModel }, + }; + const footer = new FooterComponent(state); + const rendered = footer.render(120).join('\n'); + + expect(rendered).toContain('thinking'); + expect(rendered).not.toContain('thinking:high'); + }); }); diff --git a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts index 1eb757e67..20e4d112d 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -17,7 +17,7 @@ const appState: AppState = { sessionTitle: null, model: 'kimi-k2', permissionMode: 'manual', - thinking: false, + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 0, diff --git a/apps/kimi-code/test/tui/components/dialogs/effort-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/effort-selector.test.ts new file mode 100644 index 000000000..e74fa7aa1 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/effort-selector.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { EffortSelectorComponent } from '#/tui/components/dialogs/effort-selector'; + +const ANSI = /\[[0-9;]*m/g; +const strip = (s: string): string => s.replaceAll(ANSI, ''); +const ESC = String.fromCodePoint(27); +const LEFT = `${ESC}[D`; +const RIGHT = `${ESC}[C`; + +function text(component: EffortSelectorComponent, width = 120): string { + return component.render(width).map(strip).join('\n'); +} + +describe('EffortSelectorComponent', () => { + it('renders efforts as horizontal segments with the active one bracketed', () => { + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + const out = text(picker); + // All efforts are rendered on a single row. + expect(out).toContain('Off'); + expect(out).toContain('Low'); + expect(out).toContain('High'); + expect(out).toContain('Max'); + // The active level is wrapped in brackets; the rest are not. + expect(out).toContain('[ High ]'); + expect(out).not.toContain('[ Off ]'); + expect(out).not.toContain('[ Max ]'); + }); + + it('invokes onSelect with the chosen effort on Enter', () => { + const onSelect = vi.fn(); + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + onSelect, + onCancel: vi.fn(), + }); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith('high'); + }); + + it('moves the active segment with Left/Right and stops at the edges', () => { + const onSelect = vi.fn(); + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + onSelect, + onCancel: vi.fn(), + }); + + // index 2 (high) -> 3 (max). + picker.handleInput(RIGHT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith('max'); + + // Already at the right edge — another Right stays put. + picker.handleInput(RIGHT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith('max'); + + // Walk back to the left edge (max -> high -> low -> off). + picker.handleInput(LEFT); + picker.handleInput(LEFT); + picker.handleInput(LEFT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith('off'); + + // Already at the left edge — another Left stays put. + picker.handleInput(LEFT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith('off'); + }); + + it('invokes onSessionOnlySelect on Alt+S instead of onSelect', () => { + const onSelect = vi.fn(); + const onSessionOnlySelect = vi.fn(); + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + onSelect, + onSessionOnlySelect, + onCancel: vi.fn(), + }); + picker.handleInput(`${ESC}s`); + expect(onSessionOnlySelect).toHaveBeenCalledWith('high'); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('cancels on Escape', () => { + const onCancel = vi.fn(); + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + onSelect: vi.fn(), + onCancel, + }); + picker.handleInput(ESC); + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts index 4d0ad439b..76658bb56 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -24,6 +24,23 @@ function model(displayName: string, capabilities: string[] = ['thinking']): Mode } as unknown as ModelAlias; } +function effortModel( + displayName: string, + supportEfforts: string[], + defaultEffort?: string, + capabilities: string[] = ['thinking'], +): ModelAlias { + return { + provider: 'managed:kimi-code', + model: displayName.toLowerCase().replaceAll(' ', '-'), + maxContextSize: 200_000, + displayName, + capabilities, + supportEfforts, + defaultEffort, + } as unknown as ModelAlias; +} + function text(component: ModelSelectorComponent, width = 120): string { return component.render(width).map(strip).join('\n'); } @@ -33,7 +50,7 @@ describe('ModelSelectorComponent', () => { const picker = new ModelSelectorComponent({ models: { kimi: model('Kimi K2') }, currentValue: 'kimi', - currentThinking: true, + currentThinkingEffort: 'on', onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -50,7 +67,7 @@ describe('ModelSelectorComponent', () => { const picker = new ModelSelectorComponent({ models: { kimi: model('Kimi K2', ['thinking']) }, currentValue: 'kimi', - currentThinking: true, + currentThinkingEffort: 'on', onSelect, onCancel: vi.fn(), }); @@ -58,24 +75,24 @@ describe('ModelSelectorComponent', () => { // "/" no longer toggles thinking (it used to); here it is simply ignored. picker.handleInput('/'); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: true }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'on' }); // Right arrow flips the draft (true -> false). picker.handleInput(RIGHT); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: false }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'off' }); // Left arrow flips it back. picker.handleInput(LEFT); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: true }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'on' }); }); it('shows the Left/Right thinking hint only for toggleable models', () => { const picker = new ModelSelectorComponent({ models: { kimi: model('Kimi K2', ['thinking']) }, currentValue: 'kimi', - currentThinking: false, + currentThinkingEffort: 'off', onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -90,7 +107,7 @@ describe('ModelSelectorComponent', () => { plain: model('Kimi Plain', ['tool_use']), }, currentValue: 'always', - currentThinking: false, + currentThinkingEffort: 'off', onSelect, onCancel: vi.fn(), }); @@ -101,7 +118,7 @@ describe('ModelSelectorComponent', () => { expect(alwaysOut).toContain('Off (Unsupported)'); expect(alwaysOut).not.toContain('Always on'); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'always', thinking: true }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'always', thinking: 'on' }); // Unsupported: Off selected, On greyed out — same style, mirrored. picker.handleInput(DOWN); @@ -110,7 +127,7 @@ describe('ModelSelectorComponent', () => { expect(plainOut).toContain('[ Off ]'); expect(plainOut).not.toContain('] unsupported'); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: false }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: 'off' }); }); it('ignores Left/Right on always-on and unsupported models', () => { @@ -121,26 +138,26 @@ describe('ModelSelectorComponent', () => { plain: model('Kimi Plain', ['tool_use']), }, currentValue: 'always', - currentThinking: true, + currentThinkingEffort: 'on', onSelect, onCancel: vi.fn(), }); picker.handleInput(RIGHT); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'always', thinking: true }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'always', thinking: 'on' }); picker.handleInput(DOWN); picker.handleInput(LEFT); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: false }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: 'off' }); }); it('renders the unavailable thinking segment muted', () => { const picker = new ModelSelectorComponent({ models: { always: model('Kimi Thinking', ['always_thinking']) }, currentValue: 'always', - currentThinking: true, + currentThinkingEffort: 'on', onSelect: vi.fn(), onCancel: vi.fn(), }); @@ -157,7 +174,7 @@ describe('ModelSelectorComponent', () => { thinking: model('Kimi Thinking', ['thinking']), }, currentValue: 'plain', - currentThinking: false, + currentThinkingEffort: 'off', onSelect, onCancel: vi.fn(), }); @@ -168,7 +185,7 @@ describe('ModelSelectorComponent', () => { picker.handleInput(DOWN); // -> thinking (the Off override persists) picker.handleInput('\r'); - expect(onSelect).toHaveBeenCalledWith({ alias: 'thinking', thinking: false }); + expect(onSelect).toHaveBeenCalledWith({ alias: 'thinking', thinking: 'off' }); }); it('defaults a thinking-capable model to On but keeps the current model state', () => { @@ -179,7 +196,7 @@ describe('ModelSelectorComponent', () => { other: model('Kimi Other', ['thinking']), }, currentValue: 'current', - currentThinking: false, // thinking deliberately off on the active model + currentThinkingEffort: 'off', // thinking deliberately off on the active model onSelect, onCancel: vi.fn(), }); @@ -190,7 +207,7 @@ describe('ModelSelectorComponent', () => { // A capable, non-active model defaults to On without any toggle. expect(text(picker)).toContain('[ On ]'); picker.handleInput('\r'); - expect(onSelect).toHaveBeenCalledWith({ alias: 'other', thinking: true }); + expect(onSelect).toHaveBeenCalledWith({ alias: 'other', thinking: 'on' }); }); it('fuzzy-filters by typing and reports a match count', () => { @@ -198,7 +215,7 @@ describe('ModelSelectorComponent', () => { const picker = new ModelSelectorComponent({ models: { k2: model('Kimi K2'), turbo: model('Kimi Turbo') }, currentValue: 'k2', - currentThinking: false, + currentThinkingEffort: 'off', searchable: true, onSelect: vi.fn(), onCancel, @@ -225,7 +242,7 @@ describe('ModelSelectorComponent', () => { const picker = new ModelSelectorComponent({ models, currentValue: 'm0', - currentThinking: false, + currentThinkingEffort: 'off', searchable: true, onSelect: vi.fn(), onCancel: vi.fn(), @@ -242,7 +259,7 @@ describe('ModelSelectorComponent', () => { cjk: model('超长的中文模型名称需要被正确截断处理'), }, currentValue: 'long', - currentThinking: false, + currentThinkingEffort: 'off', searchable: true, onSelect: vi.fn(), onCancel: vi.fn(), @@ -261,7 +278,7 @@ describe('ModelSelectorComponent', () => { const picker = new ModelSelectorComponent({ models: { kimi: model('Kimi K2', ['thinking']) }, currentValue: 'kimi', - currentThinking: true, + currentThinkingEffort: 'on', onSelect, onSessionOnlySelect, onCancel: vi.fn(), @@ -270,7 +287,7 @@ describe('ModelSelectorComponent', () => { // Toggle thinking Off, then Alt+S applies the choice to the session only. picker.handleInput(RIGHT); picker.handleInput(`${ESC}s`); - expect(onSessionOnlySelect).toHaveBeenCalledWith({ alias: 'kimi', thinking: false }); + expect(onSessionOnlySelect).toHaveBeenCalledWith({ alias: 'kimi', thinking: 'off' }); expect(onSelect).not.toHaveBeenCalled(); }); @@ -279,7 +296,7 @@ describe('ModelSelectorComponent', () => { const picker = new ModelSelectorComponent({ models: { kimi: model('Kimi K2') }, currentValue: 'kimi', - currentThinking: true, + currentThinkingEffort: 'on', onSelect, onCancel: vi.fn(), }); @@ -293,11 +310,115 @@ describe('ModelSelectorComponent', () => { const picker = new ModelSelectorComponent({ models: { kimi: model('Kimi K2') }, currentValue: 'kimi', - currentThinking: true, + currentThinkingEffort: 'on', onSelect: vi.fn(), onSessionOnlySelect: vi.fn(), onCancel: vi.fn(), }); expect(text(picker)).toContain('Alt+S session-only'); }); + + it('renders effort segments with the default effort highlighted', () => { + const picker = new ModelSelectorComponent({ + models: { kimi: effortModel('Kimi K2', ['low', 'high', 'max'], 'high') }, + currentValue: 'kimi', + currentThinkingEffort: 'high', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = text(picker); + // The default effort (high) is the active segment. + expect(out).toContain('[ High ]'); + // All declared efforts plus the Off entry are present. + expect(out).toContain('Low'); + expect(out).toContain('Max'); + expect(out).toContain('Off'); + // Multi-segment control advertises the switch hint. + expect(out).toContain('Thinking (←→ to switch)'); + }); + + it('cycles efforts with Left/Right and clamps at the ends', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { kimi: effortModel('Kimi K2', ['low', 'high', 'max'], 'high') }, + currentValue: 'kimi', + currentThinkingEffort: 'high', + onSelect, + onCancel: vi.fn(), + }); + + // high -> max (Right), then clamp on a second Right. + picker.handleInput(RIGHT); + picker.handleInput(RIGHT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'max' }); + + // max -> high -> low -> off (Left x3), then clamp on another Left. + picker.handleInput(LEFT); + picker.handleInput(LEFT); + picker.handleInput(LEFT); + picker.handleInput(LEFT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'off' }); + }); + + it('always-on effort models show an unsupported Off that cannot be selected', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + kimi: effortModel('Kimi K2', ['low', 'high', 'max'], 'high', ['always_thinking']), + }, + currentValue: 'kimi', + currentThinkingEffort: 'high', + onSelect, + onCancel: vi.fn(), + }); + + const raw = picker.render(120).join('\n'); + // Off is rendered muted as unavailable, not as a selectable segment. + expect(raw).toContain(currentTheme.fg('textMuted', ' Off (Unsupported) ')); + // The active effort is still highlighted. + expect(strip(raw)).toContain('[ High ]'); + + // Cycling clamps at the last effort and never reaches Off. + picker.handleInput(RIGHT); // high -> max + picker.handleInput(RIGHT); // clamp at max + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', thinking: 'max' }); + }); + + it('defaults an effort model without a current level to its defaultEffort', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + other: effortModel('Kimi Other', ['low', 'high', 'max'], 'max'), + }, + currentValue: 'current', + currentThinkingEffort: 'off', + onSelect, + onCancel: vi.fn(), + }); + + // Non-current effort model falls back to its declared defaultEffort. + expect(text(picker)).toContain('[ Max ]'); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ alias: 'other', thinking: 'max' }); + }); + + it('falls back to the middle effort when an effort model has no defaultEffort', () => { + const picker = new ModelSelectorComponent({ + models: { + other: effortModel('Kimi Other', ['low', 'medium', 'high']), + }, + currentValue: 'current', + currentThinkingEffort: 'off', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + // support_efforts present but default_effort absent -> default to the + // middle entry (medium), not a hardcoded level. + expect(text(picker)).toContain('[ Medium ]'); + }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts index bfd5ede21..28fc4210f 100644 --- a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts @@ -35,7 +35,7 @@ function make(): { gpt: model('GPT-5', 'openai'), }, currentValue: 'k2', - currentThinking: false, + currentThinkingEffort: 'off', onSelect, onCancel: vi.fn(), }); @@ -110,7 +110,7 @@ describe('TabbedModelSelectorComponent', () => { const { component, onSelect } = make(); component.handleInput(RIGHT); // toggle thinking on for k2 component.handleInput('\r'); - expect(onSelect).toHaveBeenCalledWith({ alias: 'k2', thinking: true }); + expect(onSelect).toHaveBeenCalledWith({ alias: 'k2', thinking: 'on' }); }); it('frames the tab strip with a blank line above and below it', () => { diff --git a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts index ca67aded7..5ce612466 100644 --- a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts @@ -14,7 +14,7 @@ describe('status panel report lines', () => { workDir: '/tmp/project', sessionId: 'ses-1', sessionTitle: 'Implement status', - thinking: true, + thinkingEffort: 'on', permissionMode: 'manual', planMode: false, contextUsage: 0.25, @@ -30,7 +30,7 @@ describe('status panel report lines', () => { }, status: { model: 'k2', - thinkingLevel: 'high', + thinkingEffort: 'high', permission: 'auto', planMode: true, contextTokens: 3000, @@ -75,7 +75,7 @@ describe('status panel report lines', () => { workDir: '/tmp/project', sessionId: '', sessionTitle: null, - thinking: false, + thinkingEffort: 'off', permissionMode: 'manual', planMode: false, contextUsage: 0, diff --git a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts index 62f75a8d0..101cd1911 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts @@ -16,7 +16,7 @@ function baseState(overrides: Partial = {}): AppState { sessionId: 'sess_1', permissionMode: 'manual', planMode: false, - thinking: false, + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 200_000, diff --git a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts index f8255cddf..2eebee5e3 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts @@ -26,7 +26,7 @@ function baseState(overrides: Partial = {}): AppState { sessionId: 'sess_1', permissionMode: 'manual', planMode: false, - thinking: false, + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 0, @@ -95,8 +95,8 @@ describe('FooterComponent — context NaN resilience', () => { }); it('shows "thinking" label when thinking is enabled, hides it when disabled', () => { - const on = new FooterComponent(baseState({ model: 'k2', thinking: true })); - const off = new FooterComponent(baseState({ model: 'k2', thinking: false })); + const on = new FooterComponent(baseState({ model: 'k2', thinkingEffort: 'on' })); + const off = new FooterComponent(baseState({ model: 'k2', thinkingEffort: 'off' })); expect(strip(on.render(120)[0]!)).toContain('thinking'); expect(strip(off.render(120)[0]!)).not.toContain('thinking'); diff --git a/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts b/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts index 365df3e4a..982be0657 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts @@ -17,7 +17,7 @@ function baseState(overrides: Partial = {}): AppState { sessionId: 'sess_1', permissionMode: 'manual', planMode: false, - thinking: false, + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 200_000, diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index 7c4cbcebc..0899cf070 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -14,7 +14,7 @@ function fakeInitialAppState(): AppState { planMode: false, inputMode: 'prompt', swarmMode: false, - thinking: false, + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 0, diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 9c02d4d11..b6b2fa745 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -150,7 +150,7 @@ function makeSession(overrides: Record = {}) { cancelCompaction: vi.fn(async () => {}), getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: false, contextTokens: 0, @@ -174,7 +174,7 @@ function makeSession(overrides: Record = {}) { main: { status: { model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: false, contextTokens: 0, @@ -912,7 +912,7 @@ command = "vim" const session = makeSession({ getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: true, contextTokens: 0, @@ -3441,7 +3441,7 @@ command = "vim" const session = makeSession({ getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'high', + thinkingEffort: 'high', permission: 'auto', planMode: true, contextTokens: 25, @@ -4121,7 +4121,7 @@ command = "vim" }, }, defaultModel: 'k2', - defaultThinking: false, + thinking: { enabled: false }, })), setConfig, }); @@ -4150,11 +4150,11 @@ command = "vim" expect(session.setThinking).toHaveBeenCalledWith('on'); expect(setConfig).toHaveBeenCalledWith({ defaultModel: 'turbo', - defaultThinking: true, + thinking: { enabled: true, effort: 'on' }, }); }); expect(driver.state.appState.model).toBe('turbo'); - expect(driver.state.appState.thinking).toBe(true); + expect(driver.state.appState.thinkingEffort).toBe('on'); }); it('applies /model selection to the session only on Alt+S without persisting', async () => { @@ -4179,7 +4179,7 @@ command = "vim" }, }, defaultModel: 'k2', - defaultThinking: false, + thinking: { enabled: false }, })), setConfig, }); @@ -4199,7 +4199,7 @@ command = "vim" }); expect(setConfig).not.toHaveBeenCalled(); expect(driver.state.appState.model).toBe('turbo'); - expect(driver.state.appState.thinking).toBe(true); + expect(driver.state.appState.thinkingEffort).toBe('on'); }); it('persists /model selection even when runtime state is unchanged', async () => { @@ -4217,7 +4217,7 @@ command = "vim" }, }, defaultModel: 'old-default', - defaultThinking: true, + thinking: { enabled: true }, })), setConfig, }); @@ -4233,7 +4233,7 @@ command = "vim" await vi.waitFor(() => { expect(setConfig).toHaveBeenCalledWith({ defaultModel: 'k2', - defaultThinking: false, + thinking: { enabled: false }, }); }); expect(session.setModel).not.toHaveBeenCalled(); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index a7c2719c7..deb6163c7 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -105,7 +105,7 @@ function makeSession(overrides: Record = {}) { summary: { title: 'Session title' }, getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: false, contextTokens: 10, @@ -165,7 +165,7 @@ function createResumeState(overrides: { permissionMode?: string; planMode?: bool config: { cwd: '/tmp/proj-a', modelCapabilities: { max_context_tokens: 100 }, - thinkingLevel: 'off', + thinkingEffort: 'off', systemPrompt: '', }, context: { history: [], tokenCount: 10 }, @@ -241,7 +241,7 @@ describe('KimiTUI startup', () => { const session = makeSession({ getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'yolo', planMode: true, contextTokens: 25, @@ -297,7 +297,7 @@ describe('KimiTUI startup', () => { id: 'ses-latest', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission, planMode: false, contextTokens: 10, @@ -325,7 +325,7 @@ describe('KimiTUI startup', () => { id: 'ses-latest', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission, planMode: false, contextTokens: 10, @@ -353,7 +353,7 @@ describe('KimiTUI startup', () => { id: 'ses-latest', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode, contextTokens: 10, @@ -380,7 +380,7 @@ describe('KimiTUI startup', () => { id: 'ses-latest', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: true, contextTokens: 10, @@ -407,7 +407,7 @@ describe('KimiTUI startup', () => { id: 'ses-latest', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: false, contextTokens: 10, @@ -432,7 +432,7 @@ describe('KimiTUI startup', () => { id: 'ses-latest', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: false, contextTokens: 10, @@ -498,7 +498,7 @@ describe('KimiTUI startup', () => { id: 'ses-target', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission, planMode: false, contextTokens: 10, @@ -592,7 +592,7 @@ describe('KimiTUI startup', () => { }), getStatus: vi.fn(async () => ({ model, - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: false, contextTokens: 10, @@ -631,7 +631,7 @@ describe('KimiTUI startup', () => { id: 'ses-picked', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission, planMode: false, contextTokens: 10, @@ -671,7 +671,7 @@ describe('KimiTUI startup', () => { id: 'ses-picked', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: true, contextTokens: 10, @@ -1129,7 +1129,7 @@ describe('KimiTUI startup', () => { expect(driver.state.appState).toMatchObject({ sessionId: '', model: '', - thinking: false, + thinkingEffort: 'off', contextTokens: 0, maxContextTokens: 0, contextUsage: 0, @@ -1141,7 +1141,7 @@ describe('KimiTUI startup', () => { const session = makeSession({ getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'yolo', planMode: true, contextTokens: 10, @@ -1156,7 +1156,7 @@ describe('KimiTUI startup', () => { const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ defaultModel: 'k2', - defaultThinking: false, + thinking: { enabled: false }, models: { k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, @@ -1201,7 +1201,7 @@ describe('KimiTUI startup', () => { const session = makeSession({ getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'auto', planMode: false, contextTokens: 10, @@ -1216,7 +1216,7 @@ describe('KimiTUI startup', () => { const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ defaultModel: 'k2', - defaultThinking: false, + thinking: { enabled: false }, models: { k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, @@ -1241,12 +1241,12 @@ describe('KimiTUI startup', () => { }); }); - it('syncs configured thinking after OAuth login refreshes an active session', async () => { + it('does not override active session thinking when configured thinking is enabled after OAuth login', async () => { const session = makeSession(); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ defaultModel: 'k2', - defaultThinking: true, + thinking: { enabled: true }, models: { k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, @@ -1255,16 +1255,18 @@ describe('KimiTUI startup', () => { const driver = makeDriver(harness, makeStartupInput()); await expect(driver.init()).resolves.toBe(false); - expect(driver.state.appState.thinking).toBe(false); + expect(driver.state.appState.thinkingEffort).toBe('off'); vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); await handleLoginCommand(driver as any); expect(session.setModel).toHaveBeenCalledWith('k2'); - expect(session.setThinking).toHaveBeenCalledWith('on'); + // `thinking.enabled === true` means "leave the session's current thinking + // level alone" — only an explicit `enabled === false` forces `'off'`. + expect(session.setThinking).not.toHaveBeenCalled(); expect(driver.state.appState).toMatchObject({ model: 'k2', - thinking: true, + thinkingEffort: 'off', maxContextTokens: 100, }); expect(harness.track).toHaveBeenCalledWith('login', { diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 7ffd6fdde..cfa7fae7c 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -150,7 +150,7 @@ function baseAgentState( tool_use: true, max_context_tokens: 100, }, - thinkingLevel: 'off', + thinkingEffort: 'off', systemPrompt: '', }, context: { history: [], tokenCount: 0 }, @@ -177,7 +177,7 @@ function makeSession( summary: { title: null }, getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: false, contextTokens: 0, diff --git a/apps/kimi-code/test/tui/utils/refresh-providers.test.ts b/apps/kimi-code/test/tui/utils/refresh-providers.test.ts index aadb8e764..ef78085c7 100644 --- a/apps/kimi-code/test/tui/utils/refresh-providers.test.ts +++ b/apps/kimi-code/test/tui/utils/refresh-providers.test.ts @@ -476,7 +476,7 @@ describe('refreshAllProviderModels', () => { }, }, defaultModel: 'my-b', - defaultThinking: true, + thinking: { enabled: true }, telemetry: true, } as unknown as KimiConfig); @@ -523,7 +523,7 @@ describe('refreshAllProviderModels', () => { expect(host.current().models?.['b/m1']).toBeUndefined(); expect(host.current().models?.['my-b']).toBeUndefined(); expect(host.current().defaultModel).toBeUndefined(); - expect(host.current().defaultThinking).toBeUndefined(); + expect(host.current().thinking).toBeUndefined(); }); it('coalesces duplicate custom-registry source URLs without reporting config-only changes', async () => { @@ -664,7 +664,7 @@ describe('refreshAllProviderModels', () => { [userAlias]: userAliasModel, }, defaultModel: userAlias, - defaultThinking: false, + thinking: { enabled: false }, telemetry: true, } as unknown as KimiConfig); @@ -709,7 +709,7 @@ describe('refreshAllProviderModels', () => { expect(host.setConfig).not.toHaveBeenCalled(); expect(host.current().models?.[userAlias]).toEqual(userAliasModel); expect(host.current().defaultModel).toBe(userAlias); - expect(host.current().defaultThinking).toBe(false); + expect(host.current().thinking?.enabled).toBe(false); }); it('forces default thinking on when the refreshed default model cannot disable thinking', async () => { @@ -730,7 +730,7 @@ describe('refreshAllProviderModels', () => { }, }, defaultModel: 'kimi-code/kimi-deep-coder', - defaultThinking: false, + thinking: { enabled: false }, telemetry: true, } as unknown as KimiConfig); @@ -766,6 +766,6 @@ describe('refreshAllProviderModels', () => { 'tool_use', ]); expect(host.current().defaultModel).toBe('kimi-code/kimi-deep-coder'); - expect(host.current().defaultThinking).toBe(true); + expect(host.current().thinking?.enabled).toBe(true); }); }); diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index 83e44541a..fee444e92 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -392,7 +392,7 @@ export class DaemonKimiWebApi implements KimiWebApi { ); return { model: data.model && data.model.length > 0 ? data.model : null, - thinkingLevel: data.thinking_level, + thinkingEffort: data.thinking_level, permission: data.permission, planMode: data.plan_mode === true, swarmMode: data.swarm_mode === true, diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index fe12d91b1..d07c10a4d 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -94,7 +94,7 @@ export interface AppSession { export interface AppSessionRuntimeStatus { /** Current model alias, or null if the daemon couldn't resolve it. */ model: string | null; - thinkingLevel: string; + thinkingEffort: string; permission: string; planMode: boolean; swarmMode: boolean; diff --git a/apps/vis/server/src/lib/context-projector.ts b/apps/vis/server/src/lib/context-projector.ts index fd7a376e6..9d089647e 100644 --- a/apps/vis/server/src/lib/context-projector.ts +++ b/apps/vis/server/src/lib/context-projector.ts @@ -29,7 +29,7 @@ export interface ConfigSnapshot { cwd?: string; modelAlias?: string; profileName?: string; - thinkingLevel?: string; + thinkingEffort?: string; systemPrompt?: string; } @@ -308,7 +308,7 @@ export function projectContext( if (upd.cwd !== undefined) config.cwd = upd.cwd; if (upd.modelAlias !== undefined) config.modelAlias = upd.modelAlias; if (upd.profileName !== undefined) config.profileName = upd.profileName; - if (upd.thinkingLevel !== undefined) config.thinkingLevel = upd.thinkingLevel; + if (upd.thinkingEffort !== undefined) config.thinkingEffort = upd.thinkingEffort; if (upd.systemPrompt !== undefined) config.systemPrompt = upd.systemPrompt; break; } diff --git a/apps/vis/web/src/components/wire/renderers.tsx b/apps/vis/web/src/components/wire/renderers.tsx index f81238dc6..85d6160a8 100644 --- a/apps/vis/web/src/components/wire/renderers.tsx +++ b/apps/vis/web/src/components/wire/renderers.tsx @@ -71,7 +71,7 @@ export const WIRE_RENDERERS: RendererMap = { if (r.profileName !== undefined) parts.push(`profile=${r.profileName}`); if (r.modelAlias !== undefined) parts.push(`model=${r.modelAlias}`); if (r.cwd !== undefined) parts.push(`cwd=${r.cwd}`); - if (r.thinkingLevel !== undefined) parts.push(`thinking=${r.thinkingLevel}`); + if (r.thinkingEffort !== undefined) parts.push(`thinking=${r.thinkingEffort}`); if (r.systemPrompt !== undefined) parts.push(`system(${r.systemPrompt.length}b)`); return { main: ( diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index a099b1dc1..fb879c11e 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -24,7 +24,6 @@ The following example covers the most commonly used configuration fields. You ca ```toml default_model = "kimi-code/kimi-for-coding" -default_thinking = true default_permission_mode = "manual" default_plan_mode = false merge_all_available_skills = true @@ -41,7 +40,8 @@ model = "kimi-for-coding" max_context_size = 262144 [thinking] -mode = "auto" +enabled = true +effort = "high" [loop_control] max_retries_per_step = 3 @@ -76,7 +76,6 @@ Fields in the config file fall into two categories: **top-level scalars** that d | Field | Type | Default | Description | | --- | --- | --- | --- | | `default_model` | `string` | — | Default model alias; must be defined in `models` | -| `default_thinking` | `boolean` | `false` | Whether new sessions enable Thinking (deep reasoning) mode by default; can be toggled from the model menu inside a session. Even when set to `true`, `[thinking].mode = "off"` will still force Thinking off | | `default_permission_mode` | `string` | `manual` | Default permission mode for new sessions; one of `manual` (prompt each time), `auto` (auto-approve read operations), or `yolo` (auto-approve everything) | | `default_plan_mode` | `boolean` | `false` | Whether new sessions start in Plan mode (produce a plan before executing) by default | | `merge_all_available_skills` | `boolean` | `true` | Whether to merge Agent Skills from all available directories | @@ -145,12 +144,23 @@ You can also switch models temporarily without touching the config file — by s ## `thinking` -`thinking` sets the global default behavior for Thinking mode. `mode = "off"` forces Thinking off even when the top-level `default_thinking = true`. +`thinking` sets the global default behavior for Thinking mode. | Field | Type | Default | Description | | --- | --- | --- | --- | -| `mode` | `string` | — | Trigger policy: `auto` (decided by the model), `on` (always on), `off` (force off) | -| `effort` | `string` | `high` | Thinking effort level: `low`, `medium`, `high`, `xhigh`, `max`; the levels actually available depend on the provider | +| `enabled` | `boolean` | `true` | Whether Thinking is enabled by default for new sessions; set to `false` to force Thinking off | +| `effort` | `string` | — | Thinking effort level (for example `low`, `medium`, `high`, `xhigh`, `max`); the levels actually available depend on the model's declared `support_efforts`, and unrecognized values are ignored by the provider | + +::: info Added +`enabled` was added in 1.0.0 as the single on/off switch for Thinking, replacing the top-level `default_thinking` field and `thinking.mode`. +::: + +### Deprecated fields + +The following fields were removed in 1.0.0: + +- `default_thinking` (top-level boolean) — replaced by `[thinking] enabled`. Migrate `default_thinking = true` to `enabled = true`, and `default_thinking = false` to `enabled = false`. +- `thinking.mode` (`auto` / `on` / `off`) — replaced by `[thinking] enabled`. `mode = "off"` becomes `enabled = false`; `mode = "on"` and `mode = "auto"` are equivalent to `enabled = true` (the default) and can be removed. ## `loop_control` diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index c0f84251c..10518832e 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -109,8 +109,6 @@ Complete variable list: | `KIMI_MODEL_DISPLAY_NAME` | No | Name shown in `/model` | Falls back to `KIMI_MODEL_NAME` | | `KIMI_MODEL_MAX_OUTPUT_SIZE` | No | Per-request output cap (`anthropic` only) | Model default | | `KIMI_MODEL_REASONING_KEY` | No | Reasoning field name override (`openai` only) | Auto-detected | -| `KIMI_MODEL_DEFAULT_THINKING` | No | Default Thinking toggle for new sessions | Follows global default | -| `KIMI_MODEL_THINKING_MODE` | No | Thinking trigger policy: `auto`/`on`/`off` | — | | `KIMI_MODEL_THINKING_EFFORT` | No | Thinking effort level: `low`/`medium`/`high`/`xhigh`/`max` | — | | `KIMI_MODEL_ADAPTIVE_THINKING` | No | Force adaptive thinking on or off (`anthropic` only) | Inferred from model name | diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index f9b84c58c..99a6b5db5 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -24,7 +24,6 @@ TOML 字段名一律用下划线(snake_case),如 `default_model`、`max_co ```toml default_model = "kimi-code/kimi-for-coding" -default_thinking = true default_permission_mode = "manual" default_plan_mode = false merge_all_available_skills = true @@ -41,7 +40,8 @@ model = "kimi-for-coding" max_context_size = 262144 [thinking] -mode = "auto" +enabled = true +effort = "high" [loop_control] max_retries_per_step = 3 @@ -76,7 +76,6 @@ timeout = 5 | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `default_model` | `string` | — | 默认模型别名,必须在 `models` 中定义 | -| `default_thinking` | `boolean` | `false` | 新会话是否默认开启 Thinking(深度推理)模式;可在会话内从模型菜单切换。即使设为 `true`,`[thinking].mode = "off"` 也会强制关闭 | | `default_permission_mode` | `string` | `manual` | 新会话的默认权限模式,可选 `manual`(逐次询问)、`auto`(自动批准读操作)、`yolo`(全部自动批准) | | `default_plan_mode` | `boolean` | `false` | 新会话是否默认以 Plan 模式(先出计划再执行)启动 | | `merge_all_available_skills` | `boolean` | `true` | 是否合并所有目录中的 Agent Skills | @@ -145,12 +144,23 @@ max_context_size = 1047576 ## `thinking` -`thinking` 设置 Thinking 模式的全局默认行为。`mode = "off"` 会强制关闭 Thinking,即使顶层 `default_thinking = true` 也不例外。 +`thinking` 设置 Thinking 模式的全局默认行为。 | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| `mode` | `string` | — | 触发策略:`auto`(由模型决定)、`on`(始终开启)、`off`(强制关闭) | -| `effort` | `string` | `high` | Thinking 强度:`low`、`medium`、`high`、`xhigh`、`max`,实际可用等级由供应商决定 | +| `enabled` | `boolean` | `true` | 新会话是否默认开启 Thinking,设为 `false` 可强制关闭 | +| `effort` | `string` | — | Thinking 强度(例如 `low`、`medium`、`high`、`xhigh`、`max`),实际可用等级取决于模型声明的 `support_efforts`,未识别的值会被供应商忽略 | + +::: info 新增 +`enabled` 新增于 1.0.0,作为 Thinking 的单一开关,取代顶层 `default_thinking` 字段和 `thinking.mode`。 +::: + +### 已废弃字段 + +以下字段已于 1.0.0 移除: + +- `default_thinking`(顶层布尔值)—— 由 `[thinking] enabled` 取代。将 `default_thinking = true` 迁移为 `enabled = true`,`default_thinking = false` 迁移为 `enabled = false`。 +- `thinking.mode`(`auto` / `on` / `off`)—— 由 `[thinking] enabled` 取代。`mode = "off"` 改为 `enabled = false`;`mode = "on"` 和 `mode = "auto"` 等价于 `enabled = true`(默认值),可删除该行。 ## `loop_control` diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 639f40d96..ddf76795a 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -109,8 +109,6 @@ kimi | `KIMI_MODEL_DISPLAY_NAME` | 否 | 在 `/model` 中显示的名称 | 回退到 `KIMI_MODEL_NAME` | | `KIMI_MODEL_MAX_OUTPUT_SIZE` | 否 | 单次输出上限(仅 `anthropic`) | 模型默认值 | | `KIMI_MODEL_REASONING_KEY` | 否 | 推理字段名覆盖(仅 `openai`) | 自动探测 | -| `KIMI_MODEL_DEFAULT_THINKING` | 否 | 新会话的默认 Thinking 开关 | 跟随全局默认 | -| `KIMI_MODEL_THINKING_MODE` | 否 | Thinking 触发策略:`auto`/`on`/`off` | — | | `KIMI_MODEL_THINKING_EFFORT` | 否 | Thinking 强度:`low`/`medium`/`high`/`xhigh`/`max` | — | | `KIMI_MODEL_ADAPTIVE_THINKING` | 否 | 强制开启或关闭 adaptive thinking(仅 `anthropic`) | 按模型名推断 | diff --git a/packages/acp-adapter/src/config-options.ts b/packages/acp-adapter/src/config-options.ts index a73077751..7f6088d83 100644 --- a/packages/acp-adapter/src/config-options.ts +++ b/packages/acp-adapter/src/config-options.ts @@ -21,8 +21,9 @@ * only knows how to draw `type: 'select'` options, and the spec's * `boolean` arm shows up as "Unknown". Effort granularity * (`'low' | 'medium' | …`) is still hidden behind the adapter — - * kimi-code uses a single non-`'off'` level under the hood (default - * `'high'`, resolved by agent-core's `resolveThinkingEffort`). + * kimi-code uses a single non-`'off'` level under the hood (the + * model's default effort, resolved by agent-core's + * `resolveThinkingEffort`). * - `id: 'mode'` (`type: 'select'`, `category: 'mode'`) — the * locked 4-mode taxonomy from PLAN D9 ({@link ACP_MODES}). * diff --git a/packages/acp-adapter/src/model-catalog.ts b/packages/acp-adapter/src/model-catalog.ts index e126362b4..63c3c30e5 100644 --- a/packages/acp-adapter/src/model-catalog.ts +++ b/packages/acp-adapter/src/model-catalog.ts @@ -37,6 +37,13 @@ export interface AcpModelEntry { readonly thinkingSupported: boolean; /** Declared 'always_thinking' capability — thinking cannot be turned off. */ readonly alwaysThinking?: boolean; + /** + * The thinking effort to send when the binary ACP toggle flips on: the + * model's declared `default_effort`, else the middle `support_efforts` + * entry, else `'on'` for boolean models. Mirrors agent-core's + * `defaultThinkingEffortFor` so the ACP on-state matches the TUI. + */ + readonly defaultThinkingEffort: string; } /** @@ -67,6 +74,19 @@ export function deriveAlwaysThinking(alias: ModelAlias): boolean { return (alias.capabilities ?? []).includes('always_thinking'); } +/** + * The effort a boolean "thinking on" toggle maps to for this model: declared + * `default_effort`, else the middle `support_efforts` entry, else `'on'` for + * boolean models (no `support_efforts`). + */ +export function deriveDefaultThinkingEffort(alias: ModelAlias): string { + const efforts = alias.supportEfforts; + if (efforts !== undefined && efforts.length > 0) { + return alias.defaultEffort ?? efforts[Math.floor(efforts.length / 2)]!; + } + return 'on'; +} + /** * Project `harness.getConfig().models` into a flat catalog. Returns an * empty array when the harness has no models configured, when @@ -94,6 +114,7 @@ export async function listModelsFromHarness( name: alias.displayName ?? alias.model ?? id, thinkingSupported: deriveThinkingSupported(alias), alwaysThinking: deriveAlwaysThinking(alias), + defaultThinkingEffort: deriveDefaultThinkingEffort(alias), }); } return out; diff --git a/packages/acp-adapter/src/server.ts b/packages/acp-adapter/src/server.ts index 607e7ef6e..a43fe5ebb 100644 --- a/packages/acp-adapter/src/server.ts +++ b/packages/acp-adapter/src/server.ts @@ -489,16 +489,16 @@ export class AcpServer implements Agent { typeof resumedModelAlias === 'string' && resumedModelAlias.length > 0 ? resumedModelAlias : await this.resolveCurrentModelId(); - // Phase 15 reads the resumed thinking level off the main-agent + // Phase 15 reads the resumed thinking effort off the main-agent // config and projects it onto the binary toggle: any non-`'off'` - // effort level reads as "thinking on" because the ACP surface only + // effort reads as "thinking on" because the ACP surface only // exposes the boolean axis. Falls back to the harness-level default // when the resume state lacks the field. - const resumedThinkingLevel = resumeState?.agents?.['main']?.config?.thinkingLevel; + const resumedThinkingEffort = resumeState?.agents?.['main']?.config?.thinkingEffort; const currentThinkingEnabled = - typeof resumedThinkingLevel === 'string' - ? resumedThinkingLevel.trim().toLowerCase() !== 'off' && - resumedThinkingLevel.trim().length > 0 + typeof resumedThinkingEffort === 'string' + ? resumedThinkingEffort.trim().toLowerCase() !== 'off' && + resumedThinkingEffort.trim().length > 0 : await this.resolveCurrentThinkingEnabled(); const acpSession = new AcpSession( this.conn, @@ -826,7 +826,7 @@ export class AcpServer implements Agent { /** * Compute the initial value for the `thinking` toggle when * a session is created (or loaded with no persisted thinking state). - * Reads the harness's `getConfig().defaultThinking` flag if exposed — + * Reads the harness's `getConfig().thinking.enabled` flag if exposed — * the same source `Session.createSession` would consult for new * sessions. Returns `false` when the harness has no opinion, so the * toggle starts off. @@ -840,12 +840,14 @@ export class AcpServer implements Agent { if (typeof this.harness.getConfig !== 'function') return false; try { const config = await this.harness.getConfig(); - const declared = (config as { defaultThinking?: unknown }).defaultThinking; - if (typeof declared === 'boolean') return declared; - if (typeof declared === 'string') { - const normalized = declared.trim().toLowerCase(); - return normalized !== 'off' && normalized.length > 0; - } + const thinking = (config as { thinking?: { enabled?: unknown; effort?: unknown } }) + .thinking; + if (typeof thinking?.enabled === 'boolean') return thinking.enabled; + // A non-empty effort with no explicit enabled flag still means thinking + // is on — agent-core's resolveThinkingEffort treats config.effort as + // enabled unless enabled === false, so mirror that here to keep the + // toggle consistent with the runtime. + if (typeof thinking?.effort === 'string' && thinking.effort.length > 0) return true; return false; } catch (err) { log.warn('acp: harness.getConfig threw during thinking toggle resolution; defaulting to off', { diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index 2a8aa5e0a..8031c712e 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -116,7 +116,7 @@ export class AcpSession { * `${id},thinking` form (legacy `unstable_setSessionModel` * compatibility). * - * Maps to the SDK's effort-level string at the boundary: + * Maps to the SDK's effort string at the boundary: * `true` → `'high'` (the typical default for kimi-code), `false` * → `'off'`. The granularity of `'low' | 'medium' | 'xhigh' | 'max'` * is intentionally not surfaced — the ACP `thinking` axis is binary @@ -199,7 +199,7 @@ export class AcpSession { * Initial value of the adapter-side thinking-toggle state, supplied * by the server when creating / loading the session. Phase 15 * introduces this so resumed sessions whose persisted - * `thinkingLevel` was non-`'off'` start with the toggle on. + * `thinkingEffort` was non-`'off'` start with the toggle on. * Defaults to `false` when absent. */ initialThinkingEnabled?: boolean, @@ -312,8 +312,8 @@ export class AcpSession { * * Wire semantics: * - `'kimi-v2'` → setModel('kimi-v2'); thinking state unchanged. - * - `'kimi-v2,thinking'` → setModel('kimi-v2') + setThinking('high'); - * thinking state flips on. + * - `'kimi-v2,thinking'` → setModel('kimi-v2') + setThinking(); thinking state flips on. * * Note the asymmetry: a bare model id does NOT turn thinking OFF. * That keeps the model / thinking axes orthogonal — model changes @@ -337,7 +337,7 @@ export class AcpSession { const baseKey = hasSuffix ? modelId.slice(0, -suffix.length) : modelId; await this.session.setModel(baseKey); if (hasSuffix && typeof this.session.setThinking === 'function') { - await this.session.setThinking(THINKING_ON_LEVEL); + await this.session.setThinking(await this.thinkingOnEffort()); this.currentThinkingEnabledInternal = true; } this.currentModelIdInternal = baseKey; @@ -348,10 +348,9 @@ export class AcpSession { * Forward an ACP thinking-toggle change to the underlying SDK. * * Phase 15 introduces this as the new canonical channel for the - * thinking axis. Boolean → effort-level mapping: - * - `true` → `Session.setThinking('high')` (kimi-code's typical - * default; the agent-core `resolveThinkingEffort` would also - * coerce a missing config to `'high'`). + * thinking axis. Boolean → thinking-effort mapping: + * - `true` → `Session.setThinking(effort)` where `effort` is the + * current model's default effort (see {@link thinkingOnEffort}). * - `false` → `Session.setThinking('off')`. * * Tolerant to partial-stub `Session` instances (adapter-level unit @@ -366,31 +365,26 @@ export class AcpSession { * carries a fresh snapshot. */ async setThinking(enabled: boolean): Promise { - if (!enabled && (await this.currentModelAlwaysThinking())) { - // The current model cannot disable thinking (declared - // 'always_thinking'); silently ignore the off request — agent-core - // clamps the runtime the same way — but still refresh the snapshot - // so a stale client toggle snaps back to on. - this.currentThinkingEnabledInternal = true; - await this.emitConfigOptionUpdate(); - return; - } if (typeof this.session.setThinking === 'function') { - await this.session.setThinking(enabled ? THINKING_ON_LEVEL : THINKING_OFF_LEVEL); + const effort = enabled ? await this.thinkingOnEffort() : THINKING_OFF_EFFORT; + await this.session.setThinking(effort); } this.currentThinkingEnabledInternal = enabled; await this.emitConfigOptionUpdate(); } /** - * Whether the currently-selected model declares 'always_thinking'. - * Harness-less adapter unit tests resolve to false — the agent-core - * runtime clamp still protects the actual request in that case. + * The effort to send when the ACP thinking toggle flips on: the current + * model's declared default effort (or middle `support_efforts`), falling + * back to `'on'` for boolean models or when the catalog is unavailable + * (harness-less unit tests). The `always_thinking` constraint is enforced + * downstream by agent-core's resolve, so this adapter no longer clamps an + * explicit off request here. */ - private async currentModelAlwaysThinking(): Promise { - if (!this.harness) return false; + private async thinkingOnEffort(): Promise { + if (!this.harness) return 'on'; const models = await listModelsFromHarness(this.harness); - return models.find((m) => m.id === this.currentModelIdInternal)?.alwaysThinking === true; + return models.find((m) => m.id === this.currentModelIdInternal)?.defaultThinkingEffort ?? 'on'; } /** @@ -1390,7 +1384,7 @@ function formatStatusReport(status: SessionStatus): string { return [ 'Session status:', `- Model: ${status.model ?? '(not set)'}`, - `- Thinking: ${status.thinkingLevel}`, + `- Thinking: ${status.thinkingEffort}`, `- Permission: ${status.permission}`, `- Plan mode: ${status.planMode ? 'on' : 'off'}`, `- Context: ${status.contextTokens.toLocaleString('en-US')} / ${maxTokens}${usage}`, @@ -1553,17 +1547,12 @@ function authRequiredFromUnknown(err: unknown): RequestError | undefined { } /** - * Effort-level strings passed to {@link Session.setThinking} when the - * ACP `thinking` toggle flips. Phase 15 wired the ACP-side binary axis - * (then a `SessionConfigBoolean`; Phase 16 reshaped it to a 2-entry - * `select` `off` / `on` for Zed UI compatibility) to the SDK's - * effort-level channel: `true` → `'high'` (kimi-code's typical default, - * also `resolveThinkingEffort`'s fallback), `false` → `'off'`. The - * granularity of `'low' | 'medium' | 'xhigh' | 'max'` is intentionally - * not exposed — the ACP `thinking` axis is binary. + * Effort string passed to {@link Session.setThinking} when the ACP `thinking` + * toggle flips off. The on-state effort is resolved per-model via + * {@link AcpSession.thinkingOnEffort} (declared default effort / middle + * `support_efforts` / `'on'`), so only the off sentinel is a constant here. */ -const THINKING_ON_LEVEL = 'high'; -const THINKING_OFF_LEVEL = 'off'; +const THINKING_OFF_EFFORT = 'off'; /** * Identifier the agent-core session emits for the main (user-facing) diff --git a/packages/acp-adapter/test/config-options.test.ts b/packages/acp-adapter/test/config-options.test.ts index 6aa1d6318..acd0110d6 100644 --- a/packages/acp-adapter/test/config-options.test.ts +++ b/packages/acp-adapter/test/config-options.test.ts @@ -32,8 +32,8 @@ function makeHarnessWithModels( describe('buildModelOption', () => { it('emits exactly one option per catalog row (Phase 15: no inlined `,thinking` variant rows)', () => { const models: readonly AcpModelEntry[] = [ - { id: 'alpha', name: 'Alpha', thinkingSupported: true }, - { id: 'beta', name: 'Beta', thinkingSupported: false }, + { id: 'alpha', name: 'Alpha', thinkingSupported: true, defaultThinkingEffort: 'on' }, + { id: 'beta', name: 'Beta', thinkingSupported: false, defaultThinkingEffort: 'on' }, ]; const option = buildModelOption(models, 'alpha'); @@ -57,7 +57,7 @@ describe('buildModelOption', () => { it('treats `currentValue` as the bare base model id — Phase 15 keeps the snapshot suffix-free', () => { const models: readonly AcpModelEntry[] = [ - { id: 'kimi-v2', name: 'Kimi v2', thinkingSupported: true }, + { id: 'kimi-v2', name: 'Kimi v2', thinkingSupported: true, defaultThinkingEffort: 'on' }, ]; const option = buildModelOption(models, 'kimi-v2'); diff --git a/packages/acp-adapter/test/session-control.test.ts b/packages/acp-adapter/test/session-control.test.ts index 16458faeb..046901b55 100644 --- a/packages/acp-adapter/test/session-control.test.ts +++ b/packages/acp-adapter/test/session-control.test.ts @@ -104,8 +104,8 @@ function makeFakeSession( setModel: async (model: string) => { setModelCalls.push(model); }, - setThinking: async (level: string) => { - setThinkingCalls.push(level); + setThinking: async (effort: string) => { + setThinkingCalls.push(effort); }, } as unknown as Session; return { session, planModeCalls, setPermissionCalls, setModelCalls, setThinkingCalls }; @@ -263,7 +263,7 @@ describe('AcpServer session/unstable_setSessionModel', () => { } }); - it('splits a `,thinking` suffix into a bare setModel + setThinking("high") call; snapshot model carries the base id', async () => { + it('splits a `,thinking` suffix into a bare setModel + setThinking() call; snapshot model carries the base id', async () => { const handle = makeFakeSession('sess-model-thinking'); // This test needs a thinking-supported catalog row so the snapshot // includes the toggle (otherwise it would be omitted). @@ -285,11 +285,12 @@ describe('AcpServer session/unstable_setSessionModel', () => { modelId: 'kimi-v2-something,thinking', }); - // SDK receives the bare model key for setModel and `'high'` for - // setThinking — Phase 15 routes thinking through the dedicated SDK - // channel instead of dropping the suffix on the floor. + // SDK receives the bare model key for setModel and the model's default + // thinking effort for setThinking — Phase 15 routes thinking through the + // dedicated SDK channel instead of dropping the suffix on the floor. This + // fixture declares no support_efforts, so the default effort is 'on'. expect(handle.setModelCalls).toEqual(['kimi-v2-something']); - expect(handle.setThinkingCalls).toEqual(['high']); + expect(handle.setThinkingCalls).toEqual(['on']); // The model picker's currentValue is the bare id — thinking lives // on its own boolean toggle, and the snapshot reflects that. diff --git a/packages/acp-adapter/test/session-resume.test.ts b/packages/acp-adapter/test/session-resume.test.ts index 34b92783a..2b4640ece 100644 --- a/packages/acp-adapter/test/session-resume.test.ts +++ b/packages/acp-adapter/test/session-resume.test.ts @@ -62,14 +62,14 @@ function makeInMemoryStreamPair(): { /** * Build a fake {@link Session} whose `getResumeState` reports the given * main-agent config so the server's resume-state projection (modelAlias - * → currentModelId, thinkingLevel → currentThinkingEnabled) gets a + * → currentModelId, thinkingEffort → currentThinkingEnabled) gets a * deterministic input. History is empty because `resumeSession` does * not replay anyway — the field is kept for API parity with the * matching session-load helper. */ function makeSessionWithMainConfig( sessionId: string, - mainConfig?: { modelAlias?: string; thinkingLevel?: string }, + mainConfig?: { modelAlias?: string; thinkingEffort?: string }, ): Session { return { id: sessionId, @@ -143,7 +143,7 @@ describe('AcpServer.resumeSession', () => { const sessionId = 'sess-resume-model'; // Resume state reports kimi-plain (thinking unsupported) so we can // assert the projection picks the alias from main-agent config and - // that thinking flips to `on` because `thinkingLevel='high'` is + // that thinking flips to `on` because `thinkingEffort='high'` is // non-`off` per the server's boolean projection. The mode currentValue // is always `default` because mode is session-scoped (PLAN D9). // @@ -151,7 +151,7 @@ describe('AcpServer.resumeSession', () => { // would suppress it via `thinkingSupported: false`). const session = makeSessionWithMainConfig(sessionId, { modelAlias: 'kimi-coder', - thinkingLevel: 'high', + thinkingEffort: 'high', }); const harness = makeHarness({ hasUsableToken: true, session }); @@ -179,7 +179,7 @@ describe('AcpServer.resumeSession', () => { expect(modelOpt!.currentValue).toBe('kimi-coder'); if (thinkingOpt!.type !== 'select') throw new Error('thinking option must be a select'); - // `thinkingLevel='high'` → boolean projection picks the `on` slot. + // `thinkingEffort='high'` → boolean projection picks the `on` slot. expect(thinkingOpt!.currentValue).toBe('on'); if (modeOpt!.type !== 'select') throw new Error('mode option must be a select'); diff --git a/packages/acp-adapter/test/session-slash.test.ts b/packages/acp-adapter/test/session-slash.test.ts index 1bf5a414f..f13dea0b1 100644 --- a/packages/acp-adapter/test/session-slash.test.ts +++ b/packages/acp-adapter/test/session-slash.test.ts @@ -338,7 +338,7 @@ describe('AcpSession slash routing', () => { // reads from it; we don't need the rest of the SDK surface here. (session as unknown as { getStatus: () => Promise }).getStatus = async () => ({ model: 'mock-model', - thinkingLevel: 'low', + thinkingEffort: 'low', permission: 'ask', planMode: false, contextTokens: 1234, diff --git a/packages/acp-adapter/test/set-session-config-option.test.ts b/packages/acp-adapter/test/set-session-config-option.test.ts index a037808ed..d47831e2e 100644 --- a/packages/acp-adapter/test/set-session-config-option.test.ts +++ b/packages/acp-adapter/test/set-session-config-option.test.ts @@ -79,8 +79,8 @@ function makeFakeSession(sessionId: string): FakeSessionHandle { setModel: async (model: string) => { setModelCalls.push(model); }, - setThinking: async (level: string) => { - setThinkingCalls.push(level); + setThinking: async (effort: string) => { + setThinkingCalls.push(effort); }, } as unknown as Session; return { session, planModeCalls, setPermissionCalls, setModelCalls, setThinkingCalls }; @@ -152,7 +152,7 @@ describe('AcpServer session/set_config_option', () => { } }); - it('configId="model" + `${id},thinking` → SDK gets stripped id + setThinking("high") + snapshot shows base id with thinking toggle on', async () => { + it('configId="model" + `${id},thinking` → SDK gets stripped id + setThinking() + snapshot shows base id with thinking toggle on', async () => { const handle = makeFakeSession('sess-model-thinking'); const harness = makeHarness(handle); const { client, capturing, sessionId } = await openSession(harness); @@ -165,7 +165,7 @@ describe('AcpServer session/set_config_option', () => { }); expect(handle.setModelCalls).toEqual(['kimi-coder']); - expect(handle.setThinkingCalls).toEqual(['high']); + expect(handle.setThinkingCalls).toEqual(['on']); const respModel = response.configOptions.find((o) => o.id === 'model'); if (respModel && respModel.type === 'select') { // Snapshot now carries the bare model id; thinking lives on a separate axis. @@ -179,7 +179,7 @@ describe('AcpServer session/set_config_option', () => { expect(respThinking.category).toBe('thought_level'); }); - it('configId="thinking" + "on" → setThinking("high") + 1 config_option_update with currentValue="on"', async () => { + it('configId="thinking" + "on" → setThinking() + 1 config_option_update with currentValue="on"', async () => { const handle = makeFakeSession('sess-thinking-on'); const harness = makeHarness(handle); const { client, capturing, sessionId } = await openSession(harness); @@ -191,7 +191,7 @@ describe('AcpServer session/set_config_option', () => { value: 'on', }); - expect(handle.setThinkingCalls).toEqual(['high']); + expect(handle.setThinkingCalls).toEqual(['on']); expect(handle.setModelCalls).toEqual([]); const updates = capturing.notifications.filter( (n) => n.sessionId === sessionId && n.update.sessionUpdate === 'config_option_update', @@ -226,7 +226,7 @@ describe('AcpServer session/set_config_option', () => { expect(respToggle.currentValue).toBe('off'); }); - it('configId="thinking" + "off" on an always-thinking model → no SDK call, toggle stays locked on', async () => { + it('configId="thinking" + "off" on an always-thinking model → forwards setThinking("off"); snapshot stays locked on', async () => { const handle = makeFakeSession('sess-thinking-locked'); const harness = { auth: { status: async () => AUTHED_STATUS }, @@ -248,9 +248,10 @@ describe('AcpServer session/set_config_option', () => { value: 'off', }); - // The off request is silently ignored — the runtime cannot disable - // thinking on this model, so no SDK call is forwarded. - expect(handle.setThinkingCalls).toEqual([]); + // The adapter forwards the off request to the SDK; the always_thinking + // constraint is enforced downstream by agent-core's resolve (which clamps + // it back to the model default). The snapshot still renders locked-on. + expect(handle.setThinkingCalls).toEqual(['off']); const respToggle = response.configOptions.find((o) => o.id === 'thinking'); if (!respToggle || respToggle.type !== 'select') throw new Error('expected select toggle'); expect(respToggle.currentValue).toBe('on'); diff --git a/packages/agent-core/src/agent/compaction/full.ts b/packages/agent-core/src/agent/compaction/full.ts index ef5c4206b..a42127828 100644 --- a/packages/agent-core/src/agent/compaction/full.ts +++ b/packages/agent-core/src/agent/compaction/full.ts @@ -420,7 +420,7 @@ export class FullCompaction { compactedCount: result.compactedCount, retryCount, round, - thinkingLevel: this.agent.config.thinkingLevel, + thinkingEffort: this.agent.config.thinkingEffort, ...usage, ...data, }); @@ -434,7 +434,7 @@ export class FullCompaction { duration_ms: Date.now() - startedAt, round, retryCount, - thinkingLevel: this.agent.config.thinkingLevel, + thinkingEffort: this.agent.config.thinkingEffort, errorType: error instanceof Error ? error.name : 'Unknown', }); if (isKimiError(error) && error.code === ErrorCodes.AUTH_LOGIN_REQUIRED) throw error; diff --git a/packages/agent-core/src/agent/compaction/micro.ts b/packages/agent-core/src/agent/compaction/micro.ts index 912812f60..6f94924f2 100644 --- a/packages/agent-core/src/agent/compaction/micro.ts +++ b/packages/agent-core/src/agent/compaction/micro.ts @@ -90,7 +90,7 @@ export class MicroCompaction { cutoff: nextCutoff, message_count: history.length, cache_age_ms: cacheAgeMs, - thinkingLevel: this.agent.config.thinkingLevel, + thinkingEffort: this.agent.config.thinkingEffort, }); } } diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts index 8fd96838c..990f17232 100644 --- a/packages/agent-core/src/agent/config/index.ts +++ b/packages/agent-core/src/agent/config/index.ts @@ -12,6 +12,7 @@ import type { Agent } from '..'; import { ErrorCodes, KimiError } from '../../errors'; import type { AgentConfigData, AgentConfigUpdateData } from './types'; import { resolveThinkingEffort, type ThinkingEffort } from './thinking'; +import type { ModelAlias } from '../../config/schema'; import type { ResolvedRuntimeProvider } from '../../session/provider-manager'; export * from './types'; @@ -21,7 +22,7 @@ export class ConfigState { private _cwd: string; private _modelAlias: string | undefined; private _profileName: string | undefined; - private _thinkingLevel: ThinkingEffort = 'off'; + private _thinkingEffort: ThinkingEffort = 'off'; private _systemPrompt: string = ''; constructor(protected readonly agent: Agent) { @@ -50,10 +51,23 @@ export class ConfigState { if (changed.profileName) { this._profileName = changed.profileName; } - if (changed.thinkingLevel !== undefined) { - this._thinkingLevel = resolveThinkingEffort( - changed.thinkingLevel, + if (changed.thinkingEffort !== undefined) { + // Resolve through the single source of truth so the always_thinking + // clamp and any future normalization apply uniformly — whether the + // level comes from createSession, setThinking RPC, or subagent + // inheritance. + this._thinkingEffort = resolveThinkingEffort( + changed.thinkingEffort, this.agent.kimiConfig?.thinking, + this.currentModel, + ); + } else if (changed.modelAlias !== undefined) { + // Re-apply the always_thinking clamp against the new model so a stale + // 'off' cannot survive a switch onto an always-thinking alias. + this._thinkingEffort = resolveThinkingEffort( + this._thinkingEffort, + this.agent.kimiConfig?.thinking, + this.currentModel, ); } if (changed.systemPrompt !== undefined) { @@ -73,7 +87,7 @@ export class ConfigState { modelAlias: this._modelAlias, modelCapabilities: resolved?.modelCapabilities ?? UNKNOWN_CAPABILITY, profileName: this.profileName, - thinkingLevel: this.thinkingLevel, + thinkingEffort: this.thinkingEffort, systemPrompt: this.systemPrompt, }; } @@ -104,8 +118,8 @@ export class ConfigState { // - withThinking: preserve thinking during compaction (#464) // - sampling params: KIMI_MODEL_TEMPERATURE / KIMI_MODEL_TOP_P // - thinking.keep: KIMI_MODEL_THINKING_KEEP (only while thinking is on) - const provider = createProvider(this.providerConfig).withThinking(this.thinkingLevel); - return applyKimiEnvThinkingKeep(applyKimiEnvSamplingParams(provider), this.thinkingLevel); + const provider = createProvider(this.providerConfig).withThinking(this.thinkingEffort); + return applyKimiEnvThinkingKeep(applyKimiEnvSamplingParams(provider), this.thinkingEffort); } get model(): string { @@ -119,19 +133,16 @@ export class ConfigState { return this._modelAlias; } - get thinkingLevel(): ThinkingEffort { - // Always-thinking models cannot run with thinking disabled. Clamping in - // the getter (rather than in update()) keeps the request builder, status - // events, and subagent inheritance consistent, and re-applies after a - // later model switch onto an always-thinking alias. - if (this._thinkingLevel === 'off' && this.alwaysThinkingModel) { - return resolveThinkingEffort('on', this.agent.kimiConfig?.thinking); - } - return this._thinkingLevel; + get thinkingEffort(): ThinkingEffort { + // Already resolved (with the always_thinking clamp applied) in update(); + // return it verbatim. + return this._thinkingEffort; } - private get alwaysThinkingModel(): boolean { - return this.tryResolvedProviderConfig()?.alwaysThinking === true; + private get currentModel(): ModelAlias | undefined { + const alias = this._modelAlias; + if (alias === undefined) return undefined; + return this.agent.kimiConfig?.models?.[alias]; } get profileName(): string | undefined { diff --git a/packages/agent-core/src/agent/config/thinking.ts b/packages/agent-core/src/agent/config/thinking.ts index 1e206cd19..4a4ddad70 100644 --- a/packages/agent-core/src/agent/config/thinking.ts +++ b/packages/agent-core/src/agent/config/thinking.ts @@ -1,50 +1,75 @@ import type { ThinkingEffort } from '@moonshot-ai/kosong'; -import type { ThinkingConfig } from '../../config/schema'; +import type { ModelAlias, ThinkingConfig } from '../../config/schema'; export type { ThinkingEffort }; -const DEFAULT_THINKING_EFFORT: ThinkingEffort = 'high'; - -const THINKING_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']); +function supportsThinking(model: ModelAlias | undefined): boolean { + if (model === undefined) return false; + const caps = model.capabilities ?? []; + return ( + caps.includes('thinking') || + caps.includes('always_thinking') || + model.adaptiveThinking === true + ); +} -export interface ResolveThinkingLevelOptions { - readonly defaultThinking?: boolean | undefined; - readonly thinking?: ThinkingConfig | undefined; +function middleOf(efforts: readonly string[]): string { + return efforts[Math.floor(efforts.length / 2)]!; } -export function resolveThinkingLevel( - requestedThinking: string | undefined, - options: ResolveThinkingLevelOptions, -): ThinkingEffort { - const resolvedRequest = - requestedThinking !== undefined && requestedThinking.trim().length > 0 - ? requestedThinking - : options.defaultThinking === false - ? 'off' - : undefined; - - return resolveThinkingEffort(resolvedRequest, options.thinking); +/** + * Resolve the default thinking effort for a model from its declared metadata: + * - models that do not support thinking (or an unknown model) -> `'off'` + * - effort-capable models -> `default_effort`, else the middle entry of + * `support_efforts` (so we never pick an effort the model does not support) + * - boolean models (thinking support without `support_efforts`) -> `'on'` + * + * `support_efforts` is the single source of truth for efforts; the returned + * effort is always one the model can actually accept. + */ +export function defaultThinkingEffortFor(model: ModelAlias | undefined): ThinkingEffort { + if (!supportsThinking(model)) return 'off'; + const efforts = model?.supportEfforts; + if (efforts !== undefined && efforts.length > 0) { + return model?.defaultEffort ?? middleOf(efforts); + } + return 'on'; } +/** + * Resolve the effective thinking effort for a session. + * + * Precedence: + * 1. an explicit `requested` effort (per-session override) wins; + * 2. `thinking.enabled === false` forces `'off'`; + * 3. otherwise `thinking.effort` when set, else the model's default effort. + * + * The `always_thinking` constraint is enforced here and only here: when a + * model declares `always_thinking`, an `'off'` result is clamped back to the + * model's default effort so thinking can never be disabled for it. + */ export function resolveThinkingEffort( - requested: string | undefined, - defaults: ThinkingConfig | undefined, + requested: ThinkingEffort | undefined, + config: ThinkingConfig | undefined, + model: ModelAlias | undefined, ): ThinkingEffort { - const configEffort = parseEffort(defaults?.effort) ?? DEFAULT_THINKING_EFFORT; - const normalized = requested?.trim().toLowerCase(); - if (!normalized) { - if (defaults?.mode === 'off') return 'off'; - return configEffort; + let effort: ThinkingEffort; + if (requested !== undefined) { + effort = requested; + } else if (config?.enabled === false) { + effort = 'off'; + } else { + effort = config?.effort ?? defaultThinkingEffortFor(model); + } + + if (effort === 'off' && model?.capabilities?.includes('always_thinking') === true) { + // always_thinking forces thinking on, but an explicitly configured effort + // is still honored — `enabled = false` only expresses the intent to + // disable, it should not also discard a chosen effort. Fall back to the + // model default only when no effort is configured. + effort = config?.effort ?? defaultThinkingEffortFor(model); } - if (normalized === 'off') return 'off'; - if (normalized === 'on') return configEffort; - return parseEffort(normalized) ?? configEffort; -} -function parseEffort(value: string | undefined): ThinkingEffort | undefined { - const normalized = value?.trim().toLowerCase(); - return normalized !== undefined && THINKING_EFFORTS.has(normalized as ThinkingEffort) - ? (normalized as ThinkingEffort) - : undefined; + return effort; } diff --git a/packages/agent-core/src/agent/config/types.ts b/packages/agent-core/src/agent/config/types.ts index fc7a785f5..7b4d731db 100644 --- a/packages/agent-core/src/agent/config/types.ts +++ b/packages/agent-core/src/agent/config/types.ts @@ -6,7 +6,7 @@ export interface AgentConfigData { modelAlias?: string; modelCapabilities: ModelCapability; profileName?: string; - thinkingLevel: string; + thinkingEffort: string; systemPrompt: string; } @@ -14,6 +14,6 @@ export type AgentConfigUpdateData = Partial<{ cwd: string; modelAlias: string; profileName: string; - thinkingLevel: string; + thinkingEffort: string; systemPrompt: string; }>; diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 4e733a80c..aa6dc0cab 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -298,9 +298,9 @@ export class Agent { this.context.undo(payload.count); }, setThinking: (payload) => { - const wasEnabled = this.config.thinkingLevel !== 'off'; - this.config.update({ thinkingLevel: payload.level }); - const enabled = this.config.thinkingLevel !== 'off'; + const wasEnabled = this.config.thinkingEffort !== 'off'; + this.config.update({ thinkingEffort: payload.effort }); + const enabled = this.config.thinkingEffort !== 'off'; if (enabled !== wasEnabled) { this.telemetry.track('thinking_toggle', { enabled }); } diff --git a/packages/agent-core/src/config/env-model.ts b/packages/agent-core/src/config/env-model.ts index 1d0956b7a..2933da52c 100644 --- a/packages/agent-core/src/config/env-model.ts +++ b/packages/agent-core/src/config/env-model.ts @@ -138,23 +138,9 @@ export function applyEnvModelConfig(config: KimiConfig, env: Env = process.env): ...(adaptiveThinking !== undefined ? { adaptiveThinking } : {}), }; - const thinkingMode = trimmed(env['KIMI_MODEL_THINKING_MODE']); const thinkingEffort = trimmed(env['KIMI_MODEL_THINKING_EFFORT']); const thinking: ThinkingConfig | undefined = - thinkingMode !== undefined || thinkingEffort !== undefined - ? { - ...config.thinking, - // Cast: thinkingMode is a raw string passed through to validateConfig - // for enum validation (auto/on/off). The cast avoids a TS compile error - // without skipping runtime validation. - ...(thinkingMode !== undefined ? { mode: thinkingMode as ThinkingConfig['mode'] } : {}), - ...(thinkingEffort !== undefined ? { effort: thinkingEffort } : {}), - } - : config.thinking; - const defaultThinking = parseBooleanVar( - env['KIMI_MODEL_DEFAULT_THINKING'], - 'KIMI_MODEL_DEFAULT_THINKING', - ); + thinkingEffort !== undefined ? { ...config.thinking, effort: thinkingEffort } : config.thinking; const merged: KimiConfig = { ...config, @@ -162,12 +148,11 @@ export function applyEnvModelConfig(config: KimiConfig, env: Env = process.env): models: { ...config.models, [ENV_MODEL_ALIAS_KEY]: alias }, defaultModel: ENV_MODEL_ALIAS_KEY, ...(thinking !== undefined ? { thinking } : {}), - ...(defaultThinking !== undefined ? { defaultThinking } : {}), }; - // Re-validate so the synthesized entries honor the same schema constraints - // (e.g. thinking.mode must be auto/on/off). `validateConfig` throws - // KimiError(CONFIG_INVALID) on violation, matching the explicit checks above. + // Re-validate so the synthesized entries honor the same schema constraints. + // `validateConfig` throws KimiError(CONFIG_INVALID) on violation, matching + // the explicit checks above. return validateConfig(merged); } @@ -178,7 +163,7 @@ export function applyEnvModelConfig(config: KimiConfig, env: Env = process.env): * config.toml — including via a `getConfig` -> `setConfig` patch round-trip, * where the runtime config (carrying the env provider and its shell API key) * would otherwise be merged back and written out. Every env-injected top-level - * field (default_model, thinking, default_thinking) is restored to its on-disk + * field (default_model, thinking) is restored to its on-disk * value from `config.raw` rather than erased, so real values already in * config.toml survive the round-trip. */ @@ -203,12 +188,11 @@ export function stripEnvModelConfig(config: KimiConfig): KimiConfig { ...(models !== undefined ? { models } : {}), // Restore env-injected top-level fields from raw instead of persisting the // shell overrides: the env default_model (when it points at the env alias), - // and the env thinking / default_thinking. Reaching here means env-model - // mode is active (the synthetic provider/model exist), so these may be env - // values; an unset raw field restores to undefined (i.e. drops it). + // and the env thinking. Reaching here means env-model mode is active (the + // synthetic provider/model exist), so these may be env values; an unset raw + // field restores to undefined (i.e. drops it). ...(defaultIsEnv ? { defaultModel: rawDefaultModel(config) } : {}), thinking: rawThinking(config), - defaultThinking: rawDefaultThinking(config), }; } @@ -217,11 +201,6 @@ function rawDefaultModel(config: KimiConfig): string | undefined { return typeof raw === 'string' ? raw : undefined; } -function rawDefaultThinking(config: KimiConfig): boolean | undefined { - const raw = config.raw?.['default_thinking']; - return typeof raw === 'boolean' ? raw : undefined; -} - function rawThinking(config: KimiConfig): ThinkingConfig | undefined { const raw = config.raw?.['thinking']; return typeof raw === 'object' && raw !== null && !Array.isArray(raw) diff --git a/packages/agent-core/src/config/kimi-env-params.ts b/packages/agent-core/src/config/kimi-env-params.ts index 8aa65455c..b12dd79d1 100644 --- a/packages/agent-core/src/config/kimi-env-params.ts +++ b/packages/agent-core/src/config/kimi-env-params.ts @@ -47,11 +47,11 @@ export function applyKimiEnvSamplingParams( */ export function applyKimiEnvThinkingKeep( provider: ChatProvider, - thinkingLevel: ThinkingEffort, + thinkingEffort: ThinkingEffort, env: Env = process.env, ): ChatProvider { if (!(provider instanceof KimiChatProvider)) return provider; const keep = env['KIMI_MODEL_THINKING_KEEP']?.trim(); - if (keep === undefined || keep.length === 0 || thinkingLevel === 'off') return provider; + if (keep === undefined || keep.length === 0 || thinkingEffort === 'off') return provider; return provider.withExtraBody({ thinking: { keep } }); } diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 041bf8d6e..6c1864406 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -50,12 +50,18 @@ export const ModelAliasSchema = z.object({ // model-name version inference. Needed for custom-named Anthropic endpoints // whose model name does not encode a parseable Claude version. adaptiveThinking: z.boolean().optional(), + // Efforts (e.g. ["low", "high", "max"]) the model supports for + // extended thinking, plus the catalog default. Generic to any provider: + // managed models fill these from the catalog, others can be set by hand in + // config.toml. The user's chosen effort is stored globally in thinking.effort. + supportEfforts: z.array(z.string()).optional(), + defaultEffort: z.string().optional(), }); export type ModelAlias = z.infer; export const ThinkingConfigSchema = z.object({ - mode: z.enum(['auto', 'on', 'off']).optional(), + enabled: z.boolean().optional(), effort: z.string().optional(), }); @@ -209,7 +215,6 @@ export const KimiConfigSchema = z.object({ thinking: ThinkingConfigSchema.optional(), planMode: z.boolean().optional(), yolo: z.boolean().optional(), - defaultThinking: z.boolean().optional(), defaultPermissionMode: PermissionModeSchema.optional(), defaultPlanMode: z.boolean().optional(), permission: PermissionConfigSchema.optional(), @@ -248,7 +253,6 @@ export const KimiConfigPatchSchema = z thinking: ThinkingConfigPatchSchema.optional(), planMode: z.boolean().optional(), yolo: z.boolean().optional(), - defaultThinking: z.boolean().optional(), defaultPermissionMode: PermissionModeSchema.optional(), defaultPlanMode: z.boolean().optional(), permission: PermissionConfigPatchSchema.optional(), diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index 172e97cfc..a90138251 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -460,6 +460,8 @@ export function configToTomlData(config: KimiConfig): Record { delete out['default_yolo']; delete out['defaultYolo']; delete out['defaultPermissionMode']; + delete out['default_thinking']; + delete out['defaultThinking']; // Top-level scalar fields const scalarFields: (keyof KimiConfig)[] = [ @@ -467,7 +469,6 @@ export function configToTomlData(config: KimiConfig): Record { 'defaultModel', 'planMode', 'yolo', - 'defaultThinking', 'defaultPermissionMode', 'defaultPlanMode', 'mergeAllAvailableSkills', @@ -562,6 +563,7 @@ function modelToToml(model: ModelAlias, rawModel: unknown): Record { const out = cloneRecord(rawThinking); + delete out['mode']; for (const [key, value] of Object.entries(thinking)) { setDefined(out, camelToSnake(key), value); } diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index ce9f2dd12..195bb37f9 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -199,7 +199,7 @@ export interface CancelPayload { readonly turnId?: number; } export interface SetThinkingPayload { - readonly level: string; + readonly effort: string; } export interface SetPermissionPayload { readonly mode: PermissionMode; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 6ad2a1cd5..8cc962822 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -9,7 +9,7 @@ import { MoonshotFetchURLProvider } from '#/tools/providers/moonshot-fetch-url'; import { MoonshotWebSearchProvider } from '#/tools/providers/moonshot-web-search'; import type { PromisableMethods } from '#/utils/types'; import { getCoreVersion } from '#/version'; -import { resolveThinkingLevel } from '../agent/config/thinking'; +import { resolveThinkingEffort } from '../agent/config/thinking'; import { Agent } from '../agent'; import { ensureKimiHome, @@ -222,7 +222,9 @@ export class KimiCore implements PromisableMethods { const workDir = requiredWorkDir('createSession', options.workDir); const config = this.reloadProviderManager(); const id = options.id ?? createSessionId(); - const thinkingLevel = resolveThinkingLevel(options.thinking, config); + const modelAlias = options.model ?? config.defaultModel; + const model = modelAlias !== undefined ? config.models?.[modelAlias] : undefined; + const thinkingEffort = resolveThinkingEffort(options.thinking, config.thinking, model); const permissionMode = options.permission ?? config.defaultPermissionMode; const baseMcpConfig = await resolveSessionMcpConfig({ cwd: workDir, @@ -307,7 +309,7 @@ export class KimiCore implements PromisableMethods { const mainAgent = await session.createMain(); mainAgent.config.update({ modelAlias: options.model ?? config.defaultModel, - thinkingLevel, + thinkingEffort, }); if (permissionMode !== undefined) { mainAgent.permission.setMode(permissionMode); diff --git a/packages/agent-core/src/services/config/configService.ts b/packages/agent-core/src/services/config/configService.ts index 582210bec..79a08e1af 100644 --- a/packages/agent-core/src/services/config/configService.ts +++ b/packages/agent-core/src/services/config/configService.ts @@ -57,7 +57,6 @@ function toConfigResponse(config: KimiConfig): ConfigResponse { thinking: config.thinking, plan_mode: config.planMode, yolo: config.yolo, - default_thinking: config.defaultThinking, default_permission_mode: config.defaultPermissionMode, default_plan_mode: config.defaultPlanMode, permission: config.permission, diff --git a/packages/agent-core/src/services/modelCatalog/modelCatalog.ts b/packages/agent-core/src/services/modelCatalog/modelCatalog.ts index b17387297..df53c61b3 100644 --- a/packages/agent-core/src/services/modelCatalog/modelCatalog.ts +++ b/packages/agent-core/src/services/modelCatalog/modelCatalog.ts @@ -52,6 +52,8 @@ export function toProtocolModel( display_name: alias.displayName ?? alias.model, max_context_size: alias.maxContextSize, capabilities: alias.capabilities, + support_efforts: alias.supportEfforts, + default_effort: alias.defaultEffort, }; } diff --git a/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts b/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts index bd8eb79f3..8faa68360 100644 --- a/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts +++ b/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts @@ -138,7 +138,7 @@ export class ModelCatalogService next, preserveUserProviderAliases(config, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys), ); - restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); + restoreDefaultSelection(next, config.defaultModel, config.thinking?.enabled); clampDanglingDefault(next); if (providerModelsEqual(config, next, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys)) { @@ -153,7 +153,7 @@ export class ModelCatalogService providers: next.providers, models: next.models, defaultModel: next.defaultModel, - defaultThinking: next.defaultThinking, + thinking: next.thinking, }); changed.push({ provider_id: KIMI_CODE_PROVIDER_NAME, @@ -341,13 +341,14 @@ function restoreDefaultSelection( if (defaultModel === undefined || config.models?.[defaultModel] === undefined) return; config.defaultModel = defaultModel; const capabilities = config.models[defaultModel]?.capabilities ?? []; - config.defaultThinking = capabilities.includes('always_thinking') ? true : defaultThinking; + const enabled = capabilities.includes('always_thinking') ? true : defaultThinking; + config.thinking = { ...config.thinking, ...(enabled !== undefined ? { enabled } : {}) }; } function clampDanglingDefault(config: KimiConfig): void { if (config.defaultModel !== undefined && config.models?.[config.defaultModel] === undefined) { config.defaultModel = undefined; - config.defaultThinking = undefined; + config.thinking = undefined; } } diff --git a/packages/agent-core/src/services/prompt/promptService.ts b/packages/agent-core/src/services/prompt/promptService.ts index 89da6e877..43a161357 100644 --- a/packages/agent-core/src/services/prompt/promptService.ts +++ b/packages/agent-core/src/services/prompt/promptService.ts @@ -213,7 +213,7 @@ function isTurnEnded(e: Event): e is Event & { /** * Type guard for `agent.status.updated` agent-core events. Carries the * subset of fields we mirror into the per-session shadow on every live - * change (model / permission / planMode). `thinkingLevel` is NOT on this + * change (model / permission / planMode). `thinkingEffort` is NOT on this * event — bootstrap seeds it from `getConfig` and per-request diff dispatch * keeps it in sync from there. */ @@ -609,11 +609,11 @@ export class PromptService ]); const snapshot: AgentStateSnapshot = {}; if (config.modelAlias !== undefined) snapshot.model = config.modelAlias; - // `AgentConfigData.thinkingLevel` is typed `string` but in practice + // `AgentConfigData.thinkingEffort` is typed `string` but in practice // takes one of the `PromptThinking` literals (`off|low|...|max`); the // narrow cast lets diff comparisons stay typed without forcing // protocol to import from agent-core. - snapshot.thinking = config.thinkingLevel as PromptThinking; + snapshot.thinking = config.thinkingEffort as PromptThinking; snapshot.permissionMode = permission.mode; snapshot.planMode = plan !== null; snapshot.swarmMode = swarmMode; @@ -654,7 +654,7 @@ export class PromptService this._recordDispatch(sid, 'setModel', payload, promptId, source); } if (patch.thinking !== undefined && patch.thinking !== shadow.thinking) { - const payload = { sessionId: sid, agentId, level: patch.thinking as PromptThinking }; + const payload = { sessionId: sid, agentId, effort: patch.thinking as PromptThinking }; await this.core.rpc.setThinking(payload); shadow.thinking = patch.thinking; this._recordDispatch(sid, 'setThinking', payload, promptId, source); diff --git a/packages/agent-core/src/services/session/sessionService.ts b/packages/agent-core/src/services/session/sessionService.ts index 3b684a0dc..d49662364 100644 --- a/packages/agent-core/src/services/session/sessionService.ts +++ b/packages/agent-core/src/services/session/sessionService.ts @@ -469,7 +469,7 @@ export class SessionService extends Disposable implements ISessionService { return { status: this._computeStatus(id), model: config.modelAlias ?? config.provider?.model, - thinking_level: config.thinkingLevel, + thinking_level: config.thinkingEffort, permission: permission.mode, plan_mode: plan !== null, swarm_mode: agentState?.swarmMode ?? false, diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index 44b24153d..e88d7122c 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -119,6 +119,7 @@ export class ProviderManager implements ModelProvider { alias.reasoningKey, this.options.promptCacheKey, alias.adaptiveThinking, + alias.supportEfforts, ); return { @@ -234,6 +235,7 @@ function toKosongProviderConfig( reasoningKey: string | undefined, promptCacheKey: string | undefined, adaptiveThinking: boolean | undefined, + supportEfforts: readonly string[] | undefined, ): KosongProviderConfig { const effectiveType = modelProtocol === 'anthropic' ? 'anthropic' : provider.type; switch (effectiveType) { @@ -271,6 +273,7 @@ function toKosongProviderConfig( baseUrl: providerValue(provider.baseUrl, provider.env, 'KIMI_BASE_URL'), apiKey: providerApiKey(provider), generationKwargs: { prompt_cache_key: promptCacheKey }, + supportEfforts, ...defaultHeadersField({ ...kimiRequestHeaders, ...provider.customHeaders }), }; case 'google-genai': diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 5153acea5..1e6e249cf 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -223,7 +223,7 @@ export class SessionSubagentHost { child.config.update({ modelAlias: parent.config.modelAlias, - thinkingLevel: parent.config.thinkingLevel, + thinkingEffort: parent.config.thinkingEffort, systemPrompt: parent.config.systemPrompt, }); child.tools.copyLoopToolsFrom(parent.tools); @@ -366,7 +366,7 @@ export class SessionSubagentHost { child.config.update({ cwd: parent.config.cwd, modelAlias: parent.config.modelAlias, - thinkingLevel: parent.config.thinkingLevel, + thinkingEffort: parent.config.thinkingEffort, }); const context = await prepareSystemPromptContext( diff --git a/packages/agent-core/test/agent/compaction/full.test.ts b/packages/agent-core/test/agent/compaction/full.test.ts index 2e912d625..fcf4d8f56 100644 --- a/packages/agent-core/test/agent/compaction/full.test.ts +++ b/packages/agent-core/test/agent/compaction/full.test.ts @@ -241,7 +241,7 @@ describe('FullCompaction', () => { duration_ms: expect.any(Number), compactedCount: 6, retryCount: 0, - thinkingLevel: 'off', + thinkingEffort: 'off', inputOther: 520, output: 8, inputCacheRead: 0, @@ -1727,7 +1727,7 @@ describe('FullCompaction', () => { provider: CATALOGUED_PROVIDER, modelCapabilities: CATALOGUED_MODEL_CAPABILITIES, }); - ctx.agent.config.update({ thinkingLevel: 'high' }); + ctx.agent.config.update({ thinkingEffort: 'high' }); ctx.appendExchange(1, 'old user one', 'old assistant one', 20); ctx.newEvents(); @@ -1735,12 +1735,17 @@ describe('FullCompaction', () => { await ctx.untilTurnEnd(); expect(callCount).toBe(3); - expect(providerThinkingEfforts).toEqual(['high', 'high', 'high']); + // The catalogued model declares no supportEfforts, so the kimi provider + // treats it as a boolean thinking model: any non-'off' level (incl. 'high') + // is sent as thinking.enabled with no effort, which `thinkingEffort` + // reports back as 'on'. The agent's stored thinkingEffort ('high') is still + // carried across the compaction (see the record assertion below). + expect(providerThinkingEfforts).toEqual(['on', 'on', 'on']); expect(records).toContainEqual({ event: 'compaction_finished', properties: expect.objectContaining({ source: 'auto', - thinkingLevel: 'high', + thinkingEffort: 'high', }), }); }); diff --git a/packages/agent-core/test/agent/compaction/micro.test.ts b/packages/agent-core/test/agent/compaction/micro.test.ts index 91be825d1..c033ef842 100644 --- a/packages/agent-core/test/agent/compaction/micro.test.ts +++ b/packages/agent-core/test/agent/compaction/micro.test.ts @@ -482,7 +482,7 @@ describe('MicroCompaction', () => { truncatedToolResultTokensAfter: expect.any(Number), tokensBefore: expect.any(Number), tokensAfter: expect.any(Number), - thinkingLevel: 'off', + thinkingEffort: 'off', }); expect(numberProperty(event, 'truncatedToolResultTokensBefore')).toBeGreaterThan( numberProperty(event, 'truncatedToolResultTokensAfter'), diff --git a/packages/agent-core/test/agent/config-state.test.ts b/packages/agent-core/test/agent/config-state.test.ts index f200108a7..ff15defb9 100644 --- a/packages/agent-core/test/agent/config-state.test.ts +++ b/packages/agent-core/test/agent/config-state.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { emptyUsage } from '@moonshot-ai/kosong'; import { ProviderManager } from '../../src/session/provider-manager'; +import type { KimiConfig } from '../../src/config'; import { testAgent } from './harness'; describe('ConfigState model capabilities', () => { @@ -113,7 +114,7 @@ describe('ConfigState model capabilities', () => { ctx.agent.config.update({ modelAlias: 'deepseek/deepseek-v4-flash', systemPrompt: 'system', - thinkingLevel: 'off', + thinkingEffort: 'off', }); await ctx.agent.llm.chat({ messages: [], @@ -163,39 +164,44 @@ describe('ConfigState model capabilities', () => { describe('ConfigState thinking clamp for always-thinking models', () => { function alwaysThinkingAgent() { - return testAgent({ - providerManager: new ProviderManager({ - config: { - providers: { kimi: { type: 'kimi', apiKey: 'test-key' } }, - models: { - 'kimi-code/deep': { - provider: 'kimi', - model: 'kimi-deep-coder', - maxContextSize: 128_000, - capabilities: ['thinking', 'always_thinking', 'tool_use'], - }, - 'kimi-code/toggle': { - provider: 'kimi', - model: 'kimi-for-coding', - maxContextSize: 128_000, - capabilities: ['thinking'], - }, - }, + // The always_thinking clamp in ConfigState.update() reads the model from + // `agent.kimiConfig.models`, so the same config must back both the + // ProviderManager (provider resolution) and the agent's kimiConfig (the + // clamp's model lookup). + const config: KimiConfig = { + providers: { kimi: { type: 'kimi', apiKey: 'test-key' } }, + models: { + 'kimi-code/deep': { + provider: 'kimi', + model: 'kimi-deep-coder', + maxContextSize: 128_000, + capabilities: ['thinking', 'always_thinking', 'tool_use'], }, - }), + 'kimi-code/toggle': { + provider: 'kimi', + model: 'kimi-for-coding', + maxContextSize: 128_000, + capabilities: ['thinking'], + }, + }, + }; + return testAgent({ + initialConfig: config, + providerManager: new ProviderManager({ config }), }); } - it('clamps thinkingLevel off to the configured effort', () => { + it('clamps thinkingEffort off to the model default effort', () => { const ctx = alwaysThinkingAgent(); - ctx.agent.config.update({ modelAlias: 'kimi-code/deep', thinkingLevel: 'off' }); + ctx.agent.config.update({ modelAlias: 'kimi-code/deep', thinkingEffort: 'off' }); - expect(ctx.agent.config.thinkingLevel).toBe('high'); + // boolean always-thinking model (no supportEfforts) defaults to 'on'. + expect(ctx.agent.config.thinkingEffort).toBe('on'); }); it('builds the provider with thinking enabled even after thinking was set off', () => { const ctx = alwaysThinkingAgent(); - ctx.agent.config.update({ modelAlias: 'kimi-code/deep', thinkingLevel: 'off' }); + ctx.agent.config.update({ modelAlias: 'kimi-code/deep', thinkingEffort: 'off' }); const provider = ctx.agent.config.provider; const gen = Reflect.get(provider as object, '_generationKwargs') as { @@ -206,18 +212,20 @@ describe('ConfigState thinking clamp for always-thinking models', () => { it('keeps thinking off working for toggleable models', () => { const ctx = alwaysThinkingAgent(); - ctx.agent.config.update({ modelAlias: 'kimi-code/toggle', thinkingLevel: 'off' }); + ctx.agent.config.update({ modelAlias: 'kimi-code/toggle', thinkingEffort: 'off' }); - expect(ctx.agent.config.thinkingLevel).toBe('off'); + expect(ctx.agent.config.thinkingEffort).toBe('off'); }); - it('re-clamps when switching to an always-on model after thinking was off', () => { + it('re-clamps a stale off when switching onto an always-thinking model', () => { const ctx = alwaysThinkingAgent(); - ctx.agent.config.update({ modelAlias: 'kimi-code/toggle', thinkingLevel: 'off' }); - expect(ctx.agent.config.thinkingLevel).toBe('off'); + ctx.agent.config.update({ modelAlias: 'kimi-code/toggle', thinkingEffort: 'off' }); + expect(ctx.agent.config.thinkingEffort).toBe('off'); + // A bare model switch re-applies the always_thinking clamp against the new + // model, so the previously stored 'off' is clamped back to the default. ctx.agent.config.update({ modelAlias: 'kimi-code/deep' }); - expect(ctx.agent.config.thinkingLevel).toBe('high'); + expect(ctx.agent.config.thinkingEffort).toBe('on'); }); }); @@ -254,7 +262,7 @@ describe('ConfigState.provider applies global KIMI_MODEL_* request config', () = vi.stubEnv('KIMI_MODEL_THINKING_KEEP', 'all'); try { const ctx = kimiAgent(); - ctx.agent.config.update({ modelAlias: 'kimi-code', thinkingLevel: 'high' }); + ctx.agent.config.update({ modelAlias: 'kimi-code', thinkingEffort: 'high' }); const provider = ctx.agent.config.provider; const gen = Reflect.get(provider as object, '_generationKwargs') as { @@ -270,7 +278,7 @@ describe('ConfigState.provider applies global KIMI_MODEL_* request config', () = vi.stubEnv('KIMI_MODEL_THINKING_KEEP', 'all'); try { const ctx = kimiAgent(); - ctx.agent.config.update({ modelAlias: 'kimi-code', thinkingLevel: 'off' }); + ctx.agent.config.update({ modelAlias: 'kimi-code', thinkingEffort: 'off' }); const provider = ctx.agent.config.provider; const gen = Reflect.get(provider as object, '_generationKwargs') as { diff --git a/packages/agent-core/test/agent/config.test.ts b/packages/agent-core/test/agent/config.test.ts index acd80625a..3066481bf 100644 --- a/packages/agent-core/test/agent/config.test.ts +++ b/packages/agent-core/test/agent/config.test.ts @@ -6,7 +6,7 @@ import { createCommandKaos, testAgent } from './harness/agent'; import { DEFAULT_TEST_SYSTEM_PROMPT } from './harness/snapshots'; describe('Agent config', () => { - it('exposes provider, system prompt, thinking level, and model capability updates', async () => { + it('exposes provider, system prompt, thinking effort, and model capability updates', async () => { const ctx = testAgent(); const initialProvider: ProviderConfig = { type: 'openai', @@ -30,7 +30,7 @@ describe('Agent config', () => { await expect(ctx.rpc.getConfig({})).resolves.toMatchObject({ provider: initialProvider, systemPrompt: DEFAULT_TEST_SYSTEM_PROMPT, - thinkingLevel: 'off', + thinkingEffort: 'off', modelCapabilities: initialCapability, }); @@ -51,13 +51,13 @@ describe('Agent config', () => { ctx.configureRuntimeModel(nextProvider, nextCapability); ctx.agent.config.update({ systemPrompt: 'Changed profile prompt.', - thinkingLevel: 'high', + thinkingEffort: 'high', }); await expect(ctx.rpc.getConfig({})).resolves.toMatchObject({ provider: nextProvider, systemPrompt: 'Changed profile prompt.', - thinkingLevel: 'high', + thinkingEffort: 'high', modelCapabilities: nextCapability, }); await ctx.expectResumeMatches(); diff --git a/packages/agent-core/test/agent/config/thinking.test.ts b/packages/agent-core/test/agent/config/thinking.test.ts index 255bec915..f72ea8eda 100644 --- a/packages/agent-core/test/agent/config/thinking.test.ts +++ b/packages/agent-core/test/agent/config/thinking.test.ts @@ -1,69 +1,112 @@ import { describe, expect, it } from 'vitest'; -import { resolveThinkingEffort } from '../../../src/agent/config/thinking'; +import type { ModelAlias } from '../../../src/config'; +import { defaultThinkingEffortFor, resolveThinkingEffort } from '../../../src/agent/config/thinking'; -describe('resolveThinkingEffort', () => { - describe('without explicit request', () => { - it('defaults to high when no config is provided', () => { - expect(resolveThinkingEffort(undefined, undefined)).toBe('high'); - }); - - it('returns off when config mode is off', () => { - expect(resolveThinkingEffort(undefined, { mode: 'off' })).toBe('off'); - }); +function model(overrides: Partial = {}): ModelAlias { + return { + provider: 'p', + model: 'm', + maxContextSize: 1, + ...overrides, + }; +} - it('returns high when config mode is on without explicit effort', () => { - expect(resolveThinkingEffort(undefined, { mode: 'on' })).toBe('high'); - }); +const booleanModel = model({ capabilities: ['thinking'] }); +const effortModel = model({ + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'high'], +}); +const effortModelWithDefault = model({ + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + defaultEffort: 'max', +}); +const alwaysThinkingModel = model({ capabilities: ['thinking', 'always_thinking'] }); +const alwaysThinkingEffortModel = model({ + capabilities: ['thinking', 'always_thinking'], + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'high', +}); +const nonThinkingModel = model({ capabilities: ['tool_use'] }); - it('returns explicit effort when both mode=on and effort are set', () => { - expect(resolveThinkingEffort(undefined, { mode: 'on', effort: 'medium' })).toBe('medium'); - }); +describe('defaultThinkingEffortFor', () => { + it('returns off for models that do not support thinking (or an unknown model)', () => { + expect(defaultThinkingEffortFor(undefined)).toBe('off'); + expect(defaultThinkingEffortFor(nonThinkingModel)).toBe('off'); + expect(defaultThinkingEffortFor(model())).toBe('off'); + }); - it('uses effort even when mode is omitted', () => { - expect(resolveThinkingEffort(undefined, { effort: 'low' })).toBe('low'); - }); + it('returns the declared defaultEffort for effort-capable models', () => { + expect(defaultThinkingEffortFor(effortModelWithDefault)).toBe('max'); + }); - it('returns off when mode is off even if effort is set', () => { - expect(resolveThinkingEffort(undefined, { mode: 'off', effort: 'high' })).toBe('off'); - }); + it('falls back to the middle supportEfforts entry when defaultEffort is absent', () => { + // odd length -> exact middle + expect(defaultThinkingEffortFor(effortModel)).toBe('medium'); + // even length -> upper-middle index + expect(defaultThinkingEffortFor(model({ capabilities: ['thinking'], supportEfforts: ['low', 'high'] }))).toBe( + 'high', + ); + expect(defaultThinkingEffortFor(model({ capabilities: ['thinking'], supportEfforts: ['low'] }))).toBe( + 'low', + ); }); - describe('with explicit request', () => { - it('returns off when request is "off" regardless of config', () => { - expect(resolveThinkingEffort('off', { mode: 'on', effort: 'medium' })).toBe('off'); - }); + it('returns on for boolean thinking models (thinking support without supportEfforts)', () => { + expect(defaultThinkingEffortFor(booleanModel)).toBe('on'); + expect(defaultThinkingEffortFor(model({ capabilities: ['always_thinking'] }))).toBe('on'); + expect(defaultThinkingEffortFor(model({ adaptiveThinking: true }))).toBe('on'); + }); +}); - it('returns config effort when request is "on" and config has effort', () => { - expect(resolveThinkingEffort('on', { effort: 'medium' })).toBe('medium'); - }); +describe('resolveThinkingEffort', () => { + it('returns the requested effort verbatim when one is provided', () => { + expect(resolveThinkingEffort('low', undefined, effortModel)).toBe('low'); + expect(resolveThinkingEffort('on', { enabled: false }, booleanModel)).toBe('on'); + expect(resolveThinkingEffort('off', undefined, booleanModel)).toBe('off'); + }); - it('returns high when request is "on" and config has no effort', () => { - expect(resolveThinkingEffort('on', undefined)).toBe('high'); - }); + it('returns off when config.enabled is false and no effort is requested', () => { + expect(resolveThinkingEffort(undefined, { enabled: false }, effortModel)).toBe('off'); + expect(resolveThinkingEffort(undefined, { enabled: false, effort: 'high' }, effortModel)).toBe( + 'off', + ); + }); - it('returns explicit effort level when request is a level name', () => { - expect(resolveThinkingEffort('xhigh', undefined)).toBe('xhigh'); - }); + it('uses config.effort as the default effort', () => { + expect(resolveThinkingEffort(undefined, { effort: 'high' }, effortModel)).toBe('high'); + expect(resolveThinkingEffort(undefined, { enabled: true, effort: 'low' }, effortModel)).toBe( + 'low', + ); + }); - it('falls back to config effort when request is unknown', () => { - expect(resolveThinkingEffort('bogus', { effort: 'low' })).toBe('low'); - }); + it('falls back to defaultThinkingEffortFor(model) when no effort is configured', () => { + expect(resolveThinkingEffort(undefined, undefined, effortModel)).toBe('medium'); + expect(resolveThinkingEffort(undefined, {}, booleanModel)).toBe('on'); + expect(resolveThinkingEffort(undefined, undefined, undefined)).toBe('off'); + }); - it('falls back to default high when request is unknown and no config', () => { - expect(resolveThinkingEffort('bogus', undefined)).toBe('high'); - }); + it('forces always-thinking models back on when the resolved effort is off', () => { + expect(resolveThinkingEffort('off', undefined, alwaysThinkingModel)).toBe('on'); + expect(resolveThinkingEffort(undefined, { enabled: false }, alwaysThinkingModel)).toBe('on'); + }); - it('normalizes case and whitespace', () => { - expect(resolveThinkingEffort(' Medium ', undefined)).toBe('medium'); - expect(resolveThinkingEffort('OFF', { mode: 'on' })).toBe('off'); - }); + it('honors a configured effort when clamping always-thinking models back on', () => { + // enabled=false resolves to 'off', then always_thinking clamps back on; + // an explicitly configured effort is preserved instead of falling back to + // the model default. + expect( + resolveThinkingEffort(undefined, { enabled: false, effort: 'max' }, alwaysThinkingEffortModel), + ).toBe('max'); + // without an explicit effort, fall back to the model's default effort. + expect(resolveThinkingEffort(undefined, { enabled: false }, alwaysThinkingEffortModel)).toBe( + 'high', + ); }); - describe('default behavior', () => { - it('uses high as the concrete effort for the default-on state', () => { - expect(resolveThinkingEffort(undefined, undefined)).toBe('high'); - expect(resolveThinkingEffort('on', undefined)).toBe('high'); - }); + it('does not force on for models that are not always-thinking', () => { + expect(resolveThinkingEffort('off', undefined, booleanModel)).toBe('off'); + expect(resolveThinkingEffort(undefined, { enabled: false }, booleanModel)).toBe('off'); }); }); diff --git a/packages/agent-core/test/agent/harness/agent.ts b/packages/agent-core/test/agent/harness/agent.ts index 72cc85b37..df33507a8 100644 --- a/packages/agent-core/test/agent/harness/agent.ts +++ b/packages/agent-core/test/agent/harness/agent.ts @@ -78,7 +78,7 @@ interface ResumeStateSnapshot { readonly cwd: string; readonly provider: ProviderConfig | undefined; readonly profileName: string | undefined; - readonly thinkingLevel: string; + readonly thinkingEffort: string; readonly systemPrompt: string; }; readonly context: ReturnType; @@ -224,7 +224,7 @@ export class AgentTestContext { cwd: process.cwd(), modelAlias: provider.model, systemPrompt: DEFAULT_TEST_SYSTEM_PROMPT, - thinkingLevel: 'off', + thinkingEffort: 'off', }); if (tools.length > 0) { @@ -1043,7 +1043,7 @@ function configStateSnapshot(agent: Agent): ResumeStateSnapshot['config'] { cwd: agent.config.cwd.replaceAll('\\', '/'), provider, profileName: agent.config.profileName, - thinkingLevel: agent.config.thinkingLevel, + thinkingEffort: agent.config.thinkingEffort, systemPrompt: agent.config.systemPrompt, }; } diff --git a/packages/agent-core/test/agent/records/index.test.ts b/packages/agent-core/test/agent/records/index.test.ts index 56ed53a1b..f9e07b6c4 100644 --- a/packages/agent-core/test/agent/records/index.test.ts +++ b/packages/agent-core/test/agent/records/index.test.ts @@ -337,7 +337,7 @@ describe('agent replay range build', () => { { type: 'config.update', cwd: process.cwd(), - thinkingLevel: 'off', + thinkingEffort: 'off', }, { type: 'usage.record', diff --git a/packages/agent-core/test/agent/resume.test.ts b/packages/agent-core/test/agent/resume.test.ts index 301e2533a..de24bff33 100644 --- a/packages/agent-core/test/agent/resume.test.ts +++ b/packages/agent-core/test/agent/resume.test.ts @@ -513,7 +513,7 @@ describe('Agent resume', () => { cwd: process.cwd(), modelAlias: MOCK_PROVIDER.model, systemPrompt: DEFAULT_TEST_SYSTEM_PROMPT, - thinkingLevel: 'off', + thinkingEffort: 'off', }, { type: 'context.append_message', @@ -1274,7 +1274,7 @@ function resumeHistory(): AgentRecord[] { cwd: process.cwd(), modelAlias: MOCK_PROVIDER.model, systemPrompt: DEFAULT_TEST_SYSTEM_PROMPT, - thinkingLevel: 'off', + thinkingEffort: 'off', }, { type: 'tools.set_active_tools', @@ -1394,7 +1394,7 @@ function resumeDeferredSystemReminderHistory(): AgentRecord[] { cwd: process.cwd(), modelAlias: MOCK_PROVIDER.model, systemPrompt: DEFAULT_TEST_SYSTEM_PROMPT, - thinkingLevel: 'off', + thinkingEffort: 'off', }, { type: 'context.append_message', @@ -1502,7 +1502,7 @@ function resumeConfigRecord(): AgentRecord { cwd: process.cwd(), modelAlias: MOCK_PROVIDER.model, systemPrompt: DEFAULT_TEST_SYSTEM_PROMPT, - thinkingLevel: 'off', + thinkingEffort: 'off', }; } diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index 8f35bd241..b83947876 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -1520,7 +1520,7 @@ describe('Agent turn flow', () => { cwd: process.cwd(), modelAlias: 'kimi-code', systemPrompt: 'test system prompt', - thinkingLevel: 'off', + thinkingEffort: 'off', }); Object.defineProperty(ctx.agent.config, 'provider', { configurable: true, diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index 091eee384..fd554343e 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -50,7 +50,6 @@ function expectKimiErrorCode(fn: () => unknown, code: string): void { const COMPLETE_TOML = ` default_model = "kimi-code/kimi-for-coding" -default_thinking = true default_permission_mode = "auto" default_plan_mode = false merge_all_available_skills = true @@ -75,7 +74,7 @@ capabilities = ["image_in", "thinking", "video_in"] display_name = "Kimi for Coding" [thinking] -mode = "auto" +enabled = true effort = "medium" [permission] @@ -132,7 +131,7 @@ describe('harness config TOML loader', () => { const config = parseConfigString(COMPLETE_TOML, 'config.toml'); expect(config.defaultModel).toBe('kimi-code/kimi-for-coding'); - expect(config.defaultThinking).toBe(true); + expect(config.thinking?.enabled).toBe(true); expect(config.defaultPermissionMode).toBe('auto'); expect(config.defaultPlanMode).toBe(false); expect(config.mergeAllAvailableSkills).toBe(true); @@ -152,7 +151,7 @@ describe('harness config TOML loader', () => { capabilities: ['image_in', 'thinking', 'video_in'], displayName: 'Kimi for Coding', }); - expect(config.thinking).toEqual({ mode: 'auto', effort: 'medium' }); + expect(config.thinking).toEqual({ enabled: true, effort: 'medium' }); expect(config.permission).toEqual({ rules: [ { @@ -361,7 +360,7 @@ removed_flag = true const config = readConfigFile(configPath); expect(config.providers).toEqual({}); expect(config.defaultModel).toBeUndefined(); - expect(config.defaultThinking).toBeUndefined(); + expect(config.thinking?.enabled).toBeUndefined(); }); it('does not overwrite an existing config file', async () => { @@ -501,7 +500,7 @@ describe('harness config schema and patch merge', () => { maxContextSize: 262144, capabilities: ['tool_use'], }); - expect(merged.thinking).toEqual({ mode: 'auto', effort: 'high' }); + expect(merged.thinking).toEqual({ enabled: true, effort: 'high' }); expect(merged.hooks).toEqual(base.hooks); expect(merged.raw?.['theme']).toBe('dark'); }); @@ -861,12 +860,12 @@ max_steps_per_turn = "nope" }); it('drops invalid top-level scalars and keeps the rest', async () => { - const configPath = await writeTempConfig(`default_thinking = "not-a-boolean" + const configPath = await writeTempConfig(`default_permission_mode = "not-a-mode" ${VALID_TOML}`); const result = loadRuntimeConfigSafe(configPath, {}); - expect(result.config.defaultThinking).toBeUndefined(); + expect(result.config.defaultPermissionMode).toBeUndefined(); expect(result.config.providers['kimi']).toBeDefined(); expect(result.fileWarnings).toHaveLength(1); - expect(result.fileWarnings[0]).toContain('default_thinking'); + expect(result.fileWarnings[0]).toContain('default_permission_mode'); }); }); diff --git a/packages/agent-core/test/config/env-model.test.ts b/packages/agent-core/test/config/env-model.test.ts index 3c17f0d4c..b9ac80e12 100644 --- a/packages/agent-core/test/config/env-model.test.ts +++ b/packages/agent-core/test/config/env-model.test.ts @@ -132,23 +132,13 @@ describe('applyEnvModelConfig', () => { ); }); - it('maps the thinking variables', () => { + it('maps the thinking effort variable', () => { const config = apply({ ...MIN, - KIMI_MODEL_DEFAULT_THINKING: 'true', - KIMI_MODEL_THINKING_MODE: 'on', KIMI_MODEL_THINKING_EFFORT: 'high', }); - expect(config.defaultThinking).toBe(true); - expect(config.thinking).toMatchObject({ mode: 'on', effort: 'high' }); - expect(apply({ ...MIN, KIMI_MODEL_DEFAULT_THINKING: '0' }).defaultThinking) - .toBe(false); - }); - - it('rejects an invalid thinking mode', () => { - expectConfigInvalid(() => - apply({ ...MIN, KIMI_MODEL_THINKING_MODE: 'bogus' }), - ); + expect(config.thinking).toMatchObject({ effort: 'high' }); + expect(config.thinking?.enabled).toBeUndefined(); }); it('maps KIMI_MODEL_ADAPTIVE_THINKING onto the alias', () => { @@ -238,20 +228,18 @@ describe('writeConfigFile never persists the env model', () => { const path = join(dir, 'config.toml'); writeFileSync( path, - 'default_model = "x"\ndefault_thinking = false\n[thinking]\nmode = "auto"\n[providers.x]\ntype = "kimi"\napi_key = "k"\n[models.x]\nprovider = "x"\nmodel = "x"\nmax_context_size = 1000\n', + 'default_model = "x"\n[thinking]\neffort = "medium"\n[providers.x]\ntype = "kimi"\napi_key = "k"\n[models.x]\nprovider = "x"\nmodel = "x"\nmax_context_size = 1000\n', ); try { // Reproduces the /login round-trip: a runtime config carrying the env // model AND env thinking overrides is written back and must persist none. const runtime = loadRuntimeConfig(path, { ...MIN, - KIMI_MODEL_THINKING_MODE: 'on', - KIMI_MODEL_DEFAULT_THINKING: 'true', + KIMI_MODEL_THINKING_EFFORT: 'high', }); // Sanity: env overrides are active at runtime. expect(runtime.providers[ENV_MODEL_PROVIDER_KEY]).toBeDefined(); - expect(runtime.thinking?.mode).toBe('on'); - expect(runtime.defaultThinking).toBe(true); + expect(runtime.thinking?.effort).toBe('high'); await writeConfigFile(path, runtime); const onDisk = readConfigFile(path); @@ -262,8 +250,7 @@ describe('writeConfigFile never persists the env model', () => { expect(onDisk.models?.['x']).toBeDefined(); expect(onDisk.defaultModel).toBe('x'); // Thinking is restored to the on-disk original, not the env override. - expect(onDisk.thinking?.mode).toBe('auto'); - expect(onDisk.defaultThinking).toBe(false); + expect(onDisk.thinking?.effort).toBe('medium'); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -280,7 +267,6 @@ describe('writeConfigFile never persists the env model', () => { const runtime = loadRuntimeConfig(path, { ...MIN, KIMI_MODEL_THINKING_EFFORT: 'low', - KIMI_MODEL_DEFAULT_THINKING: 'true', }); await writeConfigFile(path, runtime); const text = readFileSync(path, 'utf-8'); @@ -293,14 +279,3 @@ describe('writeConfigFile never persists the env model', () => { }); }); -describe('KIMI_MODEL_DEFAULT_THINKING validation', () => { - it('rejects a non-empty unparseable value', () => { - expectConfigInvalid(() => apply({ ...MIN, KIMI_MODEL_DEFAULT_THINKING: 'flase' })); - }); - - it('accepts valid values and ignores when unset', () => { - expect(apply({ ...MIN, KIMI_MODEL_DEFAULT_THINKING: 'true' }).defaultThinking).toBe(true); - expect(apply({ ...MIN, KIMI_MODEL_DEFAULT_THINKING: '0' }).defaultThinking).toBe(false); - expect(apply({ ...MIN }).defaultThinking).toBeUndefined(); - }); -}); diff --git a/packages/agent-core/test/harness/goal-session.test.ts b/packages/agent-core/test/harness/goal-session.test.ts index 7f4750fb9..5c1e45b20 100644 --- a/packages/agent-core/test/harness/goal-session.test.ts +++ b/packages/agent-core/test/harness/goal-session.test.ts @@ -99,7 +99,7 @@ async function setupSession( { type: 'main', generate: generate ?? scripted.generate }, { profile: goalProfile(tools) }, ); - agent.config.update({ modelAlias: 'mock-model', thinkingLevel: 'off' }); + agent.config.update({ modelAlias: 'mock-model', thinkingEffort: 'off' }); agent.permission.setMode('yolo'); return { session, agent, scripted }; } diff --git a/packages/agent-core/test/harness/model-alias-session.test.ts b/packages/agent-core/test/harness/model-alias-session.test.ts index c872bb08d..38aad6078 100644 --- a/packages/agent-core/test/harness/model-alias-session.test.ts +++ b/packages/agent-core/test/harness/model-alias-session.test.ts @@ -33,6 +33,9 @@ base_url = "https://api.example/v1" provider = "managed:kimi-code" model = "kimi-for-coding" max_context_size = 1000000 +capabilities = ["thinking"] +support_efforts = ["low", "medium", "high"] +default_effort = "high" `; describe('HarnessAPI session model aliases', () => { @@ -383,7 +386,7 @@ max_context_size = 1000000 const resumeRecords: TelemetryContextRecord[] = []; const resumeRpc = await createTestRpc({ telemetry: recordingContextTelemetry(resumeRecords) }); await resumeRpc.resumeSession({ sessionId: created.id }); - await resumeRpc.setThinking({ sessionId: created.id, agentId: 'main', level: 'off' }); + await resumeRpc.setThinking({ sessionId: created.id, agentId: 'main', effort: 'off' }); expect(resumeRecords).toContainEqual({ event: 'thinking_toggle', diff --git a/packages/agent-core/test/harness/runtime-provider.test.ts b/packages/agent-core/test/harness/runtime-provider.test.ts index 8e6585c6b..49cee31c1 100644 --- a/packages/agent-core/test/harness/runtime-provider.test.ts +++ b/packages/agent-core/test/harness/runtime-provider.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest'; -import type { KimiConfig } from '../../src/config'; +import type { KimiConfig, ModelAlias } from '../../src/config'; import { ErrorCodes, KimiError } from '../../src/errors'; import { ProviderManager } from '../../src/session/provider-manager'; -import { resolveThinkingLevel } from '../../src/agent/config/thinking'; +import { resolveThinkingEffort } from '../../src/agent/config/thinking'; // Thin wrapper that adapts the legacy `resolveRuntimeProvider(input)` shape to // the current ProviderManager API. Kept local so the existing test bodies do @@ -747,91 +747,64 @@ describe('ProviderManager OAuth auth', () => { }); }); -describe('resolveThinkingLevel', () => { - it('normalizes requested thinking into a concrete effort', () => { - expect( - resolveThinkingLevel('on', { - defaultThinking: false, - thinking: { effort: 'medium', mode: 'auto' }, - }), - ).toBe('medium'); - expect( - resolveThinkingLevel('off', { - defaultThinking: false, - thinking: { effort: 'medium', mode: 'auto' }, - }), - ).toBe('off'); - expect( - resolveThinkingLevel('low', { - defaultThinking: false, - thinking: { effort: 'medium', mode: 'auto' }, - }), - ).toBe('low'); - expect( - resolveThinkingLevel(undefined, { - defaultThinking: false, - thinking: { effort: 'medium', mode: 'auto' }, - }), - ).toBe('off'); - expect( - resolveThinkingLevel('', { - defaultThinking: false, - thinking: { effort: 'medium', mode: 'auto' }, - }), - ).toBe('off'); - expect( - resolveThinkingLevel(' ', { - defaultThinking: false, - thinking: { effort: 'medium', mode: 'auto' }, - }), - ).toBe('off'); - - expect( - resolveThinkingLevel(undefined, { - defaultThinking: true, - thinking: { effort: 'medium', mode: 'auto' }, - }), - ).toBe('medium'); - expect( - resolveThinkingLevel(' ', { - defaultThinking: true, - thinking: { effort: 'medium', mode: 'auto' }, - }), - ).toBe('medium'); - - expect( - resolveThinkingLevel('on', { - defaultThinking: true, - thinking: { mode: 'auto' }, - }), - ).toBe('high'); - expect( - resolveThinkingLevel(undefined, { - defaultThinking: true, - thinking: { mode: 'auto' }, - }), - ).toBe('high'); +describe('resolveThinkingEffort', () => { + const booleanModel: ModelAlias = { + provider: 'p', + model: 'm', + maxContextSize: 1, + capabilities: ['thinking'], + }; + const effortModel: ModelAlias = { + provider: 'p', + model: 'm', + maxContextSize: 1, + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'high'], + }; + const alwaysThinkingModel: ModelAlias = { + provider: 'p', + model: 'm', + maxContextSize: 1, + capabilities: ['thinking', 'always_thinking'], + }; + + it('returns the requested effort verbatim when one is provided', () => { + expect(resolveThinkingEffort('on', { effort: 'medium' }, booleanModel)).toBe('on'); + expect(resolveThinkingEffort('off', { effort: 'medium' }, booleanModel)).toBe('off'); + expect(resolveThinkingEffort('low', { effort: 'medium' }, booleanModel)).toBe('low'); + // No normalization: empty / whitespace strings are returned as-is. + expect(resolveThinkingEffort('', { enabled: false, effort: 'medium' }, booleanModel)).toBe(''); + expect(resolveThinkingEffort(' ', { enabled: false, effort: 'medium' }, booleanModel)).toBe( + ' ', + ); + }); + it('treats config.enabled=false as off when no effort is requested', () => { expect( - resolveThinkingLevel(undefined, { - thinking: { mode: 'off' }, - }), + resolveThinkingEffort(undefined, { enabled: false, effort: 'medium' }, booleanModel), ).toBe('off'); + expect(resolveThinkingEffort(undefined, { enabled: false }, booleanModel)).toBe('off'); + }); - expect( - resolveThinkingLevel(undefined, { - defaultThinking: true, - thinking: { effort: 'medium', mode: 'off' }, - }), - ).toBe('off'); - expect( - resolveThinkingLevel(' ', { - defaultThinking: true, - thinking: { effort: 'medium', mode: 'off' }, - }), - ).toBe('off'); + it('uses config.effort as the default effort when enabled', () => { + expect(resolveThinkingEffort(undefined, { effort: 'medium' }, booleanModel)).toBe('medium'); + expect(resolveThinkingEffort(undefined, { enabled: true, effort: 'medium' }, booleanModel)).toBe( + 'medium', + ); + }); + + it('falls back to the model default effort when no effort is set', () => { + // boolean thinking model -> 'on' + expect(resolveThinkingEffort(undefined, {}, booleanModel)).toBe('on'); + // effort-capable model -> middle supportEfforts entry + expect(resolveThinkingEffort(undefined, {}, effortModel)).toBe('medium'); + // no / non-thinking model -> 'off' + expect(resolveThinkingEffort(undefined, {}, undefined)).toBe('off'); + }); - expect(resolveThinkingLevel(undefined, {})).toBe('high'); + it('forces always-thinking models back on even when off is requested', () => { + expect(resolveThinkingEffort('off', { enabled: false }, alwaysThinkingModel)).toBe('on'); + expect(resolveThinkingEffort(undefined, { enabled: false }, alwaysThinkingModel)).toBe('on'); }); }); diff --git a/packages/agent-core/test/mcp/connection-manager.test.ts b/packages/agent-core/test/mcp/connection-manager.test.ts index 77078cf6a..c3be77b4f 100644 --- a/packages/agent-core/test/mcp/connection-manager.test.ts +++ b/packages/agent-core/test/mcp/connection-manager.test.ts @@ -871,7 +871,7 @@ describe('Session MCP startup', () => { cwd: tmp, modelAlias: 'mock-model', systemPrompt: 'test system prompt', - thinkingLevel: 'off', + thinkingEffort: 'off', }); // This bare agent gets no profile, so grant MCP access explicitly. agent.tools.setActiveTools(['mcp__*']); diff --git a/packages/agent-core/test/rpc/config-rpc.test.ts b/packages/agent-core/test/rpc/config-rpc.test.ts index 06ceb42a6..384cb91a2 100644 --- a/packages/agent-core/test/rpc/config-rpc.test.ts +++ b/packages/agent-core/test/rpc/config-rpc.test.ts @@ -81,7 +81,7 @@ max_steps_per_turn = "nope" // Write paths stay strict: changing settings on top of a broken file // must fail with a short, actionable message — not raw validation JSON — // and must leave the file untouched. - const write = core.setKimiConfig({ defaultThinking: true }); + const write = core.setKimiConfig({ thinking: { enabled: true } }); await expect(write).rejects.toThrow(/fix it first/i); await expect(write).rejects.toThrow(/kimi doctor/); await expect(write).rejects.not.toThrow(/invalid_type/); @@ -102,9 +102,9 @@ max_steps_per_turn = "nope" expect(degraded.warnings.some((w) => w.includes('Invalid TOML'))).toBe(true); expect(degraded.warnings.some((w) => w.includes('previous'))).toBe(true); - await writeFile(configPath, `default_thinking = true\n${VALID_TOML}`, 'utf-8'); + await writeFile(configPath, `[thinking]\nenabled = true\n${VALID_TOML}`, 'utf-8'); const adopted = await core.getKimiConfig({ reload: true }); - expect(adopted.defaultThinking).toBe(true); + expect(adopted.thinking?.enabled).toBe(true); await expect(core.getConfigDiagnostics({})).resolves.toEqual({ warnings: [] }); }); }); diff --git a/packages/agent-core/test/services/model-catalog-service.test.ts b/packages/agent-core/test/services/model-catalog-service.test.ts index 3abd27c2c..dbcd23707 100644 --- a/packages/agent-core/test/services/model-catalog-service.test.ts +++ b/packages/agent-core/test/services/model-catalog-service.test.ts @@ -57,7 +57,7 @@ function makeCore(configRef: { current: KimiConfig }): { next.models = payload.models as KimiConfig['models']; } if (payload.defaultModel !== undefined) next.defaultModel = payload.defaultModel; - if (payload.defaultThinking !== undefined) next.defaultThinking = payload.defaultThinking; + if (payload.thinking !== undefined) next.thinking = payload.thinking; configRef.current = next; return configRef.current; }), @@ -231,7 +231,7 @@ describe('ModelCatalogService', () => { }, }, defaultModel: 'kimi-code/kimi-for-coding', - defaultThinking: false, + thinking: { enabled: false }, models: { 'kimi-code/kimi-for-coding': { provider: KIMI_CODE_PROVIDER_NAME, @@ -266,7 +266,7 @@ describe('ModelCatalogService', () => { expect(removeCalls).toEqual([KIMI_CODE_PROVIDER_NAME]); expect(setCalls.at(-1)).toMatchObject({ defaultModel: 'kimi-code/kimi-for-coding', - defaultThinking: true, + thinking: { enabled: true }, models: { 'kimi-code/kimi-for-coding': { capabilities: ['thinking', 'always_thinking', 'tool_use'], diff --git a/packages/agent-core/test/services/prompt-service.test.ts b/packages/agent-core/test/services/prompt-service.test.ts index 414cde78b..3a8d85541 100644 --- a/packages/agent-core/test/services/prompt-service.test.ts +++ b/packages/agent-core/test/services/prompt-service.test.ts @@ -119,7 +119,7 @@ interface RpcRecord { interface BridgeStubOptions { /** Initial bootstrap values returned by getConfig/getPermission/getPlan. */ - config?: { modelAlias?: string; thinkingLevel?: string }; + config?: { modelAlias?: string; thinkingEffort?: string }; permission?: { mode: 'manual' | 'yolo' | 'auto' }; plan?: null | { id: string; content: string; path: string }; sessions?: SessionSummary[]; @@ -153,7 +153,7 @@ function makeBridge( const config = { cwd: '/tmp/ws', modelCapabilities: {} as unknown, - thinkingLevel: opts.config?.thinkingLevel ?? 'off', + thinkingEffort: opts.config?.thinkingEffort ?? 'off', systemPrompt: '', modelAlias: opts.config?.modelAlias ?? 'kimi-code/k2', }; @@ -1049,7 +1049,7 @@ describe('PromptService queue steer', () => { describe('PromptService stateless controls — bootstrap + shadow', () => { it('bootstraps shadow from getConfig/getPermission/getPlan on first submit', async () => { const { bridge, record } = makeBridge({ - config: { modelAlias: 'kimi-code/k2', thinkingLevel: 'medium' }, + config: { modelAlias: 'kimi-code/k2', thinkingEffort: 'medium' }, permission: { mode: 'yolo' }, plan: { id: 'plan_abc', content: '', path: '/tmp/p' }, }); @@ -1167,8 +1167,8 @@ describe('PromptService stateless controls — diff dispatch', () => { expect(impl._agentStateForTest(SID)?.model).toBe('kimi-code/k1'); }); - it('issues setThinking only when the body level differs from the shadow', async () => { - const { bridge, record } = makeBridge({ config: { thinkingLevel: 'off' } }); + it('issues setThinking only when the body effort differs from the shadow', async () => { + const { bridge, record } = makeBridge({ config: { thinkingEffort: 'off' } }); const { bus, triggerSubscribers } = makeBus(); const impl = newSvc(bridge, bus); await impl.submit(SID, mkBody({ thinking: 'off' })); @@ -1191,7 +1191,7 @@ describe('PromptService stateless controls — diff dispatch', () => { await impl.submit(SID, mkBody({ thinking: 'high' })); expect(record.setThinkingCalls).toEqual([ - { sessionId: SID, agentId: 'main', level: 'high' }, + { sessionId: SID, agentId: 'main', effort: 'high' }, ]); expect(impl._agentStateForTest(SID)?.thinking).toBe('high'); }); @@ -1366,7 +1366,7 @@ describe('PromptService stateless controls — dispatch log', () => { it('appends one entry per setter dispatched, in the order setModel/setThinking/setPermission/(enter|cancel)Plan', async () => { const { bridge } = makeBridge({ - config: { modelAlias: 'kimi-code/k2', thinkingLevel: 'off' }, + config: { modelAlias: 'kimi-code/k2', thinkingEffort: 'off' }, permission: { mode: 'manual' }, plan: null, }); @@ -1440,7 +1440,7 @@ describe('PromptService stateless controls — dispatch log', () => { it('bootstraps swarmMode from getSwarmMode', async () => { const { bridge, record } = makeBridge({ - config: { modelAlias: 'kimi-code/k2', thinkingLevel: 'off' }, + config: { modelAlias: 'kimi-code/k2', thinkingEffort: 'off' }, permission: { mode: 'manual' }, plan: null, }); @@ -1452,7 +1452,7 @@ describe('PromptService stateless controls — dispatch log', () => { it('dispatches enterSwarm/exitSwarm and records them in the log', async () => { const { bridge, record } = makeBridge({ - config: { modelAlias: 'kimi-code/k2', thinkingLevel: 'off' }, + config: { modelAlias: 'kimi-code/k2', thinkingEffort: 'off' }, permission: { mode: 'manual' }, plan: null, }); @@ -1493,7 +1493,7 @@ describe('PromptService stateless controls — dispatch log', () => { it('does not re-dispatch swarm_mode when it matches the shadow', async () => { const { bridge, record } = makeBridge({ - config: { modelAlias: 'kimi-code/k2', thinkingLevel: 'off' }, + config: { modelAlias: 'kimi-code/k2', thinkingEffort: 'off' }, permission: { mode: 'manual' }, plan: null, }); @@ -1523,7 +1523,7 @@ describe('PromptService stateless controls — dispatch log', () => { it('dispatches createGoal and records it in the log', async () => { const { bridge, record } = makeBridge({ - config: { modelAlias: 'kimi-code/k2', thinkingLevel: 'off' }, + config: { modelAlias: 'kimi-code/k2', thinkingEffort: 'off' }, permission: { mode: 'manual' }, plan: null, }); @@ -1543,7 +1543,7 @@ describe('PromptService stateless controls — dispatch log', () => { it('dispatches goal control actions and records them in the log', async () => { const { bridge, record } = makeBridge({ - config: { modelAlias: 'kimi-code/k2', thinkingLevel: 'off' }, + config: { modelAlias: 'kimi-code/k2', thinkingEffort: 'off' }, permission: { mode: 'manual' }, plan: null, }); @@ -1637,12 +1637,12 @@ describe('PromptService.applyAgentState (POST /sessions/{sid}/profile path)', () }); it('dispatches setThinking and records source="meta" when patch differs from shadow', async () => { - const { bridge, record } = makeBridge({ config: { thinkingLevel: 'off' } }); + const { bridge, record } = makeBridge({ config: { thinkingEffort: 'off' } }); const { bus } = makeBus(); const impl = newSvc(bridge, bus); await impl.applyAgentState(SID, { thinking: 'high' }, 'meta'); expect(record.setThinkingCalls).toEqual([ - { sessionId: SID, agentId: 'main', level: 'high' }, + { sessionId: SID, agentId: 'main', effort: 'high' }, ]); expect(impl._agentStateForTest(SID)?.thinking).toBe('high'); const log = impl._dispatchLogForTest(SID); @@ -1654,7 +1654,7 @@ describe('PromptService.applyAgentState (POST /sessions/{sid}/profile path)', () it('subsequent content-only submit observes the shadow set via /profile and dispatches nothing', async () => { const { bridge, record } = makeBridge({ - config: { thinkingLevel: 'off' }, + config: { thinkingEffort: 'off' }, permission: { mode: 'manual' }, }); const { bus } = makeBus(); diff --git a/packages/agent-core/test/services/session-service.test.ts b/packages/agent-core/test/services/session-service.test.ts index b84de9286..0d4eb0a08 100644 --- a/packages/agent-core/test/services/session-service.test.ts +++ b/packages/agent-core/test/services/session-service.test.ts @@ -192,7 +192,7 @@ function makeFakeBridge(state: FakeBridgeState): ICoreProcessService { }), getConfig: vi.fn().mockResolvedValue({ modelAlias: 'kimi-k2', - thinkingLevel: 'auto', + thinkingEffort: 'auto', modelCapabilities: { max_context_tokens: 100 }, }), getPermission: vi.fn().mockResolvedValue({ mode: 'manual' }), diff --git a/packages/agent-core/test/session/init.test.ts b/packages/agent-core/test/session/init.test.ts index 89657684a..2da472ac5 100644 --- a/packages/agent-core/test/session/init.test.ts +++ b/packages/agent-core/test/session/init.test.ts @@ -61,7 +61,7 @@ describe('Session.init', () => { ); mainAgent.config.update({ modelAlias: 'mock-model', - thinkingLevel: 'off', + thinkingEffort: 'off', }); mainAgent.tools.setActiveTools([]); events.length = 0; @@ -185,7 +185,7 @@ describe('Session.init', () => { const { agent } = await session.createAgent({ type: 'main' }, { profile: testProfile() }); agent.config.update({ modelAlias: 'mock-model', - thinkingLevel: 'off', + thinkingEffort: 'off', }); agent.tools.initializeBuiltinTools(); agent.tools.setActiveTools(['Read']); @@ -286,7 +286,7 @@ describe('AgentAPI.startBtw', () => { ); mainAgent.config.update({ modelAlias: 'mock-model', - thinkingLevel: 'off', + thinkingEffort: 'off', }); mainAgent.tools.setActiveTools(['Read']); registerLookupNoteTool(mainAgent); @@ -405,7 +405,7 @@ describe('AgentAPI.startBtw', () => { ); mainAgent.config.update({ modelAlias: 'mock-model', - thinkingLevel: 'off', + thinkingEffort: 'off', }); mainAgent.tools.setActiveTools(['Read']); registerLookupNoteTool(mainAgent); @@ -508,7 +508,7 @@ describe('AgentAPI.startBtw', () => { ); mainAgent.config.update({ modelAlias: 'mock-model', - thinkingLevel: 'off', + thinkingEffort: 'off', }); events.length = 0; diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 592c4a8ff..466000f13 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -295,7 +295,7 @@ describe('SessionSubagentHost', () => { cwd: parent.agent.config.cwd, provider: parent.agent.config.data().provider, profileName: 'explore', - thinkingLevel: parent.agent.config.thinkingLevel, + thinkingEffort: parent.agent.config.thinkingEffort, }); expect(child.agent.config.systemPrompt).toContain('codebase exploration specialist'); expect(child.agent.permission.mode).toBe('yolo'); diff --git a/packages/kosong/src/provider.ts b/packages/kosong/src/provider.ts index 1782f03e4..027f344ee 100644 --- a/packages/kosong/src/provider.ts +++ b/packages/kosong/src/provider.ts @@ -3,15 +3,20 @@ import type { Tool } from './tool'; import type { TokenUsage } from './usage'; /** - * Normalized thinking effort level used across providers. + * Thinking effort passed to {@link ChatProvider.withThinking}. * - * Values above `high` are provider/model-specific and may be clamped by the - * adapter when the native API has no matching level. OpenAI maps `max` to its - * `xhigh` ceiling; Kimi and Gemini cap `xhigh`/`max` at `high`; Anthropic - * supports `xhigh`/`max` only on selected models and otherwise clamps to - * `high`. + * `'off'` and `'on'` are the only reserved values: `'off'` disables thinking, + * and `'on'` is the on-signal for boolean models (models that do not declare + * `support_efforts`). Everything else is a model-declared effort (e.g. + * `"low"`, `"high"`, `"max"`) carried as an open string. The type collapses to + * `string` at runtime; it exists purely as a semantic marker that a value is + * expected to be `'off'`, `'on'`, or a model-declared effort. + * + * The model's `support_efforts` is the single source of truth for which + * efforts are valid — providers normalize any unrecognized effort by omitting + * the effort on the wire rather than rejecting it. */ -export type ThinkingEffort = 'off' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'; +export type ThinkingEffort = 'off' | 'on' | (string & {}); /** * Optional context passed to {@link ChatProvider.withMaxCompletionTokens} so a @@ -148,7 +153,7 @@ export interface ChatProvider { readonly name: string; /** Model name passed to the upstream API (e.g. `"moonshot-v1-auto"`). */ readonly modelName: string; - /** Current thinking-effort level, or `null` if thinking is not configured. */ + /** Current thinking effort, or `null` if thinking is not configured. */ readonly thinkingEffort: ThinkingEffort | null; /** * Send a conversation to the LLM and return a streamed response. diff --git a/packages/kosong/src/providers/anthropic.ts b/packages/kosong/src/providers/anthropic.ts index 4bbd94251..c6a3302f7 100644 --- a/packages/kosong/src/providers/anthropic.ts +++ b/packages/kosong/src/providers/anthropic.ts @@ -104,6 +104,11 @@ interface AnthropicGenerationKwargs { betaFeatures?: string[] | undefined; } +// Anthropic's native effort values. `ThinkingEffort` is an open string, so after +// clamping (and ruling out 'off') we narrow to this concrete set before writing +// `output_config.effort` / computing a token budget. +type AnthropicEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'; + const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14'; const OPUS_VERSION_RE = /opus[.-](\d+)[.-](\d{1,2})(?!\d)/; const ADAPTIVE_MIN_VERSION = { major: 4, minor: 6 } as const; @@ -331,6 +336,18 @@ function clampEffort(effort: ThinkingEffort, model: string, adaptive: boolean): if (effort === 'max' && !adaptive) { return 'high'; } + // 'on' (boolean models) or any effort Anthropic does not recognize: fall + // back to 'high' so budgetTokensForEffort / output_config.effort never see + // an unsupported value. + if ( + effort !== 'low' && + effort !== 'medium' && + effort !== 'high' && + effort !== 'xhigh' && + effort !== 'max' + ) { + return 'high'; + } return effort; } @@ -1193,10 +1210,11 @@ export class AnthropicChatProvider implements ChatProvider { return clone; } - const effectiveEffort = clampEffort(effort, this._model, adaptive); - if (effectiveEffort === 'off') { + const clamped = clampEffort(effort, this._model, adaptive); + if (clamped === 'off') { throw new Error('Non-off thinking effort unexpectedly clamped to off.'); } + const effectiveEffort = clamped as AnthropicEffort; let newBetas = [...(this._generationKwargs.betaFeatures ?? [])]; diff --git a/packages/kosong/src/providers/kimi.ts b/packages/kosong/src/providers/kimi.ts index 3a120c078..34f80d2ef 100644 --- a/packages/kosong/src/providers/kimi.ts +++ b/packages/kosong/src/providers/kimi.ts @@ -27,7 +27,6 @@ import { normalizeOpenAIFinishReason, type OpenAIContentPart, type OpenAIToolParam, - reasoningEffortToThinkingEffort, toolToOpenAI, } from './openai-common'; import { @@ -47,6 +46,10 @@ export interface KimiOptions { stream?: boolean | undefined; defaultHeaders?: Record | undefined; generationKwargs?: GenerationKwargs | undefined; + /** Efforts the model advertises (e.g. ["low", "high", "max"]). When + * present and non-empty, withThinking sends the chosen effort on the wire; + * when absent/empty, only thinking.type is sent. */ + supportEfforts?: readonly string[] | undefined; clientFactory?: (auth: ProviderRequestAuth) => OpenAI; } @@ -74,6 +77,7 @@ export interface GenerationKwargs { export interface ThinkingConfig { type?: 'enabled' | 'disabled'; + effort?: string; keep?: unknown; [key: string]: unknown; } @@ -365,6 +369,7 @@ export class KimiChatProvider implements ChatProvider { private _baseUrl: string; private _defaultHeaders: Record | undefined; private _generationKwargs: GenerationKwargs; + private readonly _supportEfforts: readonly string[]; private _client: OpenAI | undefined; private _clientFactory: ((auth: ProviderRequestAuth) => OpenAI) | undefined; private _files: KimiFiles | undefined; @@ -378,6 +383,7 @@ export class KimiChatProvider implements ChatProvider { this._model = options.model; this._stream = options.stream ?? true; this._generationKwargs = { ...options.generationKwargs }; + this._supportEfforts = options.supportEfforts ?? []; this._client = this._apiKey === undefined ? undefined @@ -414,7 +420,12 @@ export class KimiChatProvider implements ChatProvider { } get thinkingEffort(): ThinkingEffort | null { - return reasoningEffortToThinkingEffort(this._generationKwargs.reasoning_effort); + const thinking = this._generationKwargs.extra_body?.thinking; + if (thinking === undefined) return null; + if (thinking.type === 'disabled') return 'off'; + // `support_efforts` is the single source of truth for efforts: a + // model that sends thinking without an effort is a boolean ("on") model. + return thinking.effort ?? 'on'; } get modelParameters(): Record { @@ -499,28 +510,36 @@ export class KimiChatProvider implements ChatProvider { } withThinking(effort: ThinkingEffort): KimiChatProvider { - const thinking: ThinkingConfig = { - type: effort === 'off' ? 'disabled' : 'enabled', - }; + let thinking: ThinkingConfig; let reasoningEffort: string | undefined; - switch (effort) { - case 'off': - reasoningEffort = undefined; - break; - case 'low': - reasoningEffort = 'low'; - break; - case 'medium': - reasoningEffort = 'medium'; - break; - case 'high': - case 'xhigh': - case 'max': - reasoningEffort = 'high'; - break; + if (effort === 'off') { + thinking = { type: 'disabled' }; + } else { + // `support_efforts` is the single source of truth for efforts: only + // values the model declared are sent as effort. Everything else + // ('on', 'xhigh', or any unrecognized string) is normalized to "no + // effort" — thinking is enabled but the model picks its own effort. + const declared = this._supportEfforts.includes(effort) ? effort : undefined; + thinking = + declared !== undefined ? { type: 'enabled', effort: declared } : { type: 'enabled' }; + // TODO: drop reasoning_effort once the new thinking.effort wire format is + // fully rolled out across all kimi models. Until then mirror the same + // value so both code paths agree. + reasoningEffort = declared; + } + // Replace extra_body.thinking wholesale so a stale `effort` from a previous + // withThinking call can never linger on a disabled or non-effort thinking + // object — but carry over a `keep` set earlier via withExtraBody (the + // KIMI_MODEL_THINKING_KEEP path applies keep after withThinking and merges + // on top, so it is unaffected either way). + const oldExtra = this._generationKwargs.extra_body ?? {}; + const keep = oldExtra.thinking?.keep; + if (keep !== undefined) { + thinking = { ...thinking, keep }; } - return this._withGenerationKwargs({ reasoning_effort: reasoningEffort }).withExtraBody({ - thinking, + return this._withGenerationKwargs({ + reasoning_effort: reasoningEffort, + extra_body: { ...oldExtra, thinking }, }); } diff --git a/packages/kosong/src/providers/openai-common.ts b/packages/kosong/src/providers/openai-common.ts index 8727ef5ed..caba437c0 100644 --- a/packages/kosong/src/providers/openai-common.ts +++ b/packages/kosong/src/providers/openai-common.ts @@ -177,7 +177,10 @@ export function thinkingEffortToReasoningEffort(effort: ThinkingEffort): string case 'max': return 'xhigh'; default: - throw new Error(`Unknown thinking effort: ${String(effort)}`); + // 'on' (boolean models) or any model-declared effort OpenAI does not + // recognize: send no reasoning_effort and let the model use its own + // default, rather than throwing on a value the model itself advertised. + return undefined; } } diff --git a/packages/kosong/test/anthropic.test.ts b/packages/kosong/test/anthropic.test.ts index dceddc264..cbaecd67f 100644 --- a/packages/kosong/test/anthropic.test.ts +++ b/packages/kosong/test/anthropic.test.ts @@ -1750,7 +1750,7 @@ describe('AnthropicChatProvider', () => { expect(provider.thinkingEffort).toBe('high'); }); - it('pre-4.6 budget-based levels', () => { + it('pre-4.6 budget-based efforts', () => { const low = createProvider().withThinking('low'); expect(low.thinkingEffort).toBe('low'); diff --git a/packages/kosong/test/e2e/kimi-adapter-e2e.test.ts b/packages/kosong/test/e2e/kimi-adapter-e2e.test.ts index 50055d6c6..5c8018a6e 100644 --- a/packages/kosong/test/e2e/kimi-adapter-e2e.test.ts +++ b/packages/kosong/test/e2e/kimi-adapter-e2e.test.ts @@ -149,7 +149,6 @@ describe('e2e: kimi adapter', () => { model: 'kimi-k2-turbo-preview', stream: true, stream_options: { include_usage: true }, - reasoning_effort: 'high', thinking: { type: 'enabled' }, messages: [ { role: 'system', content: 'You are helpful.' }, @@ -229,7 +228,6 @@ describe('e2e: kimi adapter', () => { model: 'kimi-k2-turbo-preview', stream: true, stream_options: { include_usage: true }, - reasoning_effort: 'high', }); expect(harness.requests.length).toBeGreaterThanOrEqual(1); }); diff --git a/packages/kosong/test/kimi.test.ts b/packages/kosong/test/kimi.test.ts index e0ffcf249..a765afa94 100644 --- a/packages/kosong/test/kimi.test.ts +++ b/packages/kosong/test/kimi.test.ts @@ -22,11 +22,15 @@ function makeChatCompletionResponse(model: string = 'test-model') { }; } -function createProvider(stream: boolean = false): KimiChatProvider { +function createProvider( + stream: boolean = false, + supportEfforts?: readonly string[], +): KimiChatProvider { return new KimiChatProvider({ model: 'kimi-k2-turbo-preview', apiKey: 'test-key', stream, + supportEfforts, }); } @@ -675,7 +679,7 @@ describe('KimiChatProvider', () => { .withGenerationKwargs({ max_tokens: 512 }); expect(getGenerationState(provider)).toEqual({ - reasoning_effort: 'high', + reasoning_effort: undefined, extra_body: { thinking: { type: 'enabled' }, }, @@ -714,20 +718,43 @@ describe('KimiChatProvider', () => { }); describe('with thinking', () => { - it('hoists thinking to the top level and sets reasoning_effort for high', async () => { + it('non-effort model sends only thinking.type (no effort, no reasoning_effort)', async () => { const provider = createProvider().withThinking('high'); const history: Message[] = [ { role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] }, ]; const body = await captureRequestBody(provider, '', [], history); - expect(body['reasoning_effort']).toBe('high'); + expect(body['reasoning_effort']).toBeUndefined(); expect(body['thinking']).toEqual({ type: 'enabled' }); expect(body['extra_body']).toBeUndefined(); }); + it('effort-capable model sends thinking.effort and mirrors reasoning_effort', async () => { + const provider = createProvider(false, ['low', 'high', 'max']).withThinking('high'); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] }, + ]; + const body = await captureRequestBody(provider, '', [], history); + + expect(body['reasoning_effort']).toBe('high'); + expect(body['thinking']).toEqual({ type: 'enabled', effort: 'high' }); + expect(body['extra_body']).toBeUndefined(); + }); + + it('effort-capable model passes max through to both fields (no clamp)', async () => { + const provider = createProvider(false, ['low', 'high', 'max']).withThinking('max'); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] }, + ]; + const body = await captureRequestBody(provider, '', [], history); + + expect(body['reasoning_effort']).toBe('max'); + expect(body['thinking']).toEqual({ type: 'enabled', effort: 'max' }); + }); + it('hoists thinking disabled and clears reasoning_effort for off', async () => { - const provider = createProvider().withThinking('off'); + const provider = createProvider(false, ['low', 'high', 'max']).withThinking('off'); const history: Message[] = [ { role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] }, ]; @@ -738,20 +765,44 @@ describe('KimiChatProvider', () => { expect(body['extra_body']).toBeUndefined(); }); - it('thinkingEffort property reflects current state', () => { - const provider = createProvider(); + it('effort-capable model omits effort for efforts not declared in support_efforts', async () => { + // 'xhigh' / 'on' / 'foo' are not in ['low', 'high', 'max'], so the + // provider normalizes them to "enabled, no effort" instead of rejecting. + for (const effort of ['xhigh', 'on', 'foo']) { + const provider = createProvider(false, ['low', 'high', 'max']).withThinking(effort); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] }, + ]; + const body = await captureRequestBody(provider, '', [], history); + expect(body['reasoning_effort']).toBeUndefined(); + expect(body['thinking']).toEqual({ type: 'enabled' }); + } + }); + + it('thinkingEffort property reflects the configured effort', () => { + const provider = createProvider(false, ['low', 'high', 'max']); expect(provider.thinkingEffort).toBeNull(); - const withHigh = provider.withThinking('high'); - expect(withHigh.thinkingEffort).toBe('high'); + expect(provider.withThinking('high').thinkingEffort).toBe('high'); + expect(provider.withThinking('low').thinkingEffort).toBe('low'); + expect(provider.withThinking('max').thinkingEffort).toBe('max'); + expect(provider.withThinking('off').thinkingEffort).toBe('off'); + }); - const withLow = provider.withThinking('low'); - expect(withLow.thinkingEffort).toBe('low'); + it('thinkingEffort returns on for an enabled boolean (non-effort) model', () => { + const provider = createProvider(); + // A model without support_efforts carries no effort on the wire, so the + // getter falls back to 'on' regardless of the requested effort. + expect(provider.withThinking('high').thinkingEffort).toBe('on'); + expect(provider.withThinking('on').thinkingEffort).toBe('on'); }); it('replaces the previous thinking effort when called again', () => { - const provider = createProvider().withThinking('high').withThinking('off'); + const provider = createProvider(false, ['low', 'high', 'max']) + .withThinking('high') + .withThinking('off'); + // No stale `effort` lingers on the disabled thinking object. expect(getGenerationState(provider)).toEqual({ reasoning_effort: undefined, extra_body: { @@ -1246,9 +1297,10 @@ describe('KimiChatProvider', () => { }); describe('withThinking medium', () => { - it('maps medium -> reasoning_effort=medium', () => { - const provider = createProvider().withThinking('medium'); + it('maps medium -> reasoning_effort=medium for an effort-capable model', () => { + const provider = createProvider(false, ['low', 'medium', 'high']).withThinking('medium'); expect(provider.thinkingEffort).toBe('medium'); + expect(getGenerationState(provider).reasoning_effort).toBe('medium'); }); }); @@ -1265,7 +1317,7 @@ describe('KimiChatProvider', () => { }); it('field-merges thinking when called after withThinking', async () => { - const provider = createProvider() + const provider = createProvider(false, ['low', 'high', 'max']) .withThinking('high') .withExtraBody({ thinking: { keep: 'all' } }); const history: Message[] = [ @@ -1274,7 +1326,7 @@ describe('KimiChatProvider', () => { const body = await captureRequestBody(provider, '', [], history); expect(body['reasoning_effort']).toBe('high'); - expect(body['thinking']).toEqual({ type: 'enabled', keep: 'all' }); + expect(body['thinking']).toEqual({ type: 'enabled', effort: 'high', keep: 'all' }); expect(body['extra_body']).toBeUndefined(); }); diff --git a/packages/kosong/test/openai-common-errors.test.ts b/packages/kosong/test/openai-common-errors.test.ts index 52ebe6a25..5279fa6bb 100644 --- a/packages/kosong/test/openai-common-errors.test.ts +++ b/packages/kosong/test/openai-common-errors.test.ts @@ -330,10 +330,10 @@ describe('thinkingEffortToReasoningEffort', () => { it('maps max -> "xhigh"', () => { expect(thinkingEffortToReasoningEffort('max')).toBe('xhigh'); }); - it('throws on unknown effort', () => { - expect(() => thinkingEffortToReasoningEffort('extreme' as never)).toThrow( - /Unknown thinking effort/, - ); + it('normalizes unknown effort to undefined', () => { + // Unknown / model-declared efforts (including 'on') are tolerated: the + // provider omits reasoning_effort and lets the model use its own default. + expect(thinkingEffortToReasoningEffort('extreme' as never)).toBeUndefined(); }); }); describe('reasoningEffortToThinkingEffort', () => { diff --git a/packages/kosong/test/openai-responses.test.ts b/packages/kosong/test/openai-responses.test.ts index 5946b6309..cc7073a48 100644 --- a/packages/kosong/test/openai-responses.test.ts +++ b/packages/kosong/test/openai-responses.test.ts @@ -971,7 +971,7 @@ describe('OpenAIResponsesChatProvider', () => { it('with_thinking("max") on gpt-5.1-codex-max clamps up to xhigh on the wire', async () => { // Regression guard: "max" used to fall back to "high"; for OpenAI it - // must clamp up to their highest supported level, xhigh. + // must clamp up to their highest supported effort, xhigh. const provider = new OpenAIResponsesChatProvider({ model: 'gpt-5.1-codex-max', apiKey: 'test-key', diff --git a/packages/migration-legacy/test/steps/config.test.ts b/packages/migration-legacy/test/steps/config.test.ts index ba420e725..58a1088c8 100644 --- a/packages/migration-legacy/test/steps/config.test.ts +++ b/packages/migration-legacy/test/steps/config.test.ts @@ -17,7 +17,7 @@ afterEach(async () => { }); const OLD_CONFIG_TOML = `default_model = "internal-vibe" -default_thinking = true +merge_all_available_skills = true theme = "dark" default_editor = "code --wait" default_yolo = false @@ -56,7 +56,7 @@ describe('migrateConfigStep', () => { expect(r.migrated).toBe(true); expect(r.wroteSiblingDueToConflict).toBe(false); const cfg = await readFile(join(tgt, 'config.toml'), 'utf-8'); - expect(cfg).toContain('default_thinking = true'); + expect(cfg).toContain('merge_all_available_skills = true'); expect(cfg).not.toContain('"vllm"'); // dropped provider expect(cfg).not.toContain('"internal-vibe"'); // dropped model expect(cfg).not.toContain('theme'); // moved to tui @@ -69,14 +69,14 @@ describe('migrateConfigStep', () => { it('additively merges into a user-modified target config', async () => { await writeFile(join(src, 'config.toml'), OLD_CONFIG_TOML); - await writeFile(join(tgt, 'config.toml'), 'default_thinking = false\n'); + await writeFile(join(tgt, 'config.toml'), 'merge_all_available_skills = false\n'); const r = await migrateConfigStep({ sourceHome: src, targetHome: tgt }); expect(r.wroteSiblingDueToConflict).toBe(false); - // default_thinking is set on both, differently → target's value is kept + // merge_all_available_skills is set on both, differently → target's value is kept // and the key is reported as a conflict. - expect(r.configConflicts).toContain('default_thinking'); + expect(r.configConflicts).toContain('merge_all_available_skills'); const cfg = await readFile(join(tgt, 'config.toml'), 'utf-8'); - expect(cfg).toContain('default_thinking = false'); // target value kept + expect(cfg).toContain('merge_all_available_skills = false'); // target value kept expect(cfg).toContain('telemetry = true'); // additively brought over expect(cfg).toContain('kimi-code/kimi-for-coding'); // migrated model added }); @@ -106,23 +106,23 @@ base_url = "https://target.example/v1" it('drops top-level keys kimi-code does not support', async () => { await writeFile( join(src, 'config.toml'), - 'show_thinking_stream = true\ndefault_thinking = true\n', + 'show_thinking_stream = true\nmerge_all_available_skills = true\n', ); const r = await migrateConfigStep({ sourceHome: src, targetHome: tgt }); expect(r.droppedKeys).toContain('show_thinking_stream'); const cfg = await readFile(join(tgt, 'config.toml'), 'utf-8'); expect(cfg).not.toContain('show_thinking_stream'); - expect(cfg).toContain('default_thinking'); + expect(cfg).toContain('merge_all_available_skills'); }); it('falls back to a sibling file when the target config is unparseable', async () => { - await writeFile(join(src, 'config.toml'), 'default_thinking = true\n'); + await writeFile(join(src, 'config.toml'), 'merge_all_available_skills = true\n'); await writeFile(join(tgt, 'config.toml'), 'this is = = not valid toml [[['); const r = await migrateConfigStep({ sourceHome: src, targetHome: tgt }); expect(r.wroteSiblingDueToConflict).toBe(true); expect( await readFile(join(tgt, 'config.migrated-from-kimi-cli.toml'), 'utf-8'), - ).toContain('default_thinking'); + ).toContain('merge_all_available_skills'); // the unparseable target is left untouched expect(await readFile(join(tgt, 'config.toml'), 'utf-8')).toContain('not valid toml'); }); @@ -183,18 +183,18 @@ model = "kimi-for-coding" it('does not write an empty hooks array', async () => { // An empty `hooks` array yields no kept hooks, so no `hooks` key is written. - await writeFile(join(src, 'config.toml'), 'hooks = []\ndefault_thinking = true\n'); + await writeFile(join(src, 'config.toml'), 'hooks = []\nmerge_all_available_skills = true\n'); const r = await migrateConfigStep({ sourceHome: src, targetHome: tgt }); expect(r.migrated).toBe(true); const cfg = await readFile(join(tgt, 'config.toml'), 'utf-8'); expect(cfg).not.toContain('hooks'); - expect(cfg).toContain('default_thinking'); + expect(cfg).toContain('merge_all_available_skills'); }); it('drops default_model when it points at a model that was not kept', async () => { await writeFile( join(src, 'config.toml'), - 'default_model = "ghost-model"\ndefault_thinking = true\n', + 'default_model = "ghost-model"\nmerge_all_available_skills = true\n', ); await writeFile(join(tgt, 'config.toml'), DEFAULT_CONFIG_FILE_TEXT); const r = await migrateConfigStep({ sourceHome: src, targetHome: tgt }); @@ -203,7 +203,7 @@ model = "kimi-for-coding" // `ghost-model` has no [models."ghost-model"] entry — a dangling // default_model would fail the next session-create. expect(cfg).not.toContain('default_model'); - expect(cfg).toContain('default_thinking'); + expect(cfg).toContain('merge_all_available_skills'); }); it('drops a model whose provider has no entry anywhere', async () => { @@ -233,7 +233,7 @@ max_context_size = 1000 }); it('drops a supported top-level key whose value the schema rejects', async () => { - await writeFile(join(src, 'config.toml'), 'telemetry = "false"\ndefault_thinking = true\n'); + await writeFile(join(src, 'config.toml'), 'telemetry = "false"\nmerge_all_available_skills = true\n'); await writeFile(join(tgt, 'config.toml'), DEFAULT_CONFIG_FILE_TEXT); const r = await migrateConfigStep({ sourceHome: src, targetHome: tgt }); expect(r.migrated).toBe(true); @@ -242,13 +242,13 @@ max_context_size = 1000 expect(r.droppedKeys).toContain('telemetry'); const cfg = await readFile(join(tgt, 'config.toml'), 'utf-8'); expect(cfg).not.toContain('telemetry'); - expect(cfg).toContain('default_thinking'); + expect(cfg).toContain('merge_all_available_skills'); }); it('keeps default_model that points at a model only present in the target config', async () => { await writeFile( join(src, 'config.toml'), - 'default_model = "target-only"\ndefault_thinking = true\n', + 'default_model = "target-only"\nmerge_all_available_skills = true\n', ); // A user-modified target (merge mode) that already defines the alias. await writeFile( @@ -372,7 +372,7 @@ base_url = "https://target.example/v1" join(src, 'config.toml'), '[[hooks]]\nevent = "PreToolUse"\ncommand = "echo from-cli"\n', ); - await writeFile(join(tgt, 'config.toml'), 'default_thinking = false\n'); + await writeFile(join(tgt, 'config.toml'), 'merge_all_available_skills = false\n'); const r = await migrateConfigStep({ sourceHome: src, targetHome: tgt }); expect(r.migratedHooks).toBe(1); expect(r.configConflicts).not.toContain('hooks'); @@ -403,7 +403,7 @@ base_url = "https://target.example/v1" await writeFile( join(src, 'config.toml'), [ - 'default_thinking = true', + 'merge_all_available_skills = true', '[providers.openai]', 'type = "openai"', 'api_key = "k"', @@ -433,7 +433,7 @@ base_url = "https://target.example/v1" it('drops legacy migration fields but keeps supported loop and background fields', async () => { await writeFile( join(src, 'config.toml'), - 'default_thinking = true\n' + + 'merge_all_available_skills = true\n' + 'plan_mode = true\n' + 'yolo = true\n' + '[experimental]\n' + diff --git a/packages/node-sdk/examples/kimi-harness-config-smoke.ts b/packages/node-sdk/examples/kimi-harness-config-smoke.ts index 0294b8ff9..488f9be2b 100644 --- a/packages/node-sdk/examples/kimi-harness-config-smoke.ts +++ b/packages/node-sdk/examples/kimi-harness-config-smoke.ts @@ -17,7 +17,7 @@ async function main(): Promise { await harness.setConfig({ defaultModel: 'kimi-code/kimi-for-coding', - defaultThinking: true, + thinking: { enabled: true }, defaultPermissionMode: 'manual', defaultPlanMode: false, providers: { diff --git a/packages/node-sdk/src/catalog.ts b/packages/node-sdk/src/catalog.ts index 86687a960..27eeb6885 100644 --- a/packages/node-sdk/src/catalog.ts +++ b/packages/node-sdk/src/catalog.ts @@ -120,6 +120,6 @@ export function applyCatalogProvider( const defaultModel = `${options.providerId}/${options.selectedModelId}`; config.defaultModel = defaultModel; - config.defaultThinking = options.thinking; + config.thinking = { ...config.thinking, enabled: options.thinking }; return { defaultModel }; } diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 0b192d2a5..46b317829 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -80,7 +80,7 @@ export interface SetSessionModelRpcResult { } export interface SetSessionThinkingRpcInput extends SessionIdRpcInput { - readonly level: string; + readonly effort: string; } export interface SetSessionPermissionRpcInput extends SessionIdRpcInput { @@ -319,7 +319,7 @@ export abstract class SDKRpcClientBase { return rpc.setThinking({ sessionId: input.sessionId, agentId: this.interactiveAgentId, - level: input.level, + effort: input.effort, }); } @@ -467,7 +467,7 @@ export abstract class SDKRpcClientBase { usage.byModel !== undefined || usage.total !== undefined || usage.currentTurn !== undefined; return { model: config.modelAlias ?? config.provider?.model, - thinkingLevel: config.thinkingLevel, + thinkingEffort: config.thinkingEffort, permission: permission.mode, planMode: plan !== null, swarmMode, diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 9c73430c7..b0ed0560c 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -31,6 +31,7 @@ import type { SessionSummary, SessionUsage, SkillSummary, + ThinkingEffort, Unsubscribe, } from '#/types'; @@ -193,14 +194,14 @@ export class Session { await this.rpc.setModel({ sessionId: this.id, model: normalized }); } - async setThinking(level: string): Promise { + async setThinking(effort: ThinkingEffort): Promise { this.ensureOpen(); const normalized = normalizeRequiredString( - level, - 'Session thinking level cannot be empty', + effort, + 'Session thinking effort cannot be empty', ErrorCodes.SESSION_THINKING_EMPTY, ); - await this.rpc.setThinking({ sessionId: this.id, level: normalized }); + await this.rpc.setThinking({ sessionId: this.id, effort: normalized }); } async setPermission(mode: PermissionMode): Promise { diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 9bc328de7..35ff78593 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -65,7 +65,7 @@ export type { export type { KimiHostIdentity, OAuthRefreshOutcome }; export type { TelemetryClient, TelemetryContextPatch, TelemetryProperties }; -export type { ContentPart, Role, ToolCall } from '@moonshot-ai/kosong'; +export type { ContentPart, Role, ThinkingEffort, ToolCall } from '@moonshot-ai/kosong'; export type PermissionMode = 'yolo' | 'manual' | 'auto'; @@ -197,7 +197,7 @@ export interface SessionUsage { export interface SessionStatus { readonly model?: string; - readonly thinkingLevel: string; + readonly thinkingEffort: string; readonly permission: PermissionMode; readonly planMode: boolean; readonly swarmMode?: boolean | undefined; diff --git a/packages/node-sdk/test/catalog.test.ts b/packages/node-sdk/test/catalog.test.ts index 42dca028e..069f9d38f 100644 --- a/packages/node-sdk/test/catalog.test.ts +++ b/packages/node-sdk/test/catalog.test.ts @@ -88,7 +88,7 @@ describe('applyCatalogProvider', () => { maxContextSize: 200000, }); expect(config.defaultModel).toBe('anthropic/m1'); - expect(config.defaultThinking).toBe(true); + expect(config.thinking?.enabled).toBe(true); }); it('writes interleaved reasoning key from a catalog-selected model alias', () => { diff --git a/packages/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index 2dacfd3ef..91bff92d7 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -35,7 +35,6 @@ async function makeTempDir(): Promise { const COMPLETE_TOML = ` default_model = "kimi-for-coding" -default_thinking = false default_permission_mode = "auto" skip_afk_prompt_injection = false default_plan_mode = false @@ -84,6 +83,10 @@ api_key = "sk-fetch" [notifications] claim_stale_after_ms = 15000 + +[thinking] +enabled = true +effort = "high" `; describe('SDK config TOML', () => { @@ -125,7 +128,8 @@ max_context_size = "large" const config = parseConfigString(COMPLETE_TOML, 'complete.toml'); expect(config.defaultModel).toBe('kimi-for-coding'); - expect(config.defaultThinking).toBe(false); + expect(config.thinking?.enabled).toBe(true); + expect(config.thinking?.effort).toBe('high'); expect(config.defaultPermissionMode).toBe('auto'); expect(config.defaultPlanMode).toBe(false); expect(config.mergeAllAvailableSkills).toBe(true); @@ -368,7 +372,7 @@ micro_compaction = false const config = await harness.getConfig({ reload: true }); expect(config.providers).toEqual({}); expect(config.defaultModel).toBeUndefined(); - expect(config.defaultThinking).toBeUndefined(); + expect(config.thinking?.enabled).toBeUndefined(); }); it('reloads an active session without closing the SDK session wrapper', async () => { diff --git a/packages/node-sdk/test/create-session-transport.test.ts b/packages/node-sdk/test/create-session-transport.test.ts index 1210719be..9a696d176 100644 --- a/packages/node-sdk/test/create-session-transport.test.ts +++ b/packages/node-sdk/test/create-session-transport.test.ts @@ -599,11 +599,11 @@ effort = "medium" homeDir, session.id, 'config.update', - (event) => event['thinkingLevel'] === 'low', + (event) => event['thinkingEffort'] === 'low', ), ).resolves.toMatchObject({ type: 'config.update', - thinkingLevel: 'low', + thinkingEffort: 'low', }); await expect( waitForAgentWireEvent( diff --git a/packages/node-sdk/test/session-set-thinking.test.ts b/packages/node-sdk/test/session-set-thinking.test.ts index 6664bb021..f305caef0 100644 --- a/packages/node-sdk/test/session-set-thinking.test.ts +++ b/packages/node-sdk/test/session-set-thinking.test.ts @@ -12,7 +12,7 @@ afterEach(async () => { }); describe('Session.setThinking', () => { - it('sends config.update with the new thinking level', async () => { + it('sends config.update with the new thinking effort', async () => { const homeDir = await makeTempDir(tempDirs, 'kimi-sdk-thinking-home-'); const workDir = await makeTempDir(tempDirs, 'kimi-sdk-thinking-work-'); const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); @@ -27,18 +27,18 @@ describe('Session.setThinking', () => { homeDir, session.id, 'config.update', - (event) => event['thinkingLevel'] === 'low', + (event) => event['thinkingEffort'] === 'low', ), ).resolves.toMatchObject({ type: 'config.update', - thinkingLevel: 'low', + thinkingEffort: 'low', }); } finally { await harness.close(); } }); - it('rejects empty thinking levels', async () => { + it('rejects empty thinking efforts', async () => { const homeDir = await makeTempDir(tempDirs, 'kimi-sdk-thinking-home-'); const workDir = await makeTempDir(tempDirs, 'kimi-sdk-thinking-work-'); const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); diff --git a/packages/oauth/src/custom-registry.ts b/packages/oauth/src/custom-registry.ts index 0c5d720f7..4301178de 100644 --- a/packages/oauth/src/custom-registry.ts +++ b/packages/oauth/src/custom-registry.ts @@ -295,10 +295,15 @@ export function applyCustomRegistryProvider( }; const existingModels = config.models ?? {}; - // Drop stale aliases for the same provider before re-populating, mirroring - // applyOpenPlatformConfig's refresh semantics. + // Selectively merge upstream models into the existing config so any fields + // the user added by hand (or that upstream does not declare) survive a + // refresh. Models that upstream no longer lists are removed; the rest are + // merged field-by-field. + const upstreamKeys = new Set( + Object.keys(entry.models).map((modelKey) => `${providerKey}/${modelKey}`), + ); for (const [key, alias] of Object.entries(existingModels)) { - if (isRecord(alias) && alias['provider'] === providerKey) { + if (isRecord(alias) && alias['provider'] === providerKey && !upstreamKeys.has(key)) { delete existingModels[key]; } } @@ -309,8 +314,10 @@ export function applyCustomRegistryProvider( const capabilities = resolveCapabilities(model); const displayName = typeof model.name === 'string' && model.name.length > 0 ? model.name : model.id; + const existing = isRecord(existingModels[aliasKey]) ? existingModels[aliasKey] : {}; existingModels[aliasKey] = { + ...existing, provider: providerKey, model: model.id, maxContextSize, diff --git a/packages/oauth/src/managed-kimi-code.ts b/packages/oauth/src/managed-kimi-code.ts index 8df656a9f..d2d9be765 100644 --- a/packages/oauth/src/managed-kimi-code.ts +++ b/packages/oauth/src/managed-kimi-code.ts @@ -34,6 +34,8 @@ export interface ManagedKimiCodeModelInfo { readonly supportsVideoIn: boolean; readonly supportsToolUse?: boolean; readonly supportsThinkingType?: SupportsThinkingType; + readonly supportEfforts?: readonly string[]; + readonly defaultEffort?: string; readonly displayName?: string | undefined; readonly protocol?: ManagedKimiCodeProtocol | undefined; } @@ -125,6 +127,8 @@ export interface ManagedKimiModelAlias { model: string; maxContextSize: number; capabilities?: string[] | undefined; + supportEfforts?: readonly string[] | undefined; + defaultEffort?: string | undefined; displayName?: string | undefined; protocol?: ManagedKimiCodeProtocol; readonly [key: string]: unknown; @@ -142,11 +146,17 @@ export interface ManagedKimiServicesConfig { readonly [key: string]: unknown; } +export interface ManagedKimiThinkingShape { + enabled?: boolean | undefined; + effort?: string | undefined; + [key: string]: unknown; +} + export interface ManagedKimiConfigShape { providers: Record>; models?: Record> | undefined; defaultModel?: string | undefined; - defaultThinking?: boolean | undefined; + thinking?: ManagedKimiThinkingShape | undefined; services?: ManagedKimiServicesConfig | undefined; [key: string]: unknown; } @@ -383,6 +393,9 @@ function toModelInfo(item: unknown): ManagedKimiCodeModelInfo | undefined { const supportsToolUse = Object.hasOwn(item, 'supports_tool_use') ? Boolean(item['supports_tool_use']) : true; + // Effort levels come from the nested `think_efforts` object + // ({ support, valid_efforts, default_effort }) returned by /models. + const thinkEfforts = parseThinkEfforts(item['think_efforts']); return { id: item['id'], contextLength, @@ -391,17 +404,53 @@ function toModelInfo(item: unknown): ManagedKimiCodeModelInfo | undefined { supportsVideoIn: Boolean(item['supports_video_in']), supportsToolUse, supportsThinkingType: parseSupportsThinkingType(item['supports_thinking_type']), + supportEfforts: thinkEfforts.supportEfforts, + defaultEffort: thinkEfforts.defaultEffort, displayName: normalizedDisplayName, protocol: parseModelProtocol(item['protocol']), }; } +export function parseStringArray(value: unknown): readonly string[] | undefined { + if (!Array.isArray(value)) return undefined; + const out = value.filter((v): v is string => typeof v === 'string' && v.length > 0); + return out.length > 0 ? out : undefined; +} + // Unknown or missing values resolve to undefined so callers fall back to the // legacy supports_reasoning boolean instead of guessing. export function parseSupportsThinkingType(value: unknown): SupportsThinkingType | undefined { return value === 'only' || value === 'no' || value === 'both' ? value : undefined; } +/** + * Parse the nested `think_efforts` object from `/models`: + * { "support": true, "valid_efforts": ["low", "high", "max"], "default_effort": "high" } + * Returns the effort list and default effort, or undefineds when absent so + * callers can fall back to the legacy flat `support_efforts` / `default_effort` + * fields on older servers. + */ +export function parseThinkEfforts(value: unknown): { + supportEfforts: readonly string[] | undefined; + defaultEffort: string | undefined; +} { + if (value === null || typeof value !== 'object') { + return { supportEfforts: undefined, defaultEffort: undefined }; + } + const record = value as Record; + // `support` gates the whole object: when it is not true, ignore + // valid_efforts / default_effort entirely. + if (record['support'] !== true) { + return { supportEfforts: undefined, defaultEffort: undefined }; + } + const rawDefault = record['default_effort']; + return { + supportEfforts: parseStringArray(record['valid_efforts']), + defaultEffort: + typeof rawDefault === 'string' && rawDefault.length > 0 ? rawDefault : undefined, + }; +} + export async function fetchManagedKimiCodeModels( options: FetchManagedKimiCodeModelsOptions, ): Promise { @@ -470,26 +519,41 @@ export function applyManagedKimiCodeConfig( oauth, }; + // Selectively merge upstream models into the existing config so any fields + // the user added by hand (or that upstream does not declare) survive a + // refresh. Managed models that upstream no longer lists are removed; the + // rest are merged field-by-field — upstream-owned fields are overwritten, + // everything else is preserved. + const upstreamKeys = new Set(options.models.map((m) => managedModelKey(m.id))); for (const [key, model] of Object.entries(existingModels)) { - if (isRecord(model) && model['provider'] === KIMI_CODE_PROVIDER_NAME) { + if ( + isRecord(model) && + model['provider'] === KIMI_CODE_PROVIDER_NAME && + !upstreamKeys.has(key) + ) { delete existingModels[key]; } } for (const model of options.models) { const capabilities = capabilitiesForModel(model); - existingModels[managedModelKey(model.id)] = { + const key = managedModelKey(model.id); + const existing = isRecord(existingModels[key]) ? existingModels[key] : {}; + existingModels[key] = { + ...existing, provider: KIMI_CODE_PROVIDER_NAME, model: model.id, maxContextSize: model.contextLength, capabilities, - displayName: model.displayName, + ...(model.displayName !== undefined ? { displayName: model.displayName } : {}), + ...(model.supportEfforts !== undefined ? { supportEfforts: model.supportEfforts } : {}), + ...(model.defaultEffort !== undefined ? { defaultEffort: model.defaultEffort } : {}), protocol: model.protocol, }; } config.models = existingModels; config.defaultModel = selectedDefault.modelKey; - config.defaultThinking = selectedDefault.thinking; + config.thinking = { ...config.thinking, enabled: selectedDefault.thinking }; config.services = { moonshotSearch: { baseUrl: `${baseUrl}/search`, @@ -538,7 +602,7 @@ export function applyManagedKimiCodeLogoutConfig(config: ManagedKimiConfigShape) } } -// The server's three-state declaration overrides any stale defaultThinking +// The server's three-state declaration overrides any stale thinking.enabled // being preserved from an earlier config: an always-thinking model ('only') // must never end up with thinking off, and a non-thinking model ('no') must // never end up with thinking on. @@ -578,14 +642,14 @@ function selectDefaultModel( modelKey: currentDefault, thinking: forcedThinking( preservedModel, - config.defaultThinking ?? preservedModel?.supportsReasoning ?? false, + config.thinking?.enabled ?? preservedModel?.supportsReasoning ?? false, ), }; } return { modelKey: managedModelKey(firstModel.id), - thinking: forcedThinking(firstModel, config.defaultThinking ?? firstModel.supportsReasoning), + thinking: forcedThinking(firstModel, config.thinking?.enabled ?? firstModel.supportsReasoning), }; } diff --git a/packages/oauth/src/open-platform.ts b/packages/oauth/src/open-platform.ts index 0b6f74433..9b0943adf 100644 --- a/packages/oauth/src/open-platform.ts +++ b/packages/oauth/src/open-platform.ts @@ -1,6 +1,6 @@ import { readApiErrorMessage } from './api-error'; import { isRecord } from './utils'; -import { parseSupportsThinkingType } from './managed-kimi-code'; +import { parseSupportsThinkingType, parseThinkEfforts } from './managed-kimi-code'; import type { ManagedKimiCodeModelInfo, ManagedKimiConfigShape, @@ -55,6 +55,9 @@ function toModelInfo(item: unknown): ManagedKimiCodeModelInfo | undefined { const supportsToolUse = Object.hasOwn(item, 'supports_tool_use') ? Boolean(item['supports_tool_use']) : true; + // Effort levels come from the nested `think_efforts` object + // ({ support, valid_efforts, default_effort }) returned by /models. + const thinkEfforts = parseThinkEfforts(item['think_efforts']); return { id: item['id'], contextLength, @@ -63,6 +66,8 @@ function toModelInfo(item: unknown): ManagedKimiCodeModelInfo | undefined { supportsVideoIn: Boolean(item['supports_video_in']), supportsToolUse, supportsThinkingType: parseSupportsThinkingType(item['supports_thinking_type']), + supportEfforts: thinkEfforts.supportEfforts, + defaultEffort: thinkEfforts.defaultEffort, displayName: normalizedDisplayName, }; } @@ -164,26 +169,35 @@ export function applyOpenPlatformConfig( }; const existingModels = config.models ?? {}; + // Selectively merge upstream models into the existing config so any fields + // the user added by hand (or that upstream does not declare) survive a + // refresh. Models that upstream no longer lists are removed; the rest are + // merged field-by-field. + const upstreamKeys = new Set(options.models.map((m) => `${providerKey}/${m.id}`)); for (const [key, model] of Object.entries(existingModels)) { - if (isRecord(model) && model['provider'] === providerKey) { + if (isRecord(model) && model['provider'] === providerKey && !upstreamKeys.has(key)) { delete existingModels[key]; } } for (const model of options.models) { const aliasKey = `${providerKey}/${model.id}`; + const existing = isRecord(existingModels[aliasKey]) ? existingModels[aliasKey] : {}; existingModels[aliasKey] = { + ...existing, provider: providerKey, model: model.id, maxContextSize: model.contextLength, capabilities: capabilitiesForModel(model), - displayName: model.displayName, + ...(model.displayName !== undefined ? { displayName: model.displayName } : {}), + ...(model.supportEfforts !== undefined ? { supportEfforts: model.supportEfforts } : {}), + ...(model.defaultEffort !== undefined ? { defaultEffort: model.defaultEffort } : {}), }; } config.models = existingModels; config.defaultModel = modelKey; - config.defaultThinking = options.thinking; + config.thinking = { ...config.thinking, enabled: options.thinking }; return { defaultModel: modelKey, defaultThinking: options.thinking }; } diff --git a/packages/oauth/test/custom-registry.test.ts b/packages/oauth/test/custom-registry.test.ts index f781ce5a4..df10415e5 100644 --- a/packages/oauth/test/custom-registry.test.ts +++ b/packages/oauth/test/custom-registry.test.ts @@ -319,6 +319,41 @@ describe('applyCustomRegistryProvider', () => { expect(config.models?.['registry_chat-completions/gpt-5.5']).toBeDefined(); expect(config.models?.['other/keepme']).toBeDefined(); }); + + it('preserves hand-edited fields that upstream does not declare', () => { + const config: ManagedKimiConfigShape = { + providers: {}, + models: { + 'registry_chat-completions/gpt-5.5': { + provider: 'registry_chat-completions', + model: 'gpt-5.5', + maxContextSize: 131072, + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'high', + } as Record, + }, + }; + + applyCustomRegistryProvider( + config, + { + id: 'registry_chat-completions', + name: 'Sample Registry (chat completions)', + api: 'https://registry.example.test/v1', + type: 'openai', + models: { + 'gpt-5.5': { id: 'gpt-5.5', name: 'GPT 5.5' }, + }, + }, + KOKUB_SOURCE, + ); + + const alias = config.models?.['registry_chat-completions/gpt-5.5']; + expect(alias?.['supportEfforts']).toEqual(['low', 'high', 'max']); + expect(alias?.['defaultEffort']).toBe('high'); + // Upstream-owned fields are still refreshed. + expect(alias?.['displayName']).toBe('GPT 5.5'); + }); }); describe('removeCustomRegistryProvider', () => { diff --git a/packages/oauth/test/managed-kimi-code.test.ts b/packages/oauth/test/managed-kimi-code.test.ts index 53731e153..f3858c2d2 100644 --- a/packages/oauth/test/managed-kimi-code.test.ts +++ b/packages/oauth/test/managed-kimi-code.test.ts @@ -357,7 +357,7 @@ describe('provisionManagedKimiCodeConfig', () => { }, }, defaultModel: 'custom-default', - defaultThinking: false, + thinking: { enabled: false }, models: { 'custom-default': { provider: 'custom', @@ -386,7 +386,7 @@ describe('provisionManagedKimiCodeConfig', () => { expect(result.defaultModel).toBe('custom-default'); expect(result.defaultThinking).toBe(false); expect(config.defaultModel).toBe('custom-default'); - expect(config.defaultThinking).toBe(false); + expect(config.thinking?.enabled).toBe(false); expect(config.models?.['kimi-code/stale']).toBeUndefined(); expect(config.models?.['kimi-code/kimi-for-coding']?.displayName).toBe('Kimi for Coding'); }); @@ -423,7 +423,7 @@ describe('provisionManagedKimiCodeConfig', () => { expect(result.defaultModel).toBe('kimi-code/kimi-for-coding'); expect(result.defaultThinking).toBe(true); - expect(config.defaultThinking).toBe(true); + expect(config.thinking?.enabled).toBe(true); }); it('preserves explicit default_thinking when preserving a custom default without capabilities', async () => { @@ -435,7 +435,7 @@ describe('provisionManagedKimiCodeConfig', () => { }, }, defaultModel: 'custom-default', - defaultThinking: true, + thinking: { enabled: true }, models: { 'custom-default': { provider: 'custom', @@ -458,7 +458,7 @@ describe('provisionManagedKimiCodeConfig', () => { expect(result.defaultModel).toBe('custom-default'); expect(result.defaultThinking).toBe(true); - expect(config.defaultThinking).toBe(true); + expect(config.thinking?.enabled).toBe(true); }); it('defaults default_thinking to false when a preserved custom default has no signal', async () => { @@ -492,7 +492,7 @@ describe('provisionManagedKimiCodeConfig', () => { expect(result.defaultModel).toBe('custom-default'); expect(result.defaultThinking).toBe(false); - expect(config.defaultThinking).toBe(false); + expect(config.thinking?.enabled).toBe(false); }); it('does not infer default_thinking from preserved custom default capabilities', async () => { @@ -527,7 +527,7 @@ describe('provisionManagedKimiCodeConfig', () => { expect(result.defaultModel).toBe('custom-default'); expect(result.defaultThinking).toBe(false); - expect(config.defaultThinking).toBe(false); + expect(config.thinking?.enabled).toBe(false); }); it('keeps default_thinking off even when preserved custom default has thinking capability', async () => { @@ -562,7 +562,7 @@ describe('provisionManagedKimiCodeConfig', () => { expect(result.defaultModel).toBe('custom-default'); expect(result.defaultThinking).toBe(false); - expect(config.defaultThinking).toBe(false); + expect(config.thinking?.enabled).toBe(false); }); it('falls back to the first fetched model when the preserved default was removed', async () => { @@ -574,7 +574,7 @@ describe('provisionManagedKimiCodeConfig', () => { }, }, defaultModel: 'kimi-code/stale', - defaultThinking: false, + thinking: { enabled: false }, models: { 'kimi-code/stale': { provider: KIMI_CODE_PROVIDER_NAME, @@ -598,7 +598,7 @@ describe('provisionManagedKimiCodeConfig', () => { expect(result.defaultModel).toBe('kimi-code/kimi-for-coding'); expect(result.defaultThinking).toBe(false); expect(config.defaultModel).toBe('kimi-code/kimi-for-coding'); - expect(config.defaultThinking).toBe(false); + expect(config.thinking?.enabled).toBe(false); }); it('removes managed provider, models, services, and default model on logout', () => { @@ -614,7 +614,7 @@ describe('provisionManagedKimiCodeConfig', () => { }, }, defaultModel: 'kimi-code/kimi-for-coding', - defaultThinking: true, + thinking: { enabled: true }, models: { 'kimi-code/kimi-for-coding': { provider: KIMI_CODE_PROVIDER_NAME, @@ -945,7 +945,7 @@ describe('supports_thinking_type', () => { }); it('forces default thinking on when the selected default model is thinking-only', async () => { - const config: ManagedKimiConfigShape = { providers: {}, defaultThinking: false }; + const config: ManagedKimiConfigShape = { providers: {}, thinking: { enabled: false } }; const result = await provisionManagedKimiCodeConfig({ accessToken: 'oauth-access-token', @@ -959,7 +959,7 @@ describe('supports_thinking_type', () => { expect(result.defaultModel).toBe('kimi-code/kimi-for-coding'); expect(result.defaultThinking).toBe(true); - expect(config.defaultThinking).toBe(true); + expect(config.thinking?.enabled).toBe(true); }); it('forces default thinking on when preserving a thinking-only managed default', async () => { @@ -971,7 +971,7 @@ describe('supports_thinking_type', () => { }, }, defaultModel: 'kimi-code/kimi-for-coding', - defaultThinking: false, + thinking: { enabled: false }, models: { 'kimi-code/kimi-for-coding': { provider: KIMI_CODE_PROVIDER_NAME, @@ -995,7 +995,7 @@ describe('supports_thinking_type', () => { expect(result.defaultModel).toBe('kimi-code/kimi-for-coding'); expect(result.defaultThinking).toBe(true); - expect(config.defaultThinking).toBe(true); + expect(config.thinking?.enabled).toBe(true); }); it('forces default thinking off when preserving a no-thinking managed default', async () => { @@ -1007,7 +1007,7 @@ describe('supports_thinking_type', () => { }, }, defaultModel: 'kimi-code/kimi-plain', - defaultThinking: true, + thinking: { enabled: true }, models: { 'kimi-code/kimi-plain': { provider: KIMI_CODE_PROVIDER_NAME, @@ -1031,7 +1031,7 @@ describe('supports_thinking_type', () => { expect(result.defaultModel).toBe('kimi-code/kimi-plain'); expect(result.defaultThinking).toBe(false); - expect(config.defaultThinking).toBe(false); + expect(config.thinking?.enabled).toBe(false); }); it('keeps a preserved non-managed default thinking selection untouched', async () => { @@ -1043,7 +1043,7 @@ describe('supports_thinking_type', () => { }, }, defaultModel: 'custom-default', - defaultThinking: false, + thinking: { enabled: false }, models: { 'custom-default': { provider: 'custom', @@ -1066,7 +1066,233 @@ describe('supports_thinking_type', () => { expect(result.defaultModel).toBe('custom-default'); expect(result.defaultThinking).toBe(false); - expect(config.defaultThinking).toBe(false); + expect(config.thinking?.enabled).toBe(false); + }); +}); + +describe('support_efforts / default_effort', () => { + function makeEffortModelsResponse(): Response { + return new Response( + JSON.stringify({ + data: [ + { + id: 'kimi-for-coding', + context_length: 262144, + supports_reasoning: true, + supports_thinking_type: 'both', + think_efforts: { + support: true, + valid_efforts: ['low', 'high', 'max'], + default_effort: 'high', + }, + display_name: 'Kimi For Coding', + }, + { + // Empty / non-string entries are filtered; absent fields stay undefined. + id: 'kimi-plain', + context_length: 128000, + supports_reasoning: true, + think_efforts: { support: true, valid_efforts: ['low', '', 42] }, + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + + it('parses think_efforts from the models endpoint', async () => { + const models = await fetchManagedKimiCodeModels({ + accessToken: 'oauth-access-token', + fetchImpl: vi.fn(async () => makeEffortModelsResponse()) as unknown as typeof fetch, + }); + + expect(models[0]?.supportEfforts).toEqual(['low', 'high', 'max']); + expect(models[0]?.defaultEffort).toBe('high'); + // The empty string and number are filtered out of valid_efforts. + expect(models[1]?.supportEfforts).toEqual(['low']); + expect(models[1]?.defaultEffort).toBeUndefined(); + }); + + it('ignores think_efforts entirely when support is not true', async () => { + const models = await fetchManagedKimiCodeModels({ + accessToken: 'oauth-access-token', + fetchImpl: async () => + new Response( + JSON.stringify({ + data: [ + { + id: 'kimi-no-effort', + context_length: 128000, + supports_reasoning: true, + think_efforts: { + support: false, + valid_efforts: ['low', 'high'], + default_effort: 'high', + }, + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + }); + + // support !== true gates the whole object — valid_efforts / default_effort + // are ignored. + expect(models[0]?.supportEfforts).toBeUndefined(); + expect(models[0]?.defaultEffort).toBeUndefined(); + }); + + it('ignores legacy flat fields even when think_efforts is absent', async () => { + // The legacy support_efforts / default_effort fields are no longer read; + // only the nested think_efforts object is honored. + const models = await fetchManagedKimiCodeModels({ + accessToken: 'oauth-access-token', + fetchImpl: async () => + new Response( + JSON.stringify({ + data: [ + { + id: 'kimi-k2', + context_length: 128000, + supports_reasoning: true, + support_efforts: ['low', 'high'], + default_effort: 'high', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + }); + + expect(models[0]?.supportEfforts).toBeUndefined(); + expect(models[0]?.defaultEffort).toBeUndefined(); + }); + + it('writes supportEfforts and defaultEffort onto the provisioned model entry', async () => { + const config: ManagedKimiConfigShape = { providers: {} }; + + await provisionManagedKimiCodeConfig({ + accessToken: 'oauth-access-token', + fetchImpl: vi.fn(async () => makeEffortModelsResponse()) as unknown as typeof fetch, + adapter: { + read: () => config, + write: vi.fn(), + apply: applyManagedKimiCodeConfig, + }, + }); + + const alias = config.models?.['kimi-code/kimi-for-coding']; + expect(alias?.['supportEfforts']).toEqual(['low', 'high', 'max']); + expect(alias?.['defaultEffort']).toBe('high'); + }); +}); + +describe('selective merge', () => { + const baseOptions = { + baseUrl: 'https://api.example.test/coding/v1', + oauthKey: 'test-key', + }; + + it('preserves hand-edited fields that upstream does not declare', () => { + const config: ManagedKimiConfigShape = { + providers: {}, + models: { + 'kimi-code/kimi-k2': { + provider: 'kimi-code', + model: 'kimi-k2', + maxContextSize: 262144, + capabilities: ['thinking'], + maxOutputSize: 4096, + supportEfforts: ['low', 'high', 'max'], + } as Record, + }, + }; + + applyManagedKimiCodeConfig(config, { + ...baseOptions, + models: [ + { + id: 'kimi-k2', + contextLength: 262144, + supportsReasoning: true, + supportsImageIn: false, + supportsVideoIn: false, + supportsThinkingType: 'both', + }, + ], + }); + + const alias = config.models?.['kimi-code/kimi-k2']; + expect(alias?.['maxOutputSize']).toBe(4096); + expect(alias?.['supportEfforts']).toEqual(['low', 'high', 'max']); + expect(alias?.['maxContextSize']).toBe(262144); + }); + + it('overwrites hand-edited fields when upstream declares them', () => { + const config: ManagedKimiConfigShape = { + providers: {}, + models: { + 'kimi-code/kimi-k2': { + provider: 'kimi-code', + model: 'kimi-k2', + maxContextSize: 262144, + supportEfforts: ['low'], + } as Record, + }, + }; + + applyManagedKimiCodeConfig(config, { + ...baseOptions, + models: [ + { + id: 'kimi-k2', + contextLength: 262144, + supportsReasoning: true, + supportsImageIn: false, + supportsVideoIn: false, + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'high', + }, + ], + }); + + const alias = config.models?.['kimi-code/kimi-k2']; + expect(alias?.['supportEfforts']).toEqual(['low', 'high', 'max']); + expect(alias?.['defaultEffort']).toBe('high'); + }); + + it('removes managed models that upstream no longer lists', () => { + const config: ManagedKimiConfigShape = { + providers: {}, + models: { + 'kimi-code/kimi-k2': { + provider: KIMI_CODE_PROVIDER_NAME, + model: 'kimi-k2', + maxContextSize: 262144, + }, + 'kimi-code/removed': { + provider: KIMI_CODE_PROVIDER_NAME, + model: 'removed', + maxContextSize: 128000, + }, + }, + }; + + applyManagedKimiCodeConfig(config, { + ...baseOptions, + models: [ + { + id: 'kimi-k2', + contextLength: 262144, + supportsReasoning: true, + supportsImageIn: false, + supportsVideoIn: false, + }, + ], + }); + + expect(config.models?.['kimi-code/kimi-k2']).toBeDefined(); + expect(config.models?.['kimi-code/removed']).toBeUndefined(); }); }); diff --git a/packages/oauth/test/open-platform.test.ts b/packages/oauth/test/open-platform.test.ts index eb53829f1..deadb4fd2 100644 --- a/packages/oauth/test/open-platform.test.ts +++ b/packages/oauth/test/open-platform.test.ts @@ -289,7 +289,7 @@ describe('applyOpenPlatformConfig', () => { displayName: 'Kimi K2', }); expect(config.defaultModel).toBe('moonshot-cn/kimi-k2-0712-preview'); - expect(config.defaultThinking).toBe(true); + expect(config.thinking?.enabled).toBe(true); expect(config.services).toBeUndefined(); }); @@ -319,6 +319,45 @@ describe('applyOpenPlatformConfig', () => { expect(config.models?.['moonshot-cn/stale']).toBeUndefined(); expect(config.models?.['other/model']).toBeDefined(); }); + + it('preserves hand-edited fields that upstream does not declare', () => { + const config: ManagedKimiConfigShape = { + providers: { + 'moonshot-cn': { type: 'kimi', baseUrl: 'https://api.moonshot.cn/v1', apiKey: 'sk-old' }, + }, + models: { + 'moonshot-cn/kimi-k2-0712-preview': { + provider: 'moonshot-cn', + model: 'kimi-k2-0712-preview', + maxContextSize: 256000, + maxOutputSize: 8192, + supportEfforts: ['low', 'high'], + } as Record, + }, + }; + const platform = getOpenPlatformById('moonshot-cn')!; + const models = [ + { + id: 'kimi-k2-0712-preview', + contextLength: 256000, + supportsReasoning: true, + supportsImageIn: true, + supportsVideoIn: true, + }, + ]; + + applyOpenPlatformConfig(config, { + platform, + models, + selectedModel: models[0]!, + thinking: false, + apiKey: 'sk-new', + }); + + const alias = config.models?.['moonshot-cn/kimi-k2-0712-preview']; + expect(alias?.['maxOutputSize']).toBe(8192); + expect(alias?.['supportEfforts']).toEqual(['low', 'high']); + }); }); describe('removeOpenPlatformConfig', () => { diff --git a/packages/protocol/src/__tests__/rest-prompt.test.ts b/packages/protocol/src/__tests__/rest-prompt.test.ts index 7ecb1dc2d..57efd418d 100644 --- a/packages/protocol/src/__tests__/rest-prompt.test.ts +++ b/packages/protocol/src/__tests__/rest-prompt.test.ts @@ -85,12 +85,21 @@ describe('promptSubmissionSchema', () => { expect(promptSubmissionSchema.safeParse({} as unknown).success).toBe(false); }); - it('rejects unknown thinking level', () => { + it('accepts any non-empty thinking effort (provider normalizes)', () => { expect( promptSubmissionSchema.safeParse({ content: [{ type: 'text', text: 'hi' }], thinking: 'mega' as unknown, }).success, + ).toBe(true); + }); + + it('rejects empty thinking effort', () => { + expect( + promptSubmissionSchema.safeParse({ + content: [{ type: 'text', text: 'hi' }], + thinking: '' as unknown, + }).success, ).toBe(false); }); diff --git a/packages/protocol/src/__tests__/session.test.ts b/packages/protocol/src/__tests__/session.test.ts index a10bb6412..2420c5ed5 100644 --- a/packages/protocol/src/__tests__/session.test.ts +++ b/packages/protocol/src/__tests__/session.test.ts @@ -211,12 +211,12 @@ describe('sessionUpdateSchema', () => { }); }); - it('rejects an unknown thinking level in agent_config', () => { + it('accepts any non-empty thinking effort in agent_config', () => { expect( sessionUpdateSchema.safeParse({ agent_config: { thinking: 'mega' as unknown }, }).success, - ).toBe(false); + ).toBe(true); }); it('rejects an unknown permission_mode in agent_config', () => { diff --git a/packages/protocol/src/modelCatalog.ts b/packages/protocol/src/modelCatalog.ts index d4587de62..704869728 100644 --- a/packages/protocol/src/modelCatalog.ts +++ b/packages/protocol/src/modelCatalog.ts @@ -6,6 +6,8 @@ export const modelCatalogItemSchema = z.object({ display_name: z.string().min(1).optional(), max_context_size: z.number().int().min(1), capabilities: z.array(z.string()).optional(), + support_efforts: z.array(z.string()).optional(), + default_effort: z.string().optional(), }); export type ModelCatalogItem = z.infer; diff --git a/packages/protocol/src/rest/config.ts b/packages/protocol/src/rest/config.ts index c7a60d8e3..5dcb7d45a 100644 --- a/packages/protocol/src/rest/config.ts +++ b/packages/protocol/src/rest/config.ts @@ -16,7 +16,6 @@ export const configResponseSchema = z.object({ thinking: z.unknown().optional(), plan_mode: z.boolean().optional(), yolo: z.boolean().optional(), - default_thinking: z.boolean().optional(), default_permission_mode: z.string().optional(), default_plan_mode: z.boolean().optional(), permission: z.unknown().optional(), @@ -40,7 +39,6 @@ export const patchConfigRequestSchema = z.object({ thinking: z.unknown().optional(), plan_mode: z.boolean().optional(), yolo: z.boolean().optional(), - default_thinking: z.boolean().optional(), default_permission_mode: z.string().optional(), default_plan_mode: z.boolean().optional(), permission: z.unknown().optional(), diff --git a/packages/protocol/src/rest/prompt.ts b/packages/protocol/src/rest/prompt.ts index 203092116..82e3974a3 100644 --- a/packages/protocol/src/rest/prompt.ts +++ b/packages/protocol/src/rest/prompt.ts @@ -31,14 +31,10 @@ import { z } from 'zod'; import { messageContentSchema } from '../message'; import { isoDateTimeSchema } from '../time'; -export const promptThinkingSchema = z.enum([ - 'off', - 'low', - 'medium', - 'high', - 'xhigh', - 'max', -]); +// Accept any non-empty, model-declared effort string. Providers normalize +// unrecognized efforts on the wire, so the REST layer must not reject a value +// the catalog advertises via `support_efforts`. +export const promptThinkingSchema = z.string().min(1); export type PromptThinking = z.infer; export const promptPermissionModeSchema = z.enum(['manual', 'yolo', 'auto']); diff --git a/plan/thinking-effort-switching.md b/plan/thinking-effort-switching.md new file mode 100644 index 000000000..184e736c7 --- /dev/null +++ b/plan/thinking-effort-switching.md @@ -0,0 +1,303 @@ +# Thinking Effort 多档位切换方案 + +## 1. 背景与目标 + +新模型支持切换 reasoning effort,约 `low` / `high` / `max` 三档。模型目录会为每个模型返回: + +- `support_efforts = ["low", "high", "max"]`:该模型支持的档位列表(按模型不同)。 +- `default_effort = "high"`:该模型的出厂默认档位。 + +目标: + +1. 把 `support_efforts` / `default_effort` 像现有能力值一样存进 `config.toml`。 +2. 在 `/model` 选择器里,把模型下方的 thinking 控件从「On/Off 两段」扩展为「多档位」,并默认高亮默认值。 +3. 用户切换档位时立即生效,并更新**全局** `thinking.effort`(复用现有字段,跨重启自动保留)。 + +非目标(本期不做): + +- 按模型分别记住不同 effort(先全局生效,后续有需要再加)。 +- 改动 agent-core 的 effort 解析逻辑(客户端始终下发具体档位,解析逻辑不变)。 + +## 2. 现状关键事实 + +- **数据流(上游→下游)**:managed 模型目录 → `packages/oauth` 解析并写入 `config.toml` → `/models` 从 `config.toml` 透出;TUI 直接读 `config.toml`。 +- **模型条目刷新**:managed(`kimi-code`)模型每次刷新会**整条删除再重建**(`packages/oauth/src/managed-kimi-code.ts:464-478`),所以用户偏好**不能**存在模型条目里,会被冲掉。 +- **能力存储**:`config.toml` 里 `[models.]` 下的 `capabilities = ["thinking", "always_thinking", "tool_use", ...]`(字符串标签数组)。 +- **全局 effort 已存在**:`ThinkingConfigSchema = { mode?, effort? }`,`resolveThinkingEffort` 已用 `thinking.effort` 作为 `'on'` 的默认值(缺省 `'high'`)。 +- **底层已支持多档**:`ThinkingEffort = 'off' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'`,`Session.setThinking(level: string)` 接受任意字符串。 +- **kimi provider 当前发 `reasoning_effort`**:`packages/kosong/src/providers/kimi.ts` 的 `withThinking` 目前把 effort 映射成 `reasoning_effort`(与 openai 一致),同时额外发一个 `thinking: { type }`。新接口要求改成在 `thinking` 对象里带 `effort`;过渡期 `reasoning_effort` **仍保留一起传**(加 TODO,后续再删)。 +- **TUI 当前是布尔 On/Off**:`apps/kimi-code/src/tui/components/dialogs/model-selector.ts`,`←/→` 翻转布尔,`On` 左、`Off` 右。 + +## 3. 设计决策(已确认) + +| 决策点 | 结论 | +|---|---| +| `support_efforts` / `default_effort` 存哪 | 模型条目 `[models.]` 下的独立字段,与 `capabilities` 平级 | +| Off 行为 | 复用现有 `always_thinking` / `thinking` 标签:`always_thinking` 无 Off,`thinking` 有 Off | +| 用户选择存哪 | **全局** `thinking.effort`(已有字段);不新增按模型的表 | +| 跨重启 | 切换写入 `thinking.effort`,现有解析逻辑会自动用它,跨重启自然保留 | +| `default_effort` 角色 | 模型出厂默认值(随刷新更新);用于选择器里非当前模型的初始高亮 | +| kimi 请求里的 effort | 新增 `thinking: { type, effort? }`(`keep` 恒为 `all`,本期不传;仅当模型有 `supportEfforts` 时带 effort);`reasoning_effort` 过渡期**继续一起传**(加 TODO,后续删除) | + +## 4. config.toml 形态 + +```toml +default_model = "kimi-code/kimi-k2" +default_thinking = true + +[thinking] +mode = "auto" +effort = "max" # 用户切换后更新这里(全局) + +[models."kimi-code/kimi-k2"] +provider = "kimi-code" +model = "kimi-k2" +max_context_size = 262144 +capabilities = ["thinking", "always_thinking", "tool_use"] +support_efforts = ["low", "high", "max"] # 新增(接口给,随刷新) +default_effort = "high" # 新增(接口给,随刷新) +``` + +## 5. 配置 schema 改动 + +### 5.1 `packages/agent-core/src/config/schema.ts` + +`ModelAliasSchema` 新增两个可选字段(**通用**,任何 provider 的模型都可以有): + +```ts +supportEfforts: z.array(z.string()).optional(), +defaultEffort: z.string().optional(), +``` + +> managed 模型由接口自动写入;其它模型(openai / anthropic / 自定义 provider 等)也可以**手动在 `config.toml` 的 `[models.]` 里写 `support_efforts` / `default_effort`**,UI 读到就会展示多档位切换。 + +`ThinkingConfigSchema` **不变**(复用现有 `effort`)。 + +### 5.2 `packages/protocol/src/modelCatalog.ts` + +`modelCatalogItemSchema` 新增: + +```ts +support_efforts: z.array(z.string()).optional(), +default_effort: z.string().optional(), +``` + +### 5.3 `packages/agent-core/src/services/modelCatalog/modelCatalog.ts` + +`toProtocolModel()` 把 `alias.supportEfforts` / `alias.defaultEffort` 透到 `support_efforts` / `default_effort`,让 `/models` 返回。 + +## 6. OAuth 解析与写入 + +### 6.1 `packages/oauth/src/managed-kimi-code.ts` + +- `ManagedKimiCodeModelInfo` 新增 `supportEfforts?: readonly string[]`、`defaultEffort?: string`。 +- `toModelInfo()`(约 `:364`)解析 `support_efforts`(字符串数组,过滤非字符串/空串)与 `default_effort`(非空字符串)。新增一个小工具 `parseStringArray()`。 +- 写 config 的循环(`:469-478`)把 `supportEfforts` / `defaultEffort` 写进模型条目。 + +### 6.2 `packages/oauth/src/open-platform.ts` + +为保持一致,同步在 `toModelInfo()`(`:44`)解析、模型写入(`:173-182`)带上这两个字段(open-platform 路径共用 `ManagedKimiCodeModelInfo`)。 + +## 7. kosong kimi provider 改造(新 wire 格式) + +文件:`packages/kosong/src/providers/kimi.ts`,并把 `supportEfforts` 从 `ModelAlias` 透到 provider(见下方第 5 点)。 + +新接口格式: + +```json +{ "thinking": { "type": "enabled", "keep": "all", "effort": "high" } } +``` + +- `keep` 恒为 `all`,本期**不传**。 +- `effort` 放在 `thinking` 对象内(仅当模型有 `supportEfforts` 时)。 +- `reasoning_effort` 过渡期**继续一起传**(加 `TODO`,后续再删)。 + +改动点: + +1. **`ThinkingConfig` 接口**(`:74-78`):新增 `effort?: string`(已有 `[key: string]: unknown` 兜底,显式声明更清晰)。 + +2. **`withThinking(effort)`**(`:500-524`)重写: + - `kimiEffort(effort)` 映射(唯一映射,**删除旧的 `high/xhigh/max→high` clamp**):`low→low`、`medium→medium`、`high→high`、`xhigh→high`(kimi 无 xhigh)、`max→max`。 + - 构造 `thinking`:`effort === 'off'` → `{ type: 'disabled' }`;否则模型有 `supportEfforts`(非空)→ `{ type: 'enabled', effort: kimiEffort(effort) }`;否则(旧模型)→ `{ type: 'enabled' }`(无 effort)。 + - `reasoningEffort`:**仅当 `effort !== 'off'` 且模型有 `supportEfforts`** 时取 `kimiEffort(effort)`(与 `thinking.effort` 同值),否则 `undefined`;旁加 `TODO` 注释标明后续删除。 + - 返回 `_withGenerationKwargs({ reasoning_effort: reasoningEffort })` + `withExtraBody({ thinking })`。 + +3. **`thinkingEffort` getter**(`:415-417`):从 `extra_body.thinking` 反推(不再读 `reasoning_effort`,旧的反推映射一并删除): + - `thinking` 未设置 → `null`。 + - `type === 'disabled'` → `'off'`。 + - `thinking.effort` 存在 → 反查为 `ThinkingEffort`(`'low'/'medium'/'high'/'xhigh'/'max'`,未知值兜底 `'high'`)。 + - `type === 'enabled'` 但无 `effort`(旧模型)→ `'high'`(逻辑默认档)。 + - 由此 `reasoningEffortToThinkingEffort` 在 kimi.ts 不再使用,移除该 import。 + +4. **`reasoning_effort` 字段**:本期保留,但与 `thinking.effort` **同值**(统一走 `kimiEffort`,旧的 clamp 映射删除),且**同样按 `supportEfforts` 门控**(旧模型不再发 `reasoning_effort`);代码里加 `TODO`,待新 wire 格式全量上线后删除(见第 11 节)。 + +5. **把 `supportEfforts` 透到 provider**(`packages/agent-core/src/session/provider-manager.ts`): + - `KimiOptions` 新增 `supportEfforts?: readonly string[]`;`KimiChatProvider` 构造时存为私有字段,`_clone()` 通过 `Object.assign` 自动保留。 + - `toKosongProviderConfig` 新增 `supportEfforts` 参数,`case 'kimi'` 里写入 provider config。 + - `resolveProviderConfig` 调用 `toKosongProviderConfig` 时传入 `alias.supportEfforts`。 + - `withThinking` 用该字段决定是否带 `effort`。 + +> 注意:`thinking: { type }` 之前就已经在发,本次只是在该对象里(按能力)加 `effort`;`reasoning_effort` 暂时仍保留一起发,改动面很小。 + +> 其它 provider(openai / anthropic / google-genai 等)的 `withThinking` 已经各自把 `ThinkingEffort` 映射到自己的原生参数,**本期不改**。这些模型只要(手动)配了 `support_efforts`,UI 就能切换档位,选中的档位照旧走各 provider 现有的 `withThinking` 传参逻辑。只有 kimi provider 需要按 `supportEfforts` 门控 `thinking.effort` / `reasoning_effort`。 + +## 8. TUI 改造 + +### 8.1 `model-selector.ts`(核心) + +**类型变化** + +- `ModelSelection.thinking`:`boolean` → `string`(档位:`'off'`、`'on'`、或具体 effort 如 `'high'`)。 +- `ModelSelectorOptions.currentThinking: boolean` → `currentThinkingLevel: string`(当前模型的运行时档位)。 +- 内部 `thinkingOverrides: Map` → `Map`。 + +**新增工具** + +```ts +function effortsOf(model: ModelAlias): readonly string[] { + return model.supportEfforts ?? []; +} +``` + +**分段(segments)规则** + +| 模型类型 | segments | 说明 | +|---|---|---| +| 不支持 thinking | `['off']`(渲染 On 不可选 + `[Off]`,保持现状) | 不变 | +| 普通 toggle(无 effort) | `['on', 'off']` | 不变 | +| always-on(无 effort) | `['on']` | 不变 | +| toggle + effort | `['off', ...supportEfforts]` | Off 在最左 | +| always-on + effort | `[...supportEfforts]` | 无 Off | + +> effort 模型 Off 放最左;非 effort 模型保持 `On` 左 / `Off` 右,避免改动现有视觉。 + +**高亮(draftFor)** + +- 有 `←/→` override → override。 +- 当前模型 → `currentThinkingLevel`(运行时实际档位)。 +- 其它 effort 模型 → `defaultEffort`(若在 `supportEfforts` 内),否则 `supportEfforts[0]`。 +- 其它非 effort 模型 → 保持现有逻辑(capable 默认 `'on'`,否则 `'off'`)。 + +**键盘** + +- `←`:active 段左移一位(到最左停)。 +- `→`:active 段右移一位(到最右停)。 +- 不循环;不支持的模型忽略。 + +**渲染 `renderThinkingControl`** + +按 `segmentsFor(model)` 渲染所有段,active 段用 `boldFg('primary', '[ label ]')`,其余 `fg('text', ' label ')`;不支持的侧保持 `textMuted` + `(Unsupported)`(仅非 effort 的 always-on/unsupported 路径保留)。段标签首字母大写(`off`→`Off`,`low`→`Low`,`max`→`Max`)。 + +**标题行** + +- toggle / effort 模型可切换时显示 `Thinking (←→ to switch)`,否则 `Thinking`。 + +### 8.2 `tabbed-model-selector.ts` + +透传:`TabbedModelSelectorOptions.currentThinking` → `currentThinkingLevel`,`makeSelector` 里传 `currentThinkingLevel`。`onSelect` 的 `thinking` 现在是 string,直接转发。 + +### 8.3 `commands/config.ts` + +- `showModelPicker`:`currentThinkingLevel: host.state.appState.thinkingLevel ?? (host.state.appState.thinking ? 'on' : 'off')`。 +- `performModelSwitch(host, alias, level: string, persist)`: + - `const prevLevel = host.state.appState.thinkingLevel ?? (host.state.appState.thinking ? 'on' : 'off')` + - session 路径:`alias !== prevModel` → `setModel(alias)`;`level !== prevLevel` → `setThinking(level)`(直接传档位字符串)。 + - 无 session:`authFlow.activateModelAfterLogin(alias, level)`。 + - `setAppState({ model: alias, thinking: level !== 'off', thinkingLevel: level })`。 + - 状态文案:`thinking on/off` → 直接显示档位(如 `thinking high`)。 +- `persistModelSelection(host, alias, level: string)`: + - `defaultModel = alias` + - `defaultThinking = level !== 'off'` + - 若 `level` 是具体 effort(`level !== 'on' && level !== 'off'`)→ 写 `thinking: { effort: level }`(`setConfig` 深合并,保留 `thinking.mode`)。 + - 仅在 `defaultModel` / `defaultThinking` / `thinking.effort` 任一变化时调用 `setConfig`。 + +### 8.4 `commands/provider.ts` + +`setDefaultModel(host, alias, thinking)` 的 `thinking` 由 `boolean` 改为 `level: string`,写 `defaultThinking: level !== 'off'`,并在具体 effort 时写 `thinking.effort`。两处 `onSelect` 透传 string。两处 `currentThinking` → `currentThinkingLevel`。 + +### 8.5 `commands/prompts.ts`(`runModelSelector`) + +- 选项 `currentThinking` → `currentThinkingLevel: initialThinking ? 'on' : 'off'`。 +- `onSelect` 拿到 string level,返回时转回 boolean:`{ alias, thinking: level !== 'off' }`(open-platform / catalog 模型无 effort 字段,level 只会是 `'on'`/`'off'`)。 + +### 8.6 `types.ts` / `kimi-tui.ts` / `auth-flow.ts` / `footer.ts` + +- `AppState` 新增 `thinkingLevel?: string`(运行时档位)。 +- `kimi-tui.ts syncRuntimeState`(`:1201`):加 `thinkingLevel: status.thinkingLevel`。 +- `kimi-tui.ts` 初始 state(`:193`)与 logout 重置:`thinkingLevel: 'off'`。 +- `kimi-tui.ts:1176-1177` reload 建 session:`thinking` 用 `this.state.appState.thinkingLevel ?? (this.state.appState.thinking ? 'on' : 'off')`,保留具体档位。 +- `auth-flow.ts activateModelAfterLogin(model, level?: string)`:参数由 `thinking?: boolean` 改为 `level?: string`,直接传给 `setThinking` / `CreateSessionOptions.thinking`。`refreshConfigAfterLogin` 里 `activateModelAfterLogin(defaultModel, config.defaultThinking === false ? 'off' : undefined)`。 +- `footer.ts:265`:有 effort 时显示档位,例如 `state.thinkingLevel` 为具体 effort 时渲染 ` thinking:max`,否则保持 ` thinking`。 + +## 9. 逐文件改动清单 + +| 包 | 文件 | 改动 | +|---|---|---| +| kosong | `src/providers/kimi.ts` | `withThinking` 新增 `thinking: { type, effort? }`(仅当模型有 supportEfforts 时带 effort);`reasoning_effort` 暂保留但与 effort 同值、同样按 supportEfforts 门控,旧 clamp 映射删除并加 TODO;`thinkingEffort` getter 从 `thinking` 反推;`KimiOptions` 加 `supportEfforts` | +| agent-core | `src/session/provider-manager.ts` | `toKosongProviderConfig` 透传 `supportEfforts`;`resolveProviderConfig` 传入 `alias.supportEfforts` | +| agent-core | `src/config/schema.ts` | `ModelAliasSchema` 加 `supportEfforts` / `defaultEffort` | +| agent-core | `src/services/modelCatalog/modelCatalog.ts` | `toProtocolModel` 透出两字段 | +| protocol | `src/modelCatalog.ts` | `modelCatalogItemSchema` 加 `support_efforts` / `default_effort` | +| oauth | `src/managed-kimi-code.ts` | `ManagedKimiCodeModelInfo` + `toModelInfo` + 写 config | +| oauth | `src/open-platform.ts` | `toModelInfo` + 写 config(同步) | +| kimi-code | `src/tui/components/dialogs/model-selector.ts` | 多档位 UI + 键盘 + 类型 | +| kimi-code | `src/tui/components/dialogs/tabbed-model-selector.ts` | 透传 `currentThinkingLevel` | +| kimi-code | `src/tui/commands/config.ts` | 档位化切换 + 持久化 `thinking.effort` | +| kimi-code | `src/tui/commands/provider.ts` | `setDefaultModel` 档位化 | +| kimi-code | `src/tui/commands/prompts.ts` | `runModelSelector` 适配 string level | +| kimi-code | `src/tui/types.ts` | `AppState.thinkingLevel` | +| kimi-code | `src/tui/kimi-tui.ts` | syncRuntimeState / 初始 / reload | +| kimi-code | `src/tui/controllers/auth-flow.ts` | `activateModelAfterLogin` 档位化 | +| kimi-code | `src/tui/components/chrome/footer.ts` | 显示 effort 档位 | + +## 10. 测试计划 + +就近扩展,不新增泛化测试文件: + +- `packages/kosong`(kimi provider 测试) + - `withThinking('high')` 在模型有 `supportEfforts` 时发出 `thinking: { type: 'enabled', effort: 'high' }`,且 `reasoning_effort === 'high'`。 + - `withThinking('max')` 在模型有 `supportEfforts` 时:`thinking.effort === 'max'` 且 `reasoning_effort === 'max'`(同值,无 clamp)。 + - `withThinking('high')` 在模型无 `supportEfforts` 时发出 `thinking: { type: 'enabled' }`(无 effort),且 `reasoning_effort === undefined`(按能力门控,旧模型不再发)。 + - `withThinking('off')` 发出 `thinking: { type: 'disabled' }`,`reasoning_effort === undefined`。 + - `thinkingEffort` getter:从 `thinking` 反推(`{enabled, effort:'max'}`→`'max'`、`{enabled}`→`'high'`、`{disabled}`→`'off'`、未设置→`null`)。 +- `packages/agent-core`(provider-manager 测试) + - `resolveProviderConfig` 把 `alias.supportEfforts` 透到 kimi provider config。 + +- `apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts` + - 现有 On/Off 用例:`currentThinking` → `currentThinkingLevel`(`'on'`/`'off'`),断言 `thinking` 为字符串。 + - 新增 effort 用例: + - effort 模型渲染 `[Off] [Low] [High] [Max]`(toggle)/ `[Low] [High] [Max]`(always-on)。 + - 默认高亮 `defaultEffort`。 + - `←/→` 在多档间移动并到端点停止;Enter 下发具体档位。 + - 当前模型高亮运行时档位(`currentThinkingLevel`)。 + - 切到 Off 时下发的 `thinking === 'off'`。 +- `apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts` + - 透传 `currentThinkingLevel`;断言 string 档位透传。 +- `packages/oauth` 现有测试 + - `toModelInfo` 解析 `support_efforts` / `default_effort`。 + - 写 config 时模型条目包含 `supportEfforts` / `defaultEffort`。 +- `packages/agent-core` 配置相关测试 + - `ModelAliasSchema` 接受新字段;`toProtocolModel` 透出。 + +跑测试: + +```bash +pnpm --filter @moonshot-ai/kimi-code test -- model-selector tabbed-model-selector +pnpm --filter @moonshot-ai/oauth test +pnpm --filter @moonshot-ai/kosong test -- kimi +pnpm --filter @moonshot-ai/agent-core test -- modelCatalog provider-manager +``` + +类型检查 / lint: + +```bash +pnpm --filter @moonshot-ai/kimi-code typecheck +pnpm lint +``` + +## 11. 范围外 / 后续 + +- 删除 kimi provider 的 `reasoning_effort`(待新 `thinking.effort` wire 格式全量上线、所有 kimi 模型都接受后;代码里已留 `TODO`)。 +- 按模型分别记住 effort(先全局;若需要,再加 `[thinking.model_efforts]` 之类的覆盖表)。 +- agent-core 解析感知 `default_effort`(本期客户端始终下发具体档位,无需改动)。 +- kimi-web 端的多档 UI(`/models` 透出字段后,web 已具备 `ThinkingLevel` 模型,可另行接入)。 diff --git a/plan/thinking-model-overhaul.md b/plan/thinking-model-overhaul.md new file mode 100644 index 000000000..4007a7f61 --- /dev/null +++ b/plan/thinking-model-overhaul.md @@ -0,0 +1,385 @@ +# Thinking 模型重构方案 + +> 前置工作:`plan/thinking-effort-switching.md`(effort 多档切换)已完成。本方案是在其基础上对 thinking 模型的整体重构,目标是消除冗余开关、硬编码默认值和静态档位枚举,让 `support_efforts` 成为档位的唯一真相源。 + +## 1. 背景与目标 + +当前 thinking 的开关和级别逻辑横跨 config、agent-core、SDK、TUI、provider 五层,经过多轮演进后积累了以下问题: + +- 配置层同时存在 `default_thinking`(boolean)、`thinking.mode`(auto/on/off)、`thinking.effort` 三个字段,语义重叠;`mode=auto` 与 `mode=on` 代码里完全等价,名存实亡。 +- kosong 的 `ThinkingEffort = 'off' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'` 是全局硬编码枚举,但档位本应来自模型声明的 `support_efforts`;`kimiEffort()` 还得硬编码 `'xhigh' → 'high'` 之类的映射。 +- `'on'` 是一个不在 `ThinkingEffort` 枚举里的幽灵档位,贯穿 TUI → SDK → agent-core,语义在层间断裂。 +- TUI 用 `AppState.thinking: boolean` + `AppState.thinkingLevel?: string` 两个字段存一个状态,五处重复 fallback。 +- `DEFAULT_THINKING_EFFORT = 'high'` 硬编码兜底,与模型声明的 `default_effort` 重复。 +- `always_thinking` 约束在 UI、agent-core getter、acp adapter 三处各 clamp 一次。 + +目标: + +1. 配置收敛到 `[thinking]` 一处,删除 `default_thinking` 和 `thinking.mode`。 +2. `support_efforts` 成为 effort 档位的唯一真相源,删除全局 `ThinkingEffort` 枚举和 `kimiEffort` 映射。 +3. 删除 `DEFAULT_THINKING_EFFORT` 硬编码,默认值完全由模型声明推导。 +4. `'on'` 圈禁为 boolean 模型(无 `support_efforts`)专属的开启信号,不再贯穿全栈。 +5. TUI 单一字段 `thinkingLevel`,删除 `thinking: boolean`。 +6. `always_thinking` 约束集中到 agent-core resolve 一处。 + +非目标(本期不做): + +- 按模型分别记住 effort 偏好(`[thinking.model_efforts]`),仍用全局 `thinking.effort`,切模型时由 provider 归一。 +- 真正实现 `mode=auto` 的"按任务/模型自适应",直接删除该值。 +- kimi-web 端改造。 + +## 2. 设计原则 + +1. **单一真值**:thinking 运行时状态在 agent-core 里就是一个 `ThinkingLevel`,不存在第二个 boolean 字段。 +2. **`support_efforts` 是 effort 档位的唯一真相源**:档位是模型的属性,不是全局枚举。 +3. **归一优先于校验**:不做 `isValidThinkingLevel` 严格校验抛错;非法 effort 在 provider 端宽容归一为 `undefined`。 +4. **`'off'` 和 `'on'` 是唯二保留字**:`'off'` 关闭,`'on'` 专属于无 `support_efforts` 的 boolean 模型;其余档位都是模型声明。 +5. **`always_thinking` 是约束,不是档位**:在 agent-core resolve 一处 clamp。 + +## 3. 设计决策(已确认) + +| 决策点 | 结论 | +|---|---| +| `default_thinking` | **直接删除**,不做兼容读取。breaking change,changeset 走 major + changelog 写迁移说明 | +| `thinking.mode` | **删除**。`off` 由 `enabled=false` 表达,`on`/`auto` 由 `enabled=true` 表达 | +| `DEFAULT_THINKING_EFFORT = 'high'` | **删除**。默认值由 `defaultLevelFor(model)` 从模型声明推导 | +| `ThinkingEffort` 静态枚举 | **删除**。改为 `ThinkingLevel = 'off' | 'on' | (string & {})`,档位动态化 | +| `isValidThinkingLevel` 校验 | **不做**。非法 effort 在 provider 宽容归一为 `undefined`,不抛错 | +| effort 非法值归一 | provider 端:`supportEfforts.includes(level) ? level : undefined` | +| `always_thinking` clamp | agent-core `resolveThinkingLevel`:`level === 'off'` 时归一为 `defaultLevelFor(model)` | +| `kimiEffort()` 映射 | **删除**。effort 直接来自 `support_efforts`,是 kimi 原生值 | +| `wireEffortToThinkingEffort()` | **删除**。getter 直接读 wire 值 | +| TUI `AppState.thinking: boolean` | **删除**。只留 `thinkingLevel: ThinkingLevel` | +| `setThinking` 类型 | `ThinkingLevel`,透传无运行时校验 | +| 全局 effort 偏好 | 保留 `thinking.effort`,切模型时由 provider 归一(不合法则不带 effort) | + +## 4. 目标形态 + +### 4.1 config.toml + +```toml +default_model = "kimi-code/kimi-k2" + +[thinking] +enabled = true # 默认是否开启(替代 default_thinking + thinking.mode) +effort = "high" # 开启时的默认档位(low/medium/high/xhigh/max,或模型声明的其他档) + +[models."kimi-code/kimi-k2"] +provider = "kimi-code" +model = "kimi-k2" +max_context_size = 262144 +capabilities = ["thinking", "always_thinking", "tool_use"] +support_efforts = ["low", "high", "max"] # effort 档的唯一真相源 +default_effort = "high" +``` + +迁移说明(changelog):旧配置 `default_thinking = true` 改为 `[thinking] enabled = true`;`default_thinking = false` 改为 `enabled = false`;`[thinking] mode = "off"` 改为 `enabled = false`;`mode = "on"` / `mode = "auto"` 删除该行(等价于 `enabled = true`)。 + +### 4.2 类型 + +```ts +// packages/kosong/src/provider.ts +export type ThinkingLevel = 'off' | 'on' | (string & {}); +// ^^^^^^^^^^^^ +// 模型声明的 effort 档,运行时 string +``` + +`ThinkingEffort` 类型**直接删除**,所有引用在同一个 PR 内全量替换为 `ThinkingLevel`,**不留别名、不留兼容层**。 + +`ThinkingLevel` 在 TS 里塌缩成 `string`,主要作为**语义标注**:告诉调用者这里应该是 `'off'` / `'on'` / 模型 effort 档。运行时就是 `string`,不做强约束。 + +### 4.3 agent-core 运行时 + +```ts +// packages/agent-core/src/agent/config/index.ts +private _thinkingLevel: ThinkingLevel = 'off'; + +get thinkingLevel(): ThinkingLevel { + return this._thinkingLevel; // 不在这里 clamp always_thinking +} +``` + +`_thinkingLevel` 是 agent-core 内唯一的 thinking 状态字段。 + +### 4.4 配置 schema + +```ts +// packages/agent-core/src/config/schema.ts +export const ThinkingConfigSchema = z.object({ + enabled: z.boolean().optional(), + effort: z.string().optional(), +}); +``` + +删除 `mode` 字段。 + +### 4.5 provider 层 + +```ts +// packages/kosong/src/providers/kimi.ts +withThinking(level: ThinkingLevel): KimiChatProvider { + let thinking: ThinkingConfig; + let reasoningEffort: string | undefined; + + if (level === 'off') { + thinking = { type: 'disabled' }; + } else { + // support_efforts 是 effort 档的唯一真相源:只下发声明里的值, + // 其余('on' / 'xhigh' / 非法值)一律归一为 undefined(不带 effort)。 + const effort = this._supportEfforts.includes(level) ? level : undefined; + thinking = effort !== undefined + ? { type: 'enabled', effort } + : { type: 'enabled' }; + // TODO: drop reasoning_effort once the new thinking.effort wire format is + // fully rolled out across all kimi models. + reasoningEffort = effort; + } + + const oldExtra = this._generationKwargs.extra_body ?? {}; + const keep = oldExtra.thinking?.keep; + if (keep !== undefined) { + thinking = { ...thinking, keep }; + } + return this._withGenerationKwargs({ + reasoning_effort: reasoningEffort, + extra_body: { ...oldExtra, thinking }, + }); +} +``` + +其它 provider(openai / anthropic / google-genai 等)的 `withThinking` 已经各自把 effort 映射到原生参数,本期不改。 + +### 4.6 TUI 层 + +```ts +// apps/kimi-code/src/tui/types.ts +AppState.thinkingLevel: ThinkingLevel; // 唯一字段,删除 thinking: boolean +``` + +boolean 模型的 UI 归一: + +```ts +// model-selector.ts +function commitLevel(choice: ModelChoice, draft: string): ThinkingLevel { + if (draft === 'off') return 'off'; + if (draft === 'on') { + return defaultLevelFor(choice.model); // boolean 模型:On → 模型默认 + } + return draft; +} +``` + +`ModelSelection.thinking` 类型收紧为 `ThinkingLevel`,不再有 `'on'` 漏出 UI 边界。 + +## 5. 关键归一规则 + +### 5.1 默认值推导 + +```ts +// packages/agent-core/src/agent/config/thinking.ts +function defaultLevelFor(model: ModelAlias): ThinkingLevel { + if (!supportsThinking(model)) return 'off'; + const efforts = model.supportEfforts; + if (efforts?.length) return model.defaultEffort ?? middleOf(efforts); + return 'on'; // boolean 模型 +} +``` + +| 模型 | 默认 | +|---|---| +| 不支持 thinking | `'off'` | +| 有 `support_efforts` | `default_effort` → `support_efforts` 中位 | +| 无 effort 的 boolean 模型 | `'on'` | + +### 5.2 resolve + +```ts +// packages/agent-core/src/agent/config/thinking.ts +export function resolveThinkingLevel( + requested: ThinkingLevel | undefined, + config: ThinkingConfig | undefined, + model: ModelAlias, +): ThinkingLevel { + let level: ThinkingLevel; + + if (requested !== undefined) { + level = requested; + } else if (config?.enabled === false) { + level = 'off'; + } else { + level = config?.effort ?? defaultLevelFor(model); + } + + // always_thinking 模型强制开启:'off' 归一为默认档 + if (level === 'off' && model.capabilities?.includes('always_thinking')) { + level = defaultLevelFor(model); + } + + return level; +} +``` + +删除 `resolveThinkingEffort`、`'on'` 分支、`mode` 分支、`DEFAULT_THINKING_EFFORT`。 + +### 5.3 provider effort 归一 + +```ts +const effort = this._supportEfforts.includes(level) ? level : undefined; +``` + +行为表: + +| 模型 | 传入 level | wire `thinking` | +|---|---|---| +| effort 模型 `['low','high','max']` | `'high'` | `{ type: 'enabled', effort: 'high' }` | +| effort 模型 | `'max'` | `{ type: 'enabled', effort: 'max' }` | +| effort 模型 | `'xhigh'` | `{ type: 'enabled' }`(不带 effort) | +| effort 模型 | `'on'` | `{ type: 'enabled' }` | +| effort 模型 | `'foo'` | `{ type: 'enabled' }` | +| boolean 模型(无 support_efforts) | `'on'` | `{ type: 'enabled' }` | +| 任意 | `'off'` | `{ type: 'disabled' }` | + +### 5.4 配置写入 + +```ts +// apps/kimi-code/src/tui/utils/thinking-config.ts +export function thinkingLevelToConfig(level: ThinkingLevel): ThinkingConfigPatch { + return level === 'off' + ? { enabled: false } + : { enabled: true, effort: level }; +} +``` + +删除 `config.ts` / `provider.ts` 里两处重复的 `level !== 'on' && level !== 'off' ? level : undefined`。 + +## 6. 实施步骤(单个 PR) + +所有改动合并成**一个 PR** 一次性完成,不留兼容层、不留别名、不留 TODO 债。PR 内按以下顺序执行(仅用于把控改动节奏,不是独立 PR): + +### 步骤 1:类型与 `'on'` 圈禁 + +目标:消除 `AppState.thinking` 双字段,把 `'on'` 圈禁在 UI 层。 + +- kosong 导出 `ThinkingLevel = 'off' | 'on' | (string & {})`,**直接删除 `ThinkingEffort`**,本 PR 内全量替换所有引用,不留别名。 +- node-sdk `setThinking(level: ThinkingLevel)`:保留透传,不做运行时校验(按决策)。 +- TUI 删 `AppState.thinking: boolean`,统一用 `thinkingLevel: ThinkingLevel`: + - `types.ts` 改字段 + - `kimi-tui.ts` 删 `thinking` 初始化、`syncRuntimeState` 删 `thinking` 派生写入 + - `footer.ts` / `status-panel.ts` / `info.ts` 等从 `thinkingLevel` 派生显示 +- `model-selector.ts` 提交前归一:`'on'` → `defaultLevelFor(model)`;`ModelSelection.thinking: ThinkingLevel`。 +- 删 5 处 `thinkingLevel ?? (thinking ? 'on' : 'off')` fallback,抽 `isThinkingOn(level)` / `thinkingLabel(level)` helper。 +- `prompts.ts` open-platform/catalog 路径:现在 `ModelSelection.thinking` 已是 `ThinkingLevel`,删除 `thinking !== 'off'` 转 boolean 的逻辑(按需调整返回类型)。 + +验证:现有测试通过;新增 `'on'` 归一、`isThinkingOn` helper 测试。 + +### 步骤 2:配置收敛 + +目标:删 `default_thinking` 和 `thinking.mode`,收敛到 `[thinking] { enabled, effort }`。 + +- `schema.ts` `ThinkingConfigSchema`:删 `mode`,`effort` 保持 `z.string().optional()`。 +- `schema.ts` 顶层:删 `defaultThinking`(`KimiConfigSchema`)。 +- `thinking.ts` `resolveThinkingLevel`:按 5.2 重写;删 `resolveThinkingEffort`、`'on'` 分支、`mode` 分支、`DEFAULT_THINKING_EFFORT`;新增 `defaultLevelFor`。 +- `core-impl.ts` `createSession`:调用 `resolveThinkingLevel(options.thinking, config.thinking, model)`。 +- TUI `persistModelSelection` / `setDefaultModel`:写入走 `thinkingLevelToConfig`,不再写 `defaultThinking`。 +- `auth-flow.ts` `refreshConfigAfterLogin`:读 `config.thinking?.enabled` 替代 `config.defaultThinking`。 +- `env-model.ts`:删 `KIMI_MODEL_THINKING_MODE` 处理(或改为设置 `enabled`)。 +- 文档:`config-files.md` / `env-vars.md` 更新字段说明;changelog 写迁移说明。 + +验证:老 config.toml(含 `default_thinking`、`mode`)按 major 迁移说明手动改写后行为一致;新写入只产 `[thinking] enabled/effort`。 + +### 步骤 3:always_thinking 集中 + provider 归一 + +目标:删三处 clamp,删 `kimiEffort` / `wireEffortToThinkingEffort` 映射。 + +- `thinking.ts` `resolveThinkingLevel`:加 `always_thinking` clamp(在步骤 2 一并写入,本步骤验证 + 删其它 clamp)。 +- `config/index.ts` `thinkingLevel` getter:删 `alwaysThinkingModel` clamp,直接返回 `_thinkingLevel`。 +- `acp-adapter/session.ts` `setThinking`:删 `currentModelAlwaysThinking()` clamp;`THINKING_ON_LEVEL = 'high'` 改为 `defaultLevelFor(currentModel)` 或从 agent-core status 取。 +- `kimi.ts` `withThinking`:按 4.5 重写;删 `kimiEffort()` 函数;`reasoning_effort` 双发本步骤保留,待服务端全量后在步骤 4 删除(唯一依赖服务端的收尾项)。 +- `kimi.ts` `thinkingEffort` getter:删 `wireEffortToThinkingEffort()`,直接读 `thinking.effort`,无 effort 返回 `'on'`。 +- UI `model-selector.ts` `renderThinkingControl`:保留 `Off (Unsupported)` 纯展示(不承担 clamp)。 + +验证:always_thinking 模型设 `'off'` 仍开启(agent-core clamp);effort 模型传 `'xhigh'` / `'foo'` wire 不带 effort;`kimiEffort` / `wireEffortToThinkingEffort` 无引用。 + +### 步骤 4:清理 + +目标:删除过渡期代码。 + +- 删 kimi `reasoning_effort` 双发(确认服务端所有 kimi 模型已接受新 wire 格式后执行;这是唯一依赖服务端的收尾项)。 +- 删 `effectiveDefaultEffort`(被 `defaultLevelFor` 覆盖,确认无引用后删除)。 +- 删 acp `THINKING_ON_LEVEL` 常量(如步骤 3 未删)。 +- 文档最终校对。 + +## 7. 逐文件改动清单 + +| 包 | 文件 | 改动 | +|---|---|---| +| kosong | `src/provider.ts` | 删 `ThinkingEffort`;新增 `ThinkingLevel` | +| kosong | `src/providers/kimi.ts` | `withThinking` 重写;删 `kimiEffort` / `wireEffortToThinkingEffort`;getter 简化 | +| node-sdk | `src/session.ts` | `setThinking(level: ThinkingLevel)` | +| node-sdk | `src/types.ts` | 导出 `ThinkingLevel` | +| agent-core | `src/config/schema.ts` | `ThinkingConfigSchema` 删 `mode`;顶层删 `defaultThinking` | +| agent-core | `src/agent/config/thinking.ts` | `resolveThinkingLevel` 重写;`defaultLevelFor`;删 `resolveThinkingEffort` / `DEFAULT_THINKING_EFFORT` / `effectiveDefaultEffort` | +| agent-core | `src/agent/config/index.ts` | `_thinkingLevel: ThinkingLevel`;getter 删 always_thinking clamp | +| agent-core | `src/rpc/core-impl.ts` | `createSession` 调用新 resolve | +| agent-core | `src/config/env-model.ts` | 删 `KIMI_MODEL_THINKING_MODE` 或改设 `enabled` | +| acp-adapter | `src/session.ts` | 删 always_thinking clamp;`THINKING_ON_LEVEL` 改动态 | +| kimi-code | `src/tui/types.ts` | 删 `thinking: boolean`,留 `thinkingLevel: ThinkingLevel` | +| kimi-code | `src/tui/kimi-tui.ts` | 删 `thinking` 初始化 / syncRuntimeState 写入 / createSession 派生 | +| kimi-code | `src/tui/commands/config.ts` | `performModelSwitch` 用 `thinkingLevel`;持久化走 `thinkingLevelToConfig` | +| kimi-code | `src/tui/commands/provider.ts` | `setDefaultModel` 同上 | +| kimi-code | `src/tui/commands/prompts.ts` | 适配 `ThinkingLevel`(删 boolean 转换) | +| kimi-code | `src/tui/controllers/auth-flow.ts` | 读 `config.thinking.enabled` | +| kimi-code | `src/tui/components/dialogs/model-selector.ts` | 提交前归一 `'on'`;`ModelSelection.thinking: ThinkingLevel` | +| kimi-code | `src/tui/components/dialogs/effort-selector.ts` | 类型 `ThinkingLevel` | +| kimi-code | `src/tui/components/chrome/footer.ts` | 从 `thinkingLevel` 派生 | +| kimi-code | `src/tui/components/messages/status-panel.ts` | 从 `thinkingLevel` 派生 | +| kimi-code | `src/tui/utils/thinking-config.ts`(新增) | `thinkingLevelToConfig` / `isThinkingOn` / `thinkingLabel` | +| docs | `en/configuration/config-files.md` | `[thinking]` 字段说明 | +| docs | `en/configuration/env-vars.md` | 删 `KIMI_MODEL_THINKING_MODE` 或更新 | +| docs | `zh/...` | 同步翻译 | + +## 8. 测试计划 + +就近扩展,不新增泛化测试文件。 + +- `packages/kosong`(kimi provider) + - `withThinking('high')` 在 effort 模型发 `{ type: 'enabled', effort: 'high' }`,`reasoning_effort === 'high'`。 + - `withThinking('max')` 同上,`effort === 'max'`。 + - `withThinking('xhigh')` 在 effort 模型发 `{ type: 'enabled' }`(不带 effort),`reasoning_effort === undefined`。 + - `withThinking('on')` 在 effort 模型发 `{ type: 'enabled' }`。 + - `withThinking('foo')` 在 effort 模型发 `{ type: 'enabled' }`。 + - `withThinking('on')` 在 boolean 模型发 `{ type: 'enabled' }`。 + - `withThinking('off')` 发 `{ type: 'disabled' }`,`reasoning_effort === undefined`。 + - `thinkingEffort` getter:`{enabled, effort:'max'}` → `'max'`;`{enabled}` → `'on'`;`{disabled}` → `'off'`;未设置 → `null`。 +- `packages/agent-core`(config / thinking) + - `defaultLevelFor`:不支持 thinking → `'off'`;effort 模型 → `defaultEffort` / 中位;boolean 模型 → `'on'`。 + - `resolveThinkingLevel`:requested 优先;`enabled=false` → `'off'`;always_thinking + `'off'` → 默认档。 + - `ThinkingConfigSchema` 拒绝 `mode`;`KimiConfigSchema` 拒绝 `defaultThinking`。 +- `apps/kimi-code` + - `model-selector.test.ts`:`'on'` 提交时归一为 `defaultLevelFor`;`ModelSelection.thinking` 类型 `ThinkingLevel`。 + - `effort-selector.test.ts`:类型 `ThinkingLevel`。 + - `thinking-config.test.ts`(新增或并入现有 commands 测试):`thinkingLevelToConfig('off')` → `{ enabled: false }`;`thinkingLevelToConfig('max')` → `{ enabled: true, effort: 'max' }`。 + - `auth-flow` 相关:`refreshConfigAfterLogin` 读 `thinking.enabled`。 + +跑测试: + +```bash +pnpm --filter @moonshot-ai/kosong test -- kimi +pnpm --filter @moonshot-ai/agent-core test -- thinking config +pnpm --filter @moonshot-ai/kimi-code test -- model-selector effort-selector +pnpm --filter @moonshot-ai/acp-adapter test +``` + +类型检查 / lint: + +```bash +pnpm --filter @moonshot-ai/kimi-code typecheck +pnpm lint +``` + +## 9. 范围外 / 后续 + +- 按模型分别记住 effort(`[thinking.model_efforts]`):当前用全局 `thinking.effort`,切模型时由 provider 归一;若需要精确记忆再加。 +- 真正实现 `mode=auto` 的"按任务/模型自适应":本期直接删除该值,未来按需设计。 +- kimi-web 端:同步 `ThinkingLevel` 类型与配置字段。 +- `setThinking` 类型严格化:当前 `ThinkingLevel` 塌缩为 `string`;若未来需要真正的运行时校验,可在 SDK 入口加 `isThinkingLevel`(非空 + 字符集白名单),但不依赖模型 `support_efforts`。 diff --git a/plan/thinking-test-coverage.md b/plan/thinking-test-coverage.md new file mode 100644 index 000000000..ada0378bc --- /dev/null +++ b/plan/thinking-test-coverage.md @@ -0,0 +1,152 @@ +# Thinking 模型重构 — 测试覆盖审查报告 + +> PR #1132(分支 `support-effort`)的测试缺口分析。生成于 2026-06-26,供后续补测试用。 +> +> 总体结论:**核心逻辑(resolveThinkingEffort、kimi/anthropic provider、ACP toggle、TUI commitEffort)覆盖较扎实;本次重构引入的若干"归一/兼容"分支存在行为契约未锁定的缺口。没有发现会导致运行时崩溃(P0)的缺口;所有缺口均为行为契约(P1)或 nice-to-have(P2)。** + +--- + +## 两个待确认的设计问题 + +### 1. `default_thinking` / `thinking.mode` 是「拒绝」还是「静默忽略」? + +- **当前实现**:静默忽略 + 下次写入时剥离(schema 非 strict,`packages/agent-core/src/config/toml.ts` 写入时 `delete out['mode']` / `delete out['default_thinking']`)。 +- **计划意图**:「直接删除,不做兼容读取」,breaking change。 +- **建议**:按"静默忽略 + 写入剥离"补测试锁定(P1 第 4 项)。如果要 fail-fast,需把 schema 改为 `.strict()` 并补 fail-fast 测试。 + +### 2. `commitEffort('on')` 当 `defaultEffort` 不在 `supportEfforts` 内时返回 `defaultEffort`,是否有意? + +- **当前实现**:`defaultThinkingEffortFor`(agent-core 和 TUI 内联)都是 `model.defaultEffort ?? middleOf(supportEfforts)`,**不校验 `defaultEffort` 是否在 `supportEfforts` 内**。 +- **为什么合理**:provider 端会归一(声明外的 effort 不下发 wire effort),所以即使 `defaultEffort` 不在声明里也不会导致 wire 错误。 +- **建议**:按"返回 defaultEffort,由 provider 归一"补测试锁定(P1 第 12 项)。 + +--- + +## P1 缺口(行为契约未锁定,建议本 PR 或 follow-up 尽快补) + +### kosong + +#### `openai-common.ts` — `thinkingEffortToReasoningEffort` +- **现有**:`off/low/medium/high/xhigh/max` + 未知 `'extreme'` → undefined(`openai-common-errors.test.ts:314-337`)。 +- **缺口**:`'on'` 没有显式断言(注释明确把 `'on'` 列为归一对象,但测试用 `'extreme'`)。 +- **建议**:把 `it('normalizes unknown effort to undefined')` 改成 `it.each(['on', 'extreme', 'foo'])`。 + +#### `anthropic.ts` — `clampEffort` +- **现有**:xhigh/max 在 opus-4-5/4-6/4-7/4-8/fable/sonnet/haiku 上的 clamp 与透传、adaptive vs budget、`off`、low/medium 透传(`anthropic.test.ts:1084-1400`)。 +- **缺口**:`'on'` / 未知 effort(如 `'foo'`)→ clamp 到 `'high'`(`anthropic.ts:342-350` 的最后 if 分支)**完全未覆盖**。 +- **建议**:`it.each(['on', 'foo'])` 在 adaptive 模型(如 `claude-opus-4-7`)上 → `output_config={effort:'high'}`;在非 adaptive 模型(如 `claude-sonnet-4-5`)上 → `thinking={type:'enabled',budget_tokens:32000}` 且无 `output_config`。 + +#### `google-genai.ts` — `withThinking` +- **现有**:非 gemini-3 的 `high`/`off`;gemini-3 的 `off/low/medium/high`;getter 反射(`google-genai.test.ts:729-836`)。 +- **缺口**: + - `'on'` / 未知 effort(`'foo'`)在 **gemini-3** 上 → 只 `include_thoughts:true`、不设 `thinking_level`(`google-genai.ts:829-852`)。未覆盖。 + - `'on'` / 未知 effort 在 **非 gemini-3** 上 → 只 `include_thoughts:true`、不设 `thinking_budget`(`google-genai.ts:853-873`)。未覆盖。 + - `'xhigh'`/`'max'` 在 **gemini-3** → `HIGH`(fall-through)。未覆盖。 +- **建议**:`it.each(['on','foo'])` 在 `gemini-3-pro-preview` 上 → `thinking_config={include_thoughts:true}`(无 `thinking_level`);在 `gemini-2.5-flash` 上 → `{include_thoughts:true}`(无 `thinking_budget`)。`it.each(['xhigh','max'])` 在 gemini-3 上 → `thinking_level:'HIGH'`。 + +#### `kimi.ts` — `withThinking` +- **现有**:非 effort 模型 / effort 模型 / `max` / `off` / `xhigh`/`on`/`foo` 不在 supportEfforts / getter / 重复调用(`kimi.test.ts:708-799`)。 +- **缺口**:空 `supportEfforts: []`(与 `undefined` 等价)未显式覆盖。 +- **建议**:加 `createProvider(false, []).withThinking('high')` → `thinking={type:'enabled'}`、无 `reasoning_effort`。 + +### agent-core + +#### `config/schema.ts` — 删除 `default_thinking` / `thinking.mode` +- **现有**:`[thinking] enabled/effort` 解析;patch merge thinking;`default_yolo` 的 drop-deprecated 范式(`configs.test.ts`)。 +- **缺口**:`default_thinking`(顶层)被忽略/剥离**无测试**;`thinking.mode` 被忽略/剥离**无测试**。 +- **建议**:`parseConfigString('default_thinking = true\n[thinking]\nmode = "always"\neffort="high"\n')` → `config.thinking` 不含 `mode`、顶层无 `defaultThinking`,且 `writeConfigFile` 后文本不含 `default_thinking` / `mode`。 + +#### `config/env-model.ts` — 删除 `KIMI_MODEL_THINKING_MODE` / `KIMI_MODEL_DEFAULT_THINKING` +- **现有**:`KIMI_MODEL_THINKING_EFFORT` 映射;`KIMI_MODEL_ADAPTIVE_THINKING`;write-back 隔离(`env-model.test.ts`)。 +- **缺口**:`KIMI_MODEL_THINKING_MODE` / `KIMI_MODEL_DEFAULT_THINKING` 被忽略**无测试**(回归保护,防止未来被误加回)。 +- **建议**:`applyEnvModelConfig(MIN, { KIMI_MODEL_THINKING_MODE:'always', KIMI_MODEL_DEFAULT_THINKING:'high' })` → `config.thinking` 为 `undefined`(或不含 effort/mode)。 + +#### `agent/config/index.ts` — `update()` clamp 集成 +- **现有**:always_thinking `'off'`→`'on'`;provider 构建 enabled;toggleable `'off'` 保持;切到 always_thinking re-clamp 旧 `'off'`(`config-state.test.ts:163-228`)。 +- **缺口**:`modelAlias` 变化 + 旧 effort=`'off'` + 新 always_thinking 模型 + `config.effort='max'` → 应 clamp 到 `'max'`(而非 `defaultEffort`)。`update()` 在 modelAlias 分支把 `this._thinkingEffort` 作为 requested 传入,clamp 时读 `config?.effort`——这条路径未在 ConfigState 集成层测。 +- **建议**:先 `update({modelAlias: toggleable, thinkingEffort:'off'})`,再 `update({modelAlias: deep})` 且 kimiConfig 带 `thinking:{effort:'max'}` → 期望 `'max'`。 + +#### `session/provider-manager.ts` — `supportEfforts` 透传给 kimi provider +- **现有**:无直接测试。kimi provider 单元覆盖了 supportEfforts 行为,但**接线层**(provider-manager 把 supportEfforts 写进 kimi provider config)未测。 +- **缺口**:effort-capable alias 经 `ProviderManager.resolveProviderConfig` → kimi `ProviderConfig` 应携带 `supportEfforts`。 +- **建议**:构造 `supportEfforts:['low','high','max']` 的 kimi alias,断言 `resolveProviderConfig(...).provider` 的 supportEfforts 透传(或经 `config.provider.thinkingEffort`/`modelParameters` 验证)。 + +### acp-adapter + +#### `server.ts` — `resolveCurrentThinkingEnabled` +- **现有**:无直接测试。仅经 `newSession` 间接走 `getConfig` 无 `thinking` 字段 → `false` 分支。 +- **缺口**(全 P1): + - `getConfig` 缺失(partial stub)→ `false` + - `thinking.enabled === true` → `true`;`=== false` → `false` + - `thinking.effort` 为非空 string(无 `enabled`)→ `true`(**本次新增核心分支**) + - `getConfig` 抛错 → `false` + - `thinking` 缺失 / `effort:''` → `false` +- **建议**:抽一个可直接调用 `resolveCurrentThinkingEnabled` 的测试(或经 `newSession` + 不同 `getConfig` 返回值断言 `configOptions.thinking.currentValue`),覆盖以上 5 条。 + +#### `server.ts` — `setupSessionFromExisting` resumedThinkingEffort 投影 +- **现有**:`thinkingEffort='high'` → toggle `on`(`session-resume.test.ts:142-189`)。 +- **缺口**:`thinkingEffort='off'` → `currentValue='off'` 未测;`thinkingEffort=''`(空串)→ `off` 未测。 +- **建议**:加 `thinkingEffort:'off'` 与 `thinkingEffort:''` 两条,断言 `thinking.currentValue==='off'`。 + +#### `session.ts` — `thinkingOnEffort` +- **现有**:默认返回 `'on'`(fixture model 无 `supportEfforts`/`defaultEffort`)。 +- **缺口**:effort-capable model(`defaultEffort='high'` 或 middle `supportEfforts`)→ 返回对应 effort,而非 `'on'`;`harness` 缺失(`undefined`)→ `'on'`。 +- **建议**:`makeHarness` 加 effort-capable model(`supportEfforts:['low','high','max']`、`defaultEffort:'high'`),断言 `setThinkingCalls` 收到 `'high'`;另加无 harness 的 `AcpSession.setThinking(true)` → `'on'`。 + +### apps/kimi-code(TUI) + +#### `tui/utils/thinking-config.ts` — `thinkingEffortToConfig` / `isThinkingOn` +- **现有**:无直接单元测试。仅经 `cli/provider.test.ts` 与 `kimi-tui-message-flow.test.ts` 间接覆盖。 +- **缺口**:`thinkingEffortToConfig('off')` → `{enabled:false}`;`('low')`→`{enabled:true,effort:'low'}`;`('on')`→`{enabled:true,effort:'on'}` 无直接单测。 +- **建议**:新建 `test/tui/utils/thinking-config.test.ts`,对 `thinkingEffortToConfig` / `isThinkingOn` 做参数化断言。 + +#### `tui/components/dialogs/model-selector.ts` — `commitEffort` +- **现有**:`'on'`→effort 模型 `defaultEffort`;`'on'`→middle;`'on'`→boolean `'on'`(`model-selector.test.ts`)。 +- **缺口**:`commitEffort('on')` 当 `defaultEffort` **不在** `supportEfforts` 内 → 返回 `defaultEffort`(即使模型未声明支持)。该行为与 agent-core 一致,但 TUI 层未锁定(见"待确认问题 2")。 +- **建议**:加 `effortModel(..., ['low','high'], 'max')` 非当前模型 → Enter → `onSelect` 收到 `thinking:'max'`(锁定现状)。 + +#### `tui/commands/config.ts` — `persistModelSelection` short-circuit +- **现有**:runtime 不变但 `defaultModel` 不同 → 仍写入;Alt+S 不持久化(`kimi-tui-message-flow.test.ts`)。 +- **缺口**:`defaultModel`、`thinking.enabled`、`thinking.effort` **三者完全相同** → 返回 `false`、不调用 `setConfig`(short-circuit `config.ts:467-473`)。未直接测。 +- **建议**:加一条 `getConfig` 返回 `defaultModel:'k2', thinking:{enabled:true,effort:'on'}`,选择 `k2` + `on` → `setConfig` 未被调用,且状态提示 "Already using ..."。 + +--- + +## P2 缺口(nice-to-have) + +- `provider.ts`:type-level 开放字符串断言(`const e: ThinkingEffort = 'any-custom-effort'`)。 +- `kimi.ts`:`keep` 字段在 `withThinking` 后保留(`withExtraBody({thinking:{keep}}).withThinking(...)`)。 +- `anthropic.ts`:`budgetTokensForEffort` 对 `'off'/'xhigh'/'max'` 的 `throw` 分支(生产路径被 clampEffort 保护)。 +- `google-genai.ts`:非 gemini-3 的 `low`/`medium`/`xhigh`/`max` budget 矩阵;gemini-3 getter 反射。 +- `thinking.ts`:`defaultEffort` 不在 `supportEfforts` 内的语义命名;`requested='off'` + always_thinking ± `config.effort` 组合。 +- `config-state.test.ts`:modelAlias 切换保留非 `'off'` effort;thinkingEffort + modelAlias 同时变化。 +- `schema.ts`:`thinking.enabled` 非 boolean、`thinking.effort` 非 string → 报错的类型校验。 +- `env-model.ts`:`KIMI_MODEL_THINKING_EFFORT` 与已有 `enabled` 的合并(base config 带 `thinking:{enabled:true}`,env 设 effort → `{enabled:true, effort}`)。 +- `session-resume.test.ts`:`thinkingEffort` 大写/带空格;mainConfig 缺 `thinkingEffort` 的 fallback 断言。 +- `session.ts` `thinkingOnEffort`:currentModelId 不在 catalog → `'on'`。 +- `promptThinkingSchema`:非 string(如 `number`/`boolean`)→ 拒绝。 +- `thinking-config.test.ts`:`isThinkingOn` 各分支。 +- `commitEffort`:非 `'on'` draft 透传。 +- `persistModelSelection`:defaultModel 相同但 effort 不同 → 写入。 + +--- + +## 建议的补充顺序 + +### 本 PR 内补(简单 + 高价值) +1. openai-common:`it.each(['on', 'extreme', 'foo'])` 显式含 `'on'` +2. anthropic `clampEffort`:`it.each(['on', 'foo'])` adaptive + budget 两条 +3. schema:`default_thinking` / `thinking.mode` 静默忽略 + 写入剥离 +4. env-model:`KIMI_MODEL_THINKING_MODE` / `KIMI_MODEL_DEFAULT_THINKING` 被忽略 +5. ACP `resolveCurrentThinkingEnabled`:5 条分支 +6. TUI `thinkingEffortToConfig`:直接单元测试(`off` / `on` / `low`) +7. kimi `withThinking`:空 `supportEfforts: []` +8. google-genai `withThinking`:`'on'` / 未知(gemini-3 + 非 gemini-3) + +### follow-up(避免本 PR 继续膨胀) +- provider-manager `supportEfforts` 透传 +- ConfigState.update() modelAlias 切换 + config.effort 集成 +- ACP resume `thinkingEffort='off'` / `''` +- commitEffort defaultEffort 不在声明内 +- persistModelSelection short-circuit +- 全部 P2