refactor!: overhaul thinking config and effort resolution (#1132)

* feat: support multi-level thinking effort switching

- kimi provider: emit thinking.effort in the new wire format; keep reasoning_effort mirrored during the transition
- model catalog: thread support_efforts / default_effort from oauth through to /models
- config schema: add supportEfforts / defaultEffort on model aliases
- TUI: multi-segment thinking control in /model, new /effort command, footer effort display
- switch status uses displayName and distinguishes model vs effort-only changes

* docs: add thinking effort design plans

- thinking-effort-switching.md: implemented multi-level effort switching
- thinking-model-overhaul.md: follow-up refactor plan for the thinking state model

* docs: collapse thinking overhaul plan into a single PR

* refactor!: overhaul thinking config and effort resolution

Replace default_thinking and thinking.mode with a single [thinking] enabled/effort table. ThinkingEffort is now an open string ('off' | 'on' | model-declared effort); effort levels come from each model's support_efforts instead of a fixed enum.

Centralize default and always_thinking clamp logic in resolveThinkingEffort/defaultThinkingEffortFor, and honor an explicitly configured effort when an always_thinking model is forced back on.

TUI keeps a single thinkingEffort field instead of the boolean + level pair; 'on' is normalized to the model default at the UI boundary.

BREAKING CHANGE: default_thinking and thinking.mode are removed from config; migrate to [thinking] enabled/effort.

* refactor: rename residual thinking level wording to effort

Rename comments, error messages, parameter names, the SetThinkingPayload wire field (level -> effort), and TUI local variables so the thinking effort naming is consistent throughout. No behavior change.

* refactor: rename remaining camelCase thinking level identifiers to effort

Rename liveLevel/prevLevel/levelChanged/commitLevel/effectiveLevel to liveEffort/prevEffort/effortChanged/commitEffort/effectiveEffort in the TUI model picker and config commands.

* refactor: eliminate remaining thinking level wording in comments and tests

Rename levelLabel -> effortLabel, EffortSelectorOptions.levels -> efforts, and 'effort level(s)' / 'default level' / 'requested level' wording in comments, error messages, slash-command description, and test titles to effort. Also restore the withThinking(effort) parameter rename in the Kimi provider that was accidentally reverted.

* fix: address codex review feedback on thinking effort handling

- OpenAI thinkingEffortToReasoningEffort and Anthropic clampEffort now normalize 'on' / unrecognized efforts instead of throwing, so boolean non-Kimi models no longer crash on session start.

- ACP resolveCurrentThinkingEnabled treats a non-empty thinking.effort as enabled, matching agent-core's resolveThinkingEffort.

- REST promptThinkingSchema accepts any non-empty effort string so model-declared efforts are not rejected at the API boundary.

* test: align kimi e2e expectations with supportEfforts-gated reasoning_effort

The kimi provider now sends reasoning_effort only when the model declares support_efforts; boolean models (no support_efforts) send only thinking.type. Update the kimi e2e tests to drop the stale reasoning_effort expectation for the boolean test model.

* test: cover [thinking] effort parsing in config.test

Add effort = "high" to the documented [thinking] table in the config parse test and assert config.thinking.effort is resolved, so the new [thinking] effort field has direct parse coverage.

* docs: add thinking test coverage gap analysis

Capture the explore agent's test coverage review for the thinking overhaul PR, including P1/P2 gaps and the two open design questions, for follow-up test additions.

* feat(oauth): parse nested think_efforts from /models response

The /models endpoint now returns effort levels under a nested think_efforts object ({ support, valid_efforts, default_effort }). Parse it preferentially in both managed-kimi-code and open-platform model parsing, falling back to the legacy flat support_efforts / default_effort fields for older servers.

* refactor(oauth): only read nested think_efforts; gate on support=true

Drop the legacy flat support_efforts / default_effort fallback. The think_efforts object is now the single source, and its support flag gates the whole object — when support is not true, valid_efforts and default_effort are ignored entirely.

* chore: remove unused parseStringArray import in open-platform

* docs: finalize thinking effort release notes

Downgrade the changeset to minor with an English summary, drop the version-specific 'added in 1.0.0' info block, and present the deprecated config fields as a table (field / deprecated in 0.21.0 / description).

* refactor: drop temporary refresh toggles and kimi reasoning_effort mirror

Remove the always-true REFRESH_MODELS_ON_PICKER_OPEN / REFRESH_PROVIDER_MODELS_ON_STARTUP toggles and their stale re-enable TODOs, and stop sending reasoning_effort from the kimi provider (thinking.effort is the only wire field now).

* fix(tui): avoid persisting "on" as thinking effort

* fix: preserve persisted thinking effort across login and provider setup

* fix(tui): show actual thinking effort in /status and footer

* test(tui): align message-flow expectations with effort persistence and /status display

* fix(vis): rename thinkingLevel to thinkingEffort in config.update analysis
This commit is contained in:
liruifengv 2026-06-30 22:34:13 +08:00 committed by GitHub
parent e207f52f55
commit 108299be3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
127 changed files with 2909 additions and 830 deletions

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/kimi-code": minor
"@moonshot-ai/kimi-code-sdk": minor
---
Refactor the thinking effort system

View file

@ -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;

View file

@ -159,7 +159,11 @@ async function handleOpenPlatformLogin(
platform,
models,
selectedModel: selection.model,
thinking: selection.thinking,
thinking: selection.thinking !== 'off',
effort:
selection.thinking !== 'off' && selection.thinking !== 'on'
? selection.thinking
: undefined,
apiKey,
});
@ -167,7 +171,7 @@ async function handleOpenPlatformLogin(
providers: config.providers,
models: config.models,
defaultModel: config.defaultModel,
defaultThinking: config.defaultThinking,
thinking: config.thinking,
});
await host.authFlow.refreshConfigAfterLogin();

View file

@ -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';
@ -212,6 +217,55 @@ export async function handleModelCommand(host: SlashCommandHost, args: string):
showModelPicker(host, alias);
}
export async function handleEffortCommand(host: SlashCommandHost, args: string): Promise<void> {
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
// ---------------------------------------------------------------------------
@ -308,7 +362,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 +381,7 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string =
async function performModelSwitch(
host: SlashCommandHost,
alias: string,
thinking: boolean,
effort: ThinkingEffort,
persist: boolean,
): Promise<void> {
if (host.state.appState.streamingPhase !== 'idle') {
@ -335,21 +389,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 +414,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<boolean> {
async function persistModelSelection(
host: SlashCommandHost,
alias: string,
effort: ThinkingEffort,
): Promise<boolean> {
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;
}

View file

@ -25,6 +25,7 @@ import {
handleAutoCommand,
handleCompactCommand,
handleEditorCommand,
handleEffortCommand,
handleModelCommand,
handlePlanCommand,
handleThemeCommand,
@ -65,6 +66,7 @@ export {
handleAutoCommand,
handleCompactCommand,
handleEditorCommand,
handleEffortCommand,
handleModelCommand,
handlePlanCommand,
handleThemeCommand,
@ -292,6 +294,9 @@ async function handleBuiltInSlashCommand(
case 'model':
await handleModelCommand(host, args);
return;
case 'effort':
await handleEffortCommand(host, args);
return;
case 'provider':
await handleProviderCommand(host);
return;

View file

@ -132,7 +132,7 @@ export async function showStatusReport(host: SlashCommandHost): Promise<void> {
workDir: appState.workDir,
sessionId: appState.sessionId,
sessionTitle: appState.sessionTitle,
thinking: appState.thinking,
thinkingEffort: appState.thinkingEffort,
permissionMode: appState.permissionMode,
planMode: appState.planMode,
contextUsage: appState.contextUsage,

View file

@ -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<string, ModelAlias> = {};
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<string, ModelAlias> = {};
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<string, ModelAlias>,
): 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();

View file

@ -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<void> {
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<void> {
async function setDefaultModel(
host: SlashCommandHost,
alias: string,
thinking: boolean,
effort: ThinkingEffort,
): Promise<void> {
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<boolean> {
@ -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();

View file

@ -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'],

View file

@ -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()) {

View file

@ -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();

View file

@ -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));
}
}

View file

@ -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<string, ModelAlias>;
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<string, ModelAlias>): 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<ModelChoice>;
/** Per-model thinking override set by ←/→; absent → the capability default. */
private readonly thinkingOverrides = new Map<string, boolean>();
/** Per-model thinking-effort override set by ←/→; absent → the default. */
private readonly thinkingOverrides = new Map<string, string>();
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(' ')}`;
}
}

View file

@ -39,7 +39,7 @@ export interface TabbedModelSelectorOptions {
readonly models: Record<string, ModelAlias>;
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,

View file

@ -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,10 +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';
return `${displayModelName(model, options.availableModels)} (thinking ${thinking})`;
const effort = options.status?.thinkingEffort ?? options.thinkingEffort;
return `${displayModelName(model, options.availableModels)} (thinking ${effort})`;
}
function addFieldRows(

View file

@ -7,6 +7,7 @@ import {
type RefreshProviderScope,
type RefreshResult,
} from '../utils/refresh-providers';
import { thinkingEffortFromConfig } from '../utils/thinking-config';
import type { SessionEventHandler } from './session-event-handler';
import type { AppState, KimiTUIOptions } from '../types';
import type { TUIState } from '../tui-state';
@ -51,7 +52,7 @@ export class AuthFlowController {
this.host.setAppState({
sessionId: '',
model: '',
thinking: false,
thinkingEffort: 'off',
contextTokens: 0,
maxContextTokens: 0,
contextUsage: 0,
@ -61,13 +62,12 @@ export class AuthFlowController {
this.host.setStartupReady();
}
async activateModelAfterLogin(model: string, thinking?: boolean): Promise<void> {
async activateModelAfterLogin(model: string, effort?: string): Promise<void> {
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;
}
@ -75,7 +75,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
@ -125,16 +125,13 @@ export class AuthFlowController {
return;
}
await this.activateModelAfterLogin(defaultModel, config.defaultThinking);
await this.activateModelAfterLogin(defaultModel, thinkingEffortFromConfig(config.thinking));
const appStatePatch: Partial<AppState> = {
availableModels,
availableProviders,
model: defaultModel,
maxContextTokens: selected.maxContextSize,
};
if (config.defaultThinking !== undefined) {
appStatePatch.thinking = config.defaultThinking;
}
host.setAppState(appStatePatch);
}
@ -144,7 +141,7 @@ export class AuthFlowController {
availableModels: config.models ?? {},
availableProviders: config.providers ?? {},
model: '',
thinking: false,
thinkingEffort: 'off',
maxContextTokens: 0,
contextUsage: 0,
contextTokens: 0,

View file

@ -205,7 +205,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState {
planMode: input.cliOptions.plan,
inputMode: 'prompt',
swarmMode: false,
thinking: false,
thinkingEffort: 'off',
contextUsage: 0,
contextTokens: 0,
maxContextTokens: 0,
@ -1385,8 +1385,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,
};
@ -1410,7 +1409,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,

View file

@ -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;

View file

@ -0,0 +1,36 @@
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; a concrete effort enables thinking
* and records it as the global effort preference. `'on'` is the boolean-model
* on-signal rather than a declared effort, so it only persists `enabled`
* boolean models resolve back to `'on'` at runtime via `defaultThinkingEffortFor`.
*/
export function thinkingEffortToConfig(effort: ThinkingEffort): {
enabled: boolean;
effort?: string;
} {
if (effort === 'off') return { enabled: false };
if (effort === 'on') return { enabled: true };
return { enabled: true, effort };
}
/**
* Inverse of {@link thinkingEffortToConfig}: derive the runtime thinking effort
* to activate a model with from the persisted `[thinking]` config. Returns
* `'off'` when thinking is disabled, the configured concrete effort when set,
* and `undefined` when thinking is enabled without a concrete effort so the
* model's own default applies.
*/
export function thinkingEffortFromConfig(
config: { enabled?: boolean; effort?: string } | undefined,
): ThinkingEffort | undefined {
if (config?.enabled === false) return 'off';
return config?.effort;
}

View file

@ -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 () => {

View file

@ -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');
});
});

View file

@ -17,7 +17,7 @@ const appState: AppState = {
sessionTitle: null,
model: 'kimi-k2',
permissionMode: 'manual',
thinking: false,
thinkingEffort: 'off',
contextUsage: 0,
contextTokens: 0,
maxContextTokens: 0,

View file

@ -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);
});
});

View file

@ -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 ]');
});
});

View file

@ -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', () => {

View file

@ -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,
@ -52,7 +52,7 @@ describe('status panel report lines', () => {
const output = lines.join('\n');
expect(output).toContain('>_ Kimi Code (v1.2.3)');
expect(output).toContain('Model Kimi K2 (thinking on)');
expect(output).toContain('Model Kimi K2 (thinking high)');
expect(output).toContain('Directory /tmp/project');
expect(output).toContain('Permissions auto');
expect(output).toContain('Plan mode on');
@ -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,

View file

@ -16,7 +16,7 @@ function baseState(overrides: Partial<AppState> = {}): AppState {
sessionId: 'sess_1',
permissionMode: 'manual',
planMode: false,
thinking: false,
thinkingEffort: 'off',
contextUsage: 0,
contextTokens: 0,
maxContextTokens: 200_000,

View file

@ -26,7 +26,7 @@ function baseState(overrides: Partial<AppState> = {}): 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');

View file

@ -17,7 +17,7 @@ function baseState(overrides: Partial<AppState> = {}): AppState {
sessionId: 'sess_1',
permissionMode: 'manual',
planMode: false,
thinking: false,
thinkingEffort: 'off',
contextUsage: 0,
contextTokens: 0,
maxContextTokens: 200_000,

View file

@ -14,7 +14,7 @@ function fakeInitialAppState(): AppState {
planMode: false,
inputMode: 'prompt',
swarmMode: false,
thinking: false,
thinkingEffort: 'off',
contextUsage: 0,
contextTokens: 0,
maxContextTokens: 0,

View file

@ -150,7 +150,7 @@ function makeSession(overrides: Record<string, unknown> = {}) {
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<string, unknown> = {}) {
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,
@ -3484,7 +3484,7 @@ command = "vim"
const session = makeSession({
getStatus: vi.fn(async () => ({
model: 'k2',
thinkingLevel: 'high',
thinkingEffort: 'high',
permission: 'auto',
planMode: true,
contextTokens: 25,
@ -3504,7 +3504,7 @@ command = "vim"
expect(output).toContain(' Status ');
expect(output).toContain('>_ Kimi Code');
expect(output).toContain('Model');
expect(output).toContain('thinking on');
expect(output).toContain('thinking high');
expect(output).toContain('Permissions auto');
expect(output).toContain('Plan mode on');
expect(output).toContain('Context window');
@ -4164,7 +4164,7 @@ command = "vim"
},
},
defaultModel: 'k2',
defaultThinking: false,
thinking: { enabled: false },
})),
setConfig,
});
@ -4193,11 +4193,11 @@ command = "vim"
expect(session.setThinking).toHaveBeenCalledWith('on');
expect(setConfig).toHaveBeenCalledWith({
defaultModel: 'turbo',
defaultThinking: true,
thinking: { enabled: true },
});
});
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 () => {
@ -4222,7 +4222,7 @@ command = "vim"
},
},
defaultModel: 'k2',
defaultThinking: false,
thinking: { enabled: false },
})),
setConfig,
});
@ -4242,7 +4242,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 () => {
@ -4260,7 +4260,7 @@ command = "vim"
},
},
defaultModel: 'old-default',
defaultThinking: true,
thinking: { enabled: true },
})),
setConfig,
});
@ -4276,7 +4276,7 @@ command = "vim"
await vi.waitFor(() => {
expect(setConfig).toHaveBeenCalledWith({
defaultModel: 'k2',
defaultThinking: false,
thinking: { enabled: false },
});
});
expect(session.setModel).not.toHaveBeenCalled();

View file

@ -105,7 +105,7 @@ function makeSession(overrides: Record<string, unknown> = {}) {
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', {

View file

@ -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,

View file

@ -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);
});
});

View file

@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import {
isThinkingOn,
thinkingEffortFromConfig,
thinkingEffortToConfig,
} from '@/tui/utils/thinking-config';
describe('thinkingEffortToConfig', () => {
it.each([
['off', { enabled: false }],
// 'on' is the boolean-model on-signal, not a declared effort. It must not
// be persisted as `thinking.effort` — boolean models have no effort concept
// and resolve back to 'on' at runtime via defaultThinkingEffortFor.
['on', { enabled: true }],
['low', { enabled: true, effort: 'low' }],
['high', { enabled: true, effort: 'high' }],
['max', { enabled: true, effort: 'max' }],
] as const)('maps %s → %o', (effort, expected) => {
expect(thinkingEffortToConfig(effort)).toEqual(expected);
});
});
describe('isThinkingOn', () => {
it.each([
['off', false],
['on', true],
['low', true],
['high', true],
['max', true],
] as const)('%s → %s', (effort, expected) => {
expect(isThinkingOn(effort)).toBe(expected);
});
});
describe('thinkingEffortFromConfig', () => {
it.each([
[undefined, undefined],
[{}, undefined],
// enabled with no concrete effort → let the model's own default apply.
[{ enabled: true }, undefined],
[{ enabled: false }, 'off'],
[{ enabled: true, effort: 'high' }, 'high'],
// effort is honored even when enabled is not explicitly set.
[{ effort: 'max' }, 'max'],
] as const)('%o → %s', (config, expected) => {
expect(thinkingEffortFromConfig(config)).toBe(expected);
});
});

View file

@ -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,

View file

@ -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;

View file

@ -29,7 +29,7 @@ export interface ConfigSnapshot {
cwd?: string;
modelAlias?: string;
profileName?: string;
thinkingLevel?: string;
thinkingEffort?: string;
systemPrompt?: string;
}
@ -312,7 +312,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;
}

View file

@ -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: (

View file

@ -292,7 +292,7 @@ export function analyzeWire(entries: readonly WireEntry[]): Analysis {
const changed: { field: string; value: string }[] = [];
if (rec.profileName !== undefined) changed.push({ field: 'profile', value: rec.profileName });
if (rec.modelAlias !== undefined) changed.push({ field: 'model', value: rec.modelAlias });
if (rec.thinkingLevel !== undefined) changed.push({ field: 'thinking', value: rec.thinkingLevel });
if (rec.thinkingEffort !== undefined) changed.push({ field: 'thinking', value: rec.thinkingEffort });
if (rec.cwd !== undefined) changed.push({ field: 'cwd', value: rec.cwd });
if (rec.systemPrompt !== undefined) changed.push({ field: 'systemPrompt', value: `${rec.systemPrompt.length} chars` });
if (changed.length > 0) configChanges.push({ lineNo: entry.lineNo, time: t, changed });

View file

@ -104,7 +104,7 @@ describe('analyzeWire', () => {
it('collects config.update changes', () => {
line = 0;
const a = analyzeWire([
e({ type: 'config.update', modelAlias: 'opus', thinkingLevel: 'high', systemPrompt: 'x'.repeat(120) }, 0),
e({ type: 'config.update', modelAlias: 'opus', thinkingEffort: 'high', systemPrompt: 'x'.repeat(120) }, 0),
e({ type: 'config.update', modelAlias: 'sonnet' }, 10),
]);
expect(a.configChanges).toHaveLength(2);

View file

@ -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,19 @@ 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 |
### Deprecated fields
| Field | Deprecated in | Description |
| --- | --- | --- |
| `default_thinking` | 0.21.0 | Top-level boolean, replaced by `[thinking] enabled`. Migrate `default_thinking = true` to `enabled = true`, and `default_thinking = false` to `enabled = false`. |
| `thinking.mode` | 0.21.0 | One of `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`

View file

@ -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 |

View file

@ -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,19 @@ 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`,未识别的值会被供应商忽略 |
### 已废弃字段
| 字段 | 废弃版本 | 描述 |
| --- | --- | --- |
| `default_thinking` | 0.21.0 | 顶层布尔值,由 `[thinking] enabled` 取代。将 `default_thinking = true` 迁移为 `enabled = true``default_thinking = false` 迁移为 `enabled = false`。 |
| `thinking.mode` | 0.21.0 | 可选值 `auto` / `on` / `off`,由 `[thinking] enabled` 取代。`mode = "off"` 改为 `enabled = false``mode = "on"``mode = "auto"` 等价于 `enabled = true`(默认值),可删除该行。 |
## `loop_control`

View file

@ -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` | 按模型名推断 |

View file

@ -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}).
*

View file

@ -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;

View file

@ -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', {

View file

@ -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(<default
* effort for that model>); 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<void> {
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<boolean> {
if (!this.harness) return false;
private async thinkingOnEffort(): Promise<string> {
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)

View file

@ -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');

View file

@ -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(<model default>) 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.

View file

@ -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');

View file

@ -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<unknown> }).getStatus = async () => ({
model: 'mock-model',
thinkingLevel: 'low',
thinkingEffort: 'low',
permission: 'ask',
planMode: false,
contextTokens: 1234,

View file

@ -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(<model default>) + 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(<model default>) + 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');

View file

@ -426,7 +426,7 @@ export class FullCompaction {
compacted_count: result.compactedCount,
retry_count: retryCount,
round,
thinking_level: this.agent.config.thinkingLevel,
thinking_effort: this.agent.config.thinkingEffort,
...(usage === null
? {}
: { input_tokens: inputTotal(usage), output_tokens: usage.output }),
@ -441,7 +441,7 @@ export class FullCompaction {
duration_ms: Date.now() - startedAt,
round,
retry_count: retryCount,
thinking_level: this.agent.config.thinkingLevel,
thinking_effort: this.agent.config.thinkingEffort,
error_type: error instanceof Error ? error.name : 'Unknown',
});
if (isKimiError(error) && error.code === ErrorCodes.AUTH_LOGIN_REQUIRED) throw error;

View file

@ -96,7 +96,7 @@ export class MicroCompaction {
cutoff: nextCutoff,
message_count: history.length,
cache_age_ms: cacheAgeMs,
thinking_level: this.agent.config.thinkingLevel,
thinking_effort: this.agent.config.thinkingEffort,
});
}
}

View file

@ -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 {

View file

@ -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<ThinkingEffort>(['low', 'medium', 'high', 'xhigh', 'max']);
export interface ResolveThinkingLevelOptions {
readonly defaultThinking?: boolean | undefined;
readonly thinking?: ThinkingConfig | undefined;
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 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);
function middleOf(efforts: readonly string[]): string {
return efforts[Math.floor(efforts.length / 2)]!;
}
export function resolveThinkingEffort(
requested: string | undefined,
defaults: ThinkingConfig | 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;
/**
* 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);
}
if (normalized === 'off') return 'off';
if (normalized === 'on') return configEffort;
return parseEffort(normalized) ?? configEffort;
return 'on';
}
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;
/**
* 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: ThinkingEffort | undefined,
config: ThinkingConfig | undefined,
model: ModelAlias | undefined,
): ThinkingEffort {
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);
}
return effort;
}

View file

@ -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;
}>;

View file

@ -304,9 +304,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 });
}

View file

@ -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)

View file

@ -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 } });
}

View file

@ -50,6 +50,12 @@ 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(),
// Route the Anthropic transport through the beta Messages API
// (`POST /v1/messages?beta=true`) instead of the standard endpoint. Used by
// managed Kimi Code models that declare `protocol: 'anthropic'`.
@ -59,7 +65,7 @@ export const ModelAliasSchema = z.object({
export type ModelAlias = z.infer<typeof ModelAliasSchema>;
export const ThinkingConfigSchema = z.object({
mode: z.enum(['auto', 'on', 'off']).optional(),
enabled: z.boolean().optional(),
effort: z.string().optional(),
});
@ -222,7 +228,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(),
@ -263,7 +268,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(),

View file

@ -460,6 +460,8 @@ export function configToTomlData(config: KimiConfig): Record<string, unknown> {
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<string, unknown> {
'defaultModel',
'planMode',
'yolo',
'defaultThinking',
'defaultPermissionMode',
'defaultPlanMode',
'mergeAllAvailableSkills',
@ -562,6 +563,7 @@ function modelToToml(model: ModelAlias, rawModel: unknown): Record<string, unkno
function thinkingToToml(thinking: ThinkingConfig, rawThinking: unknown): Record<string, unknown> {
const out = cloneRecord(rawThinking);
delete out['mode'];
for (const [key, value] of Object.entries(thinking)) {
setDefined(out, camelToSnake(key), value);
}

View file

@ -201,7 +201,7 @@ export interface CancelPayload {
readonly turnId?: number;
}
export interface SetThinkingPayload {
readonly level: string;
readonly effort: string;
}
export interface SetPermissionPayload {
readonly mode: PermissionMode;

View file

@ -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,
@ -224,7 +224,9 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
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,
@ -311,7 +313,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
const mainAgent = await session.createMain();
mainAgent.config.update({
modelAlias: options.model ?? config.defaultModel,
thinkingLevel,
thinkingEffort,
});
if (permissionMode !== undefined) {
mainAgent.permission.setMode(permissionMode);

View file

@ -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,

View file

@ -64,6 +64,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,
};
}

View file

@ -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);

View file

@ -475,7 +475,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,

View file

@ -126,6 +126,7 @@ export class ProviderManager implements ModelProvider {
this.options.promptCacheKey,
effectiveAdaptiveThinking,
alias.betaApi,
alias.supportEfforts,
);
return {
@ -242,6 +243,7 @@ function toKosongProviderConfig(
promptCacheKey: string | undefined,
adaptiveThinking: boolean | undefined,
betaApi: boolean | undefined,
supportEfforts: readonly string[] | undefined,
): KosongProviderConfig {
const effectiveType = modelProtocol === 'anthropic' ? 'anthropic' : provider.type;
const envCustomHeaders = parseKimiCodeCustomHeaders();
@ -296,6 +298,7 @@ function toKosongProviderConfig(
baseUrl: providerValue(provider.baseUrl, provider.env, 'KIMI_BASE_URL'),
apiKey: providerApiKey(provider),
generationKwargs: { prompt_cache_key: promptCacheKey },
supportEfforts,
...defaultHeadersField({
...envCustomHeaders,
...kimiRequestHeaders,

View file

@ -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(

View file

@ -240,7 +240,7 @@ describe('FullCompaction', () => {
duration_ms: expect.any(Number),
compacted_count: 6,
retry_count: 0,
thinking_level: 'off',
thinking_effort: 'off',
input_tokens: 520,
output_tokens: 8,
}),
@ -1724,7 +1724,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();
@ -1732,12 +1732,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',
thinking_level: 'high',
thinking_effort: 'high',
}),
});
});

View file

@ -485,7 +485,7 @@ describe('MicroCompaction', () => {
truncated_tool_result_tokens_after: expect.any(Number),
tokens_before: expect.any(Number),
tokens_after: expect.any(Number),
thinking_level: 'off',
thinking_effort: 'off',
});
expect(numberProperty(event, 'truncated_tool_result_tokens_before')).toBeGreaterThan(
numberProperty(event, 'truncated_tool_result_tokens_after'),

View file

@ -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 {

View file

@ -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();

View file

@ -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');
});
function model(overrides: Partial<ModelAlias> = {}): ModelAlias {
return {
provider: 'p',
model: 'm',
maxContextSize: 1,
...overrides,
};
}
it('returns off when config mode is off', () => {
expect(resolveThinkingEffort(undefined, { mode: 'off' })).toBe('off');
});
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 high when config mode is on without explicit effort', () => {
expect(resolveThinkingEffort(undefined, { mode: 'on' })).toBe('high');
});
it('returns explicit effort when both mode=on and effort are set', () => {
expect(resolveThinkingEffort(undefined, { mode: 'on', effort: 'medium' })).toBe('medium');
});
it('uses effort even when mode is omitted', () => {
expect(resolveThinkingEffort(undefined, { effort: 'low' })).toBe('low');
});
it('returns off when mode is off even if effort is set', () => {
expect(resolveThinkingEffort(undefined, { mode: 'off', effort: 'high' })).toBe('off');
});
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');
});
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 config effort when request is "on" and config has effort', () => {
expect(resolveThinkingEffort('on', { effort: 'medium' })).toBe('medium');
});
it('returns high when request is "on" and config has no effort', () => {
expect(resolveThinkingEffort('on', undefined)).toBe('high');
});
it('returns explicit effort level when request is a level name', () => {
expect(resolveThinkingEffort('xhigh', undefined)).toBe('xhigh');
});
it('falls back to config effort when request is unknown', () => {
expect(resolveThinkingEffort('bogus', { effort: 'low' })).toBe('low');
});
it('falls back to default high when request is unknown and no config', () => {
expect(resolveThinkingEffort('bogus', undefined)).toBe('high');
});
it('normalizes case and whitespace', () => {
expect(resolveThinkingEffort(' Medium ', undefined)).toBe('medium');
expect(resolveThinkingEffort('OFF', { mode: 'on' })).toBe('off');
});
it('returns the declared defaultEffort for effort-capable models', () => {
expect(defaultThinkingEffortFor(effortModelWithDefault)).toBe('max');
});
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('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',
);
});
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');
});
});
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 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('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 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('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('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',
);
});
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');
});
});

View file

@ -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<Agent['context']['data']>;
@ -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,
};
}

View file

@ -337,7 +337,7 @@ describe('agent replay range build', () => {
{
type: 'config.update',
cwd: process.cwd(),
thinkingLevel: 'off',
thinkingEffort: 'off',
},
{
type: 'usage.record',

View file

@ -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',
};
}

View file

@ -1551,7 +1551,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,

View file

@ -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');
});
});

View file

@ -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();
});
});

View file

@ -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 };
}

View file

@ -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',

View file

@ -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
@ -784,91 +784,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');
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'],
};
expect(
resolveThinkingLevel(undefined, {
defaultThinking: true,
thinking: { effort: 'medium', mode: 'auto' },
}),
).toBe('medium');
expect(
resolveThinkingLevel(' ', {
defaultThinking: true,
thinking: { effort: 'medium', mode: 'auto' },
}),
).toBe('medium');
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('on', {
defaultThinking: true,
thinking: { mode: 'auto' },
}),
).toBe('high');
expect(
resolveThinkingLevel(undefined, {
defaultThinking: true,
thinking: { mode: 'auto' },
}),
).toBe('high');
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',
);
});
expect(resolveThinkingLevel(undefined, {})).toBe('high');
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');
});
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');
});
});

View file

@ -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__*']);

View file

@ -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: [] });
});
});

View file

@ -59,7 +59,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;
}),
@ -245,7 +245,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,
@ -280,7 +280,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'],

View file

@ -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();

View file

@ -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' }),

View file

@ -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;

View file

@ -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');

View file

@ -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
@ -178,7 +183,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.

View file

@ -113,6 +113,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;
@ -340,6 +345,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;
}
@ -1222,10 +1239,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 ?? [])];

View file

@ -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<string, string> | 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;
}
@ -67,13 +70,13 @@ export interface GenerationKwargs {
presence_penalty?: number | undefined;
frequency_penalty?: number | undefined;
stop?: string | string[] | undefined;
reasoning_effort?: string | undefined;
prompt_cache_key?: string | undefined;
extra_body?: ExtraBody;
}
export interface ThinkingConfig {
type?: 'enabled' | 'disabled';
effort?: string;
keep?: unknown;
[key: string]: unknown;
}
@ -365,6 +368,7 @@ export class KimiChatProvider implements ChatProvider {
private _baseUrl: string;
private _defaultHeaders: Record<string, string> | 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 +382,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 +419,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<string, unknown> {
@ -486,8 +496,8 @@ export class KimiChatProvider implements ChatProvider {
try {
const client = this._createClient(options?.auth);
// Use type assertion via unknown because we pass Moonshot-proprietary fields
// (reasoning_effort, thinking) that don't exist in the OpenAI type definitions.
// Use type assertion via unknown because we pass the Moonshot-proprietary
// `thinking` field (via extra_body) that doesn't exist in the OpenAI type definitions.
options?.onRequestSent?.();
const response = (await client.chat.completions.create(
createParams as unknown as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming,
@ -500,28 +510,30 @@ export class KimiChatProvider implements ChatProvider {
}
withThinking(effort: ThinkingEffort): KimiChatProvider {
const thinking: ThinkingConfig = {
type: effort === 'off' ? 'disabled' : 'enabled',
};
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;
let thinking: ThinkingConfig;
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' };
}
return this._withGenerationKwargs({ reasoning_effort: reasoningEffort }).withExtraBody({
thinking,
// 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({
extra_body: { ...oldExtra, thinking },
});
}

View file

@ -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;
}
}

View file

@ -1827,7 +1827,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');

View file

@ -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);
});

View file

@ -22,18 +22,21 @@ 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,
});
}
type KimiGenerationState = {
max_tokens?: number | undefined;
temperature?: number | undefined;
reasoning_effort?: string | undefined;
prompt_cache_key?: string | undefined;
extra_body?: Record<string, unknown> | undefined;
};
@ -675,7 +678,6 @@ describe('KimiChatProvider', () => {
.withGenerationKwargs({ max_tokens: 512 });
expect(getGenerationState(provider)).toEqual({
reasoning_effort: 'high',
extra_body: {
thinking: { type: 'enabled' },
},
@ -714,20 +716,41 @@ 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', 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['thinking']).toEqual({ type: 'enabled', effort: 'high' });
expect(body['extra_body']).toBeUndefined();
});
it('effort-capable model passes max through to thinking.effort (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['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,22 +761,45 @@ 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: {
thinking: { type: 'disabled' },
},
@ -1246,8 +1292,8 @@ describe('KimiChatProvider', () => {
});
describe('withThinking medium', () => {
it('maps medium -> reasoning_effort=medium', () => {
const provider = createProvider().withThinking('medium');
it('maps medium -> thinking.effort=medium for an effort-capable model', () => {
const provider = createProvider(false, ['low', 'medium', 'high']).withThinking('medium');
expect(provider.thinkingEffort).toBe('medium');
});
});
@ -1265,7 +1311,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[] = [
@ -1273,8 +1319,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();
});

Some files were not shown because too many files have changed in this diff Show more