mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
e207f52f55
commit
108299be3c
127 changed files with 2909 additions and 830 deletions
6
.changeset/thinking-model-overhaul.md
Normal file
6
.changeset/thinking-model-overhaul.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": minor
|
||||
"@moonshot-ai/kimi-code-sdk": minor
|
||||
---
|
||||
|
||||
Refactor the thinking effort system
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
94
apps/kimi-code/src/tui/components/dialogs/effort-selector.ts
Normal file
94
apps/kimi-code/src/tui/components/dialogs/effort-selector.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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(' ')}`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
36
apps/kimi-code/src/tui/utils/thinking-config.ts
Normal file
36
apps/kimi-code/src/tui/utils/thinking-config.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const appState: AppState = {
|
|||
sessionTitle: null,
|
||||
model: 'kimi-k2',
|
||||
permissionMode: 'manual',
|
||||
thinking: false,
|
||||
thinkingEffort: 'off',
|
||||
contextUsage: 0,
|
||||
contextTokens: 0,
|
||||
maxContextTokens: 0,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 ]');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ function fakeInitialAppState(): AppState {
|
|||
planMode: false,
|
||||
inputMode: 'prompt',
|
||||
swarmMode: false,
|
||||
thinking: false,
|
||||
thinkingEffort: 'off',
|
||||
contextUsage: 0,
|
||||
contextTokens: 0,
|
||||
maxContextTokens: 0,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
49
apps/kimi-code/test/tui/utils/thinking-config.test.ts
Normal file
49
apps/kimi-code/test/tui/utils/thinking-config.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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`) | 按模型名推断 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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}).
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}>;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -337,7 +337,7 @@ describe('agent replay range build', () => {
|
|||
{
|
||||
type: 'config.update',
|
||||
cwd: process.cwd(),
|
||||
thinkingLevel: 'off',
|
||||
thinkingEffort: 'off',
|
||||
},
|
||||
{
|
||||
type: 'usage.record',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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__*']);
|
||||
|
|
|
|||
|
|
@ -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: [] });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ?? [])];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue