Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
4138656
fix: fall back to the model default for thinking efforts outside supp…
kimi-agent-bot Aug 20, 2026
be43c7e
fix: update thinking-effort rejection hint for declared-list fallback
kimi-agent-bot Aug 20, 2026
b191946
test(kosong): update thinking-effort rejection hint expectation
kimi-agent-bot Aug 20, 2026
a24ef25
fix: fall back to the declared default effort for models without a th…
kimi-agent-bot Aug 20, 2026
08902a0
fix(agent-core-v2): normalize persisted thinking efforts after sessio…
kimi-agent-bot Aug 20, 2026
f1217ec
fix(agent-core): warn when a Kimi env thinking-effort override is unl…
kimi-agent-bot Aug 20, 2026
d32ed94
fix: note the forced-effort exception in thinking-effort rejection gu…
kimi-agent-bot Aug 20, 2026
2a1f750
fix: suppress the thinking-effort fallback warning when a forced effo…
kimi-agent-bot Aug 20, 2026
4b8c8da
fix(agent-core): warn when a config reload strands the current thinki…
kimi-agent-bot Aug 20, 2026
f2c856c
fix(agent-core): warn on createSession efforts that fall back to the …
kimi-agent-bot Aug 20, 2026
07b6f6d
fix(kimi-code): reject /effort values outside a model's declared list
kimi-agent-bot Aug 20, 2026
5b64151
fix(kimi-code): ignore blank support_efforts entries in the /effort c…
kimi-agent-bot Aug 21, 2026
119db0f
fix: treat a declared support_efforts list as thinking support under …
kimi-agent-bot Aug 21, 2026
2a32e56
fix(agent-core): trim declared thinking efforts before matching and f…
kimi-agent-bot Aug 21, 2026
5d3ea30
fix(kimi-code): match /effort against trimmed declared effort names
kimi-agent-bot Aug 21, 2026
21bd007
fix: finish normalizing declared thinking-effort lists
kimi-agent-bot Aug 21, 2026
baff362
fix: align declared-effort handling across the TUI and request diagno…
kimi-agent-bot Aug 21, 2026
5c71641
fix(kimi-code): resolve /effort on to the model default before lazy s…
kimi-agent-bot Aug 21, 2026
15ce9aa
fix(kimi-code): reject an unlisted declared default effort in the TUI
kimi-agent-bot Aug 21, 2026
bfc60b9
fix(kimi-code): match the top thinking tier against the normalized de…
kimi-agent-bot Aug 21, 2026
b8fa016
fix: align the picker's unlisted-default fallback and the 400 hint wi…
kimi-agent-bot Aug 21, 2026
04ad3e2
fix(agent-core): report an unlisted env thinking-effort override only…
kimi-agent-bot Aug 21, 2026
63980b0
fix(agent-core): recheck an env-pinned effort against reloaded declar…
kimi-agent-bot Aug 21, 2026
2318d63
fix(kimi-code): hydrate unlisted configured thinking efforts to the m…
kimi-agent-bot Aug 21, 2026
f8bbc7a
fix: normalize hydrated efforts and warn when a reload strands the st…
kimi-agent-bot Aug 21, 2026
f468c16
fix(agent-core-v2): republish status on reload fallback and watch pro…
kimi-agent-bot Aug 21, 2026
134c1f6
fix: republish status on effort restoration and match declared effort…
kimi-agent-bot Aug 21, 2026
534780d
fix(agent-core-v2): coalesce thinking-effort revalidation across a co…
kimi-agent-bot Aug 21, 2026
4a4a92c
fix: keep inherited default_effort covered by a padded override list
kimi-agent-bot Aug 21, 2026
742c370
test(agent-core-v2): drive thinking-effort revalidation with an injec…
kimi-agent-bot Aug 21, 2026
9799e01
fix: match declared thinking efforts case-insensitively in diagnostic…
kimi-agent-bot Aug 21, 2026
fd09758
fix: include the resolved fallback in thinking-effort warning dedupli…
kimi-agent-bot Aug 21, 2026
611438d
test(agent-core-v2): drop an inline comment from the reload fallback …
kimi-agent-bot Aug 21, 2026
b290ac3
fix(agent-core-v2): track the last published thinking effort for relo…
kimi-agent-bot Aug 21, 2026
File filter

Filter by extension

Filter by extension

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

Models with a declared support_efforts list now fall back to their default thinking effort when the configured effort is not in the list.
42 changes: 35 additions & 7 deletions apps/kimi-code/src/tui/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ import {
ExperimentsSelectorComponent,
type ExperimentalFeatureDraftChange,
} from '../components/dialogs/experiments-selector';
import { modelDisplayName, segmentsFor } from '../components/dialogs/model-selector';
import {
defaultThinkingEffortFor,
modelDisplayName,
segmentsFor,
effortsOf,
} 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';
Expand Down Expand Up @@ -311,22 +316,35 @@ export async function handleEffortCommand(host: SlashCommandHost, args: string):
showEffortPicker(host, effective, segments);
return;
}
if (!segments.includes(arg)) {
const canonical = segments.find((segment) => segment.toLowerCase() === arg);
if (canonical === undefined) {
const declared = effortsOf(effective);
// 'on' is the generic enable signal: with a declared effort list the
// engine maps it to the declared default effort, so it stays a valid
// command even though it never appears in the segment list.
if (arg === 'on' && declared.length > 0) {
await performModelSwitch(host, alias, arg, true);
Comment thread
kimi-agent-bot marked this conversation as resolved.
return;
}
const providerType = host.state.appState.availableProviders[effective.provider]?.type;
const protocol = effective.protocol ?? providerType;
if (protocol !== 'anthropic') {
// With a declared effort list the engine falls back to the model default
// for every protocol, so an unlisted value is rejected like any invalid
// input. Only Anthropic-compatible models WITHOUT a declared list keep
// the warn-and-send escape hatch — there the engine passes the value
// through for the backend to judge.
if (protocol !== 'anthropic' || declared.length > 0) {
host.showError(
`Unsupported thinking effort "${arg}" for ${alias}. Available: ${segments.join(', ')}`,
);
return;
}
const knownEfforts = effective.supportEfforts?.join(', ') ?? 'none declared';
host.showStatus(
`Thinking effort "${arg}" is not listed for ${alias} (known: ${knownEfforts}). Sending "${arg}" unchanged; the configured provider will validate it.`,
`Thinking effort "${arg}" is not declared for ${alias}. Sending "${arg}" unchanged; the configured provider will validate it.`,
'warning',
);
}
await performModelSwitch(host, alias, arg, true);
await performModelSwitch(host, alias, canonical ?? arg, true);
}

function showEffortPicker(
Expand Down Expand Up @@ -512,7 +530,17 @@ async function performModelSwitch(

try {
if (session === undefined && runtimeChanged) {
await host.authFlow.activateModelAfterLogin(alias, effort);
// Session-less (lazy creation): no engine is around to map the generic
// 'on' onto the model's declared default yet, so resolve it here — the
// carried state and the status line should show the effort the first
// session will actually use. The engine re-resolves the concrete value
// at creation, which is in-list and therefore unchanged.
const selectedModel = host.state.appState.availableModels[alias];
const sessionlessEffort =
effort === 'on' && selectedModel !== undefined
? defaultThinkingEffortFor(effectiveModelForHost(host, selectedModel))
: effort;
await host.authFlow.activateModelAfterLogin(alias, sessionlessEffort);
} else if (session !== undefined) {
if (alias !== prevModel) {
await session.setModel(alias);
Expand Down
57 changes: 45 additions & 12 deletions apps/kimi-code/src/tui/components/dialogs/model-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,12 @@ export function thinkingAvailability(model: ModelAlias): ThinkingAvailability {
}

export function effortsOf(model: ModelAlias): readonly string[] {
return model.supportEfforts ?? [];
// Blank entries are not real efforts: the engine resolvers discard them
// (effortsFor), so a list like [""] must not count as a declared list here.
// Entries are trimmed so padded declarations match normalized /effort input.
return (model.supportEfforts ?? [])
.map((effort) => effort.trim())
Comment thread
kimi-agent-bot marked this conversation as resolved.
.filter((effort) => effort.length > 0);
Comment thread
kimi-agent-bot marked this conversation as resolved.
}

/**
Expand Down Expand Up @@ -138,14 +143,50 @@ export function effortLabel(effort: string): string {
* thinking is unsupported.
*/
export 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)]!;
const declared = model.defaultEffort?.trim();
const matched = declared === undefined ? undefined : matchDeclaredEffort(efforts, declared);
return (matched ?? efforts[Math.floor(efforts.length / 2)]!) as ThinkingEffort;
}
if (thinkingAvailability(model) === 'unsupported') return 'off';
return 'on';
}

/**
* Case-insensitive membership against the declared list, returning the
* declared (canonical) entry — the backend recognizes the declared casing.
*/
function matchDeclaredEffort(
efforts: readonly string[],
effort: string,
): string | undefined {
return efforts.find((candidate) => candidate.toLowerCase() === effort.toLowerCase());
}

/**
* Mirror of the engine's configured-effort resolution, for session-less
* display state: with a declared effort list, `'on'` or an unlisted value
* becomes the model's default effort; listed values (and anything, when no
* list is declared) pass through. `'off'` always stays `'off'`.
*/
export function resolveConfiguredEffortForModel(
effort: ThinkingEffort,
model: ModelAlias,
): ThinkingEffort {
// Normalize like the engine does before any comparison (trim + lowercase);
// a blank value reads as unconfigured and resolves to the model default.
const normalized = effort.trim().toLowerCase();
if (normalized.length === 0) return defaultThinkingEffortFor(model);
const efforts = effortsOf(model);
if (efforts.length === 0 || normalized === 'off') return normalized;
if (normalized !== 'on') {
const matched = matchDeclaredEffort(efforts, normalized);
if (matched !== undefined) return matched as ThinkingEffort;
}
return defaultThinkingEffortFor(model);
Comment thread
kimi-agent-bot marked this conversation as resolved.
}

/**
* Normalize a draft effort before committing a selection. A boolean `'on'`
* never leaks past the UI boundary — it becomes the model's default effort
Expand Down Expand Up @@ -196,15 +237,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
const override = this.thinkingOverrides.get(choice.alias);
if (override !== undefined) return override;
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';
return defaultThinkingEffortFor(choice.model);
}

/** Draft coerced onto the model's segment list so rendering/selection never
Expand Down
28 changes: 22 additions & 6 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ import {
} from './components/dialogs/approval-preview';
import { CompactionComponent } from './components/dialogs/compaction';
import { HelpPanelComponent } from './components/dialogs/help-panel';
import { defaultThinkingEffortFor } from './components/dialogs/model-selector';
import {
defaultThinkingEffortFor,
resolveConfiguredEffortForModel,
} from './components/dialogs/model-selector';
import { QuestionDialogComponent } from './components/dialogs/question-dialog';
import { SessionPickerComponent, type SessionRow } from './components/dialogs/session-picker';
import { TrustPromptComponent, type TrustPromptChoice } from './components/dialogs/trust-prompt';
Expand Down Expand Up @@ -2154,16 +2157,29 @@ export class KimiTUI {
patch.planMode = config.defaultPlanMode === true;
}
const effort = thinkingEffortFromConfig(config.thinking);
const startupModelConfig =
startupModel === undefined ? undefined : config.models?.[startupModel];
const startupProviderType =
startupModelConfig === undefined
? undefined
: (config.providers?.[startupModelConfig.provider]?.type ?? startupModelConfig.protocol);
if (effort !== undefined) {
patch.thinkingEffort = effort;
// A configured effort outside the model's declared list falls back to
// the declared default at session creation — hydrate the same way so
// the footer and the picker never coerce it to 'off'.
patch.thinkingEffort =
startupModelConfig === undefined
? effort
: resolveConfiguredEffortForModel(
effort,
effectiveModelAlias(startupModelConfig, startupProviderType),
);
} else if (startupModel !== undefined) {
// No concrete effort configured: mirror the engine, which resolves the
// model's default effort at createSession time.
const raw = config.models?.[startupModel];
if (raw !== undefined) {
const providerType = config.providers?.[raw.provider]?.type;
if (startupModelConfig !== undefined) {
patch.thinkingEffort = defaultThinkingEffortFor(
effectiveModelAlias(raw, providerType ?? raw.protocol),
effectiveModelAlias(startupModelConfig, startupProviderType),
);
}
}
Expand Down
7 changes: 6 additions & 1 deletion apps/kimi-code/src/tui/utils/thinking-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ export function thinkingEffortToConfig(
} {
if (effort === 'off') return { enabled: false };
if (effort === 'on') return { enabled: true };
const top = supportEfforts?.at(-1);
// Declared entries are padded-tolerant, matching the engine resolvers:
// trim and drop blanks before identifying the top tier.
const declared = (supportEfforts ?? [])
.map((value) => value.trim())
.filter((value) => value.length > 0);
const top = declared.at(-1);
if (top !== undefined && effort === top) return { enabled: true };
return { enabled: true, effort };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk';
import { visibleWidth } from '@moonshot-ai/pi-tui';
import { describe, expect, it, vi } from 'vitest';

import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector';
import {
ModelSelectorComponent,
defaultThinkingEffortFor,
resolveConfiguredEffortForModel,
} from '#/tui/components/dialogs/model-selector';
import { currentTheme } from '#/tui/theme';
import { darkColors } from '#/tui/theme/colors';

Expand Down Expand Up @@ -504,6 +508,72 @@ describe('ModelSelectorComponent', () => {
expect(text(picker)).toContain('[ Medium ]');
});

it('falls back to the middle effort when the declared defaultEffort is unlisted', () => {
const onSelect = vi.fn();
const picker = new ModelSelectorComponent({
models: {
other: effortModel('Kimi Other', ['low', 'high'], 'max'),
},
currentValue: 'current',
currentThinkingEffort: 'off',
onSelect,
onCancel: vi.fn(),
});

// An unlisted default_effort is not selectable: the middle entry wins,
// matching the engine resolvers.
expect(text(picker)).toContain('[ High ]');
picker.handleInput('\r');
expect(onSelect).toHaveBeenCalledWith({ alias: 'other', thinking: 'high' });
});

it('trims a padded defaultEffort before matching the declared efforts', () => {
const onSelect = vi.fn();
const picker = new ModelSelectorComponent({
models: {
other: effortModel('Kimi Other', [' low ', ' medium ', ' xhigh '], ' xhigh '),
},
currentValue: 'current',
currentThinkingEffort: 'off',
onSelect,
onCancel: vi.fn(),
});

// The padded declared default wins over the first/middle entry.
expect(text(picker)).toContain('[ Xhigh ]');
picker.handleInput('\r');
expect(onSelect).toHaveBeenCalledWith({ alias: 'other', thinking: 'xhigh' });
});

it('prefers the declared default effort when the model omits the thinking capability', () => {
expect(defaultThinkingEffortFor(effortModel('Kimi Other', ['low', 'high', 'max'], 'max', []))).toBe(
'max',
);
expect(defaultThinkingEffortFor(effortModel('Kimi Other', ['low', 'high', 'max'], undefined, []))).toBe(
'high',
);
});

it('normalizes a padded configured effort before matching the declared list', () => {
const declared = effortModel('Kimi Other', ['low', 'high', 'max'], 'high');
expect(resolveConfiguredEffortForModel(' LOW ', declared)).toBe('low');
expect(resolveConfiguredEffortForModel(' ', declared)).toBe('high');
expect(resolveConfiguredEffortForModel(' ULTRA ', declared)).toBe('high');
});

it('matches declared efforts case-insensitively and resolves the declared casing', () => {
const declared = effortModel('Kimi Other', ['Low', 'High', 'Max'], 'max');
expect(resolveConfiguredEffortForModel('low', declared)).toBe('Low');
expect(defaultThinkingEffortFor(declared)).toBe('Max');
});

it('falls back to the middle entry when the declared default is not listed', () => {
// Matches the engine resolvers: an unlisted default_effort is rejected.
expect(defaultThinkingEffortFor(effortModel('Kimi Other', ['low', 'high'], 'max', []))).toBe(
'high',
);
});

it('renders the warning line directly below the key-hint line when provided', () => {
const picker = new ModelSelectorComponent({
models: { kimi: model('Kimi K2') },
Expand Down
Loading
Loading