From 4138656ee5b064635d331e1602b3446d75f0db8a Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Thu, 20 Aug 2026 17:26:43 +0000 Subject: [PATCH 01/34] fix: fall back to the model default for thinking efforts outside support_efforts Non-Kimi providers used to pass a configured thinking effort through unchanged even when the model declared a support_efforts list that did not contain it, so backends rejecting unknown efforts failed every session. Both engines now resolve an unlisted concrete effort to the model's default effort (declared default_effort, else the middle list entry) whenever a support_efforts list is declared, and emit a one-time warning naming the configured value and the applied fallback. Models without a declared list keep the pass-through behavior. --- ...inking-effort-fallback-declared-efforts.md | 5 + .../agent/llmRequester/llmRequesterService.ts | 16 +-- .../src/agent/profile/profileService.ts | 62 ++++++----- .../src/kosong/model/thinking.ts | 34 +++++- .../llmRequester/llmRequesterService.test.ts | 25 ++++- .../test/agent/profile/config-state.test.ts | 8 +- .../test/agent/profile/thinking.test.ts | 23 ++++ .../test/kosong/model/thinking.test.ts | 32 +++++- packages/agent-core/src/agent/config/index.ts | 16 ++- .../agent-core/src/agent/config/thinking.ts | 70 +++++++++--- packages/agent-core/src/agent/index.ts | 104 ++++++++---------- .../test/agent/config-state.test.ts | 8 +- .../test/agent/config/thinking.test.ts | 26 ++++- .../test/harness/model-alias-session.test.ts | 4 +- 14 files changed, 288 insertions(+), 145 deletions(-) create mode 100644 .changeset/thinking-effort-fallback-declared-efforts.md diff --git a/.changeset/thinking-effort-fallback-declared-efforts.md b/.changeset/thinking-effort-fallback-declared-efforts.md new file mode 100644 index 0000000000..7f1684aca3 --- /dev/null +++ b/.changeset/thinking-effort-fallback-declared-efforts.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Models with a declared support_efforts list now fall back to their default thinking effort when the configured effort is not in the list. diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 45dc08dbe6..35d21da4e5 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -336,7 +336,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { signal, ), }; - this.warnAboutAnthropicThinkingEffort(request); + this.warnAboutThinkingEffortNotListed(request); const logInput: LLMRequestLogInput = { protocol: request.model.protocol, providerType: request.model.providerType, @@ -514,21 +514,15 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { return assigned === part.id ? part : { ...part, id: assigned }; } - private warnAboutAnthropicThinkingEffort(request: ResolvedLLMRequest): void { - if (request.model.protocol !== 'anthropic') return; + private warnAboutThinkingEffortNotListed(request: ResolvedLLMRequest): void { const effort = request.thinkingEffort; if (effort === 'on' || effort === 'off') return; - - let code: string; - let message: string; - let knownEfforts: string | undefined; const supportEfforts = request.model.supportEfforts?.filter((value) => value.length > 0); if (supportEfforts === undefined || supportEfforts.length === 0) return; if (supportEfforts.includes(effort)) return; - code = 'anthropic-thinking-effort-not-listed'; - knownEfforts = supportEfforts.join(','); - message = `Thinking effort "${effort}" is not listed for model "${request.model.name}" (known: ${supportEfforts.join(', ')}). The configured value will be sent unchanged to the Anthropic-compatible backend.`; - + const code = 'thinking-effort-not-listed'; + const knownEfforts = supportEfforts.join(','); + const message = `Thinking effort "${effort}" is not listed for model "${request.model.name}" (known: ${supportEfforts.join(', ')}). The value will be sent unchanged to the backend.`; const key = [code, request.modelAlias, request.model.name, effort, knownEfforts].join('\u0000'); if (this.emittedThinkingEffortWarnings.has(key)) return; this.emittedThinkingEffortWarnings.add(key); diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 2b504afa03..20300e2d1b 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -14,6 +14,7 @@ import { normalizeRequestedThinkingEffort, resolveForcedThinkingEffort, resolveThinkingEffortForModel, + resolveThinkingEffortForModelWithFallback, resolveThinkingKeep, requiresStrictThinkingValidation, type ThinkingConfig, @@ -247,8 +248,13 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ this.activeProfile = undefined; } if (Object.keys(configChanged).length > 0) { - void this.dispatcher.dispatch(new ConfigUpdate(this.resolveConfigPayload(configChanged))); - this.afterConfigDispatch(configChanged); + const thinkingRequested = + configChanged.thinkingLevel ?? + (this.modelAlias === undefined ? undefined : this.thinkingLevel); + void this.dispatcher.dispatch( + new ConfigUpdate(this.resolveConfigPayload(configChanged, thinkingRequested)), + ); + this.afterConfigDispatch(configChanged, thinkingRequested); } if (activeToolNames !== undefined) { this.setActiveTools(activeToolNames); @@ -283,7 +289,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ environmentDisclosure: snapshot.environmentDisclosure, agentsMdPaths, disallowedTools: snapshot.disallowedTools ?? [], - }); + }, snapshot.thinkingLevel); this.agentsMdReminder.seedInjected(agentsMdPaths, this.sessionContext.cwd); } @@ -324,10 +330,9 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ this.activeProfile = profile; this.cacheAgentsMdWarning(context); - const thinkingLevel = this.resolveThinkingEffort( - input.thinking ?? (currentProfileName !== undefined ? this.thinkingLevel : undefined), - model, - ); + const thinkingRequested = + input.thinking ?? (currentProfileName !== undefined ? this.thinkingLevel : undefined); + const thinkingLevel = this.resolveThinkingEffort(thinkingRequested, model); this.activeToolNamesOverlay = undefined; await this.dispatcher.dispatch(new ProfileBind({ @@ -348,7 +353,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ thinkingLevel, systemPrompt: rendered.text, disallowedTools: profile.disallowedTools ?? [], - }); + }, thinkingRequested); this.seedAgentsMdReminder(context); this.publishAgentsMdWarning(); @@ -568,15 +573,14 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ private resolveConfigPayload( changed: Omit, + thinkingRequested: string | undefined, ): ConfigUpdatePayload { const payload: ConfigUpdatePayload = { agentId: this.scopeContext.agentId }; if (changed.modelAlias !== undefined) payload.modelAlias = changed.modelAlias; if (changed.profileName !== undefined) payload.profileName = changed.profileName; if (changed.thinkingLevel !== undefined || changed.modelAlias !== undefined) { const model = this.resolveModelForThinking(changed.modelAlias ?? this.modelAlias); - const requested = - changed.thinkingLevel ?? (this.modelAlias === undefined ? undefined : this.thinkingLevel); - payload.thinkingEffort = this.resolveThinkingEffort(requested, model); + payload.thinkingEffort = this.resolveThinkingEffort(thinkingRequested, model); } if (changed.systemPrompt !== undefined) { payload.systemPrompt = changed.systemPrompt; @@ -593,7 +597,10 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ return payload; } - private afterConfigDispatch(changed: Omit): void { + private afterConfigDispatch( + changed: Omit, + thinkingRequested: string | undefined, + ): void { if (changed.modelAlias !== undefined) { const model = this.tryResolveRawModel(); this.telemetryContext.set({ @@ -602,30 +609,29 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ }); } if (changed.modelAlias !== undefined || changed.thinkingLevel !== undefined) { - this.warnAboutAnthropicThinkingEffort(); + this.warnAboutThinkingEffortFallback(thinkingRequested); } this.emitStatusUpdated( changed.modelAlias !== undefined || changed.thinkingLevel !== undefined, ); } - private warnAboutAnthropicThinkingEffort(): void { + private warnAboutThinkingEffortFallback(requested: string | undefined): void { try { const model = this.tryResolveRawModel(); - if (model?.protocol !== 'anthropic') return; - const effort = this.getEffectiveThinkingLevel(); - if (effort === 'on' || effort === 'off') return; - - let code: string; - let message: string; - let knownEfforts = ''; - const efforts = model.supportEfforts?.filter((value) => value.length > 0); - if (efforts === undefined || efforts.length === 0 || efforts.includes(effort)) return; - knownEfforts = efforts.join(','); - code = 'anthropic-thinking-effort-not-listed'; - message = `Thinking effort "${effort}" is not listed for model "${model.name}" (known: ${efforts.join(', ')}). The configured value will be sent unchanged to the Anthropic-compatible backend.`; - - const key = [code, model.id, model.name, effort, knownEfforts].join('\u0000'); + if (model === undefined) return; + const { fallback } = resolveThinkingEffortForModelWithFallback( + requested, + this.config.get(THINKING_SECTION), + model, + this.strictThinkingValidation(model), + ); + if (fallback === undefined) return; + const efforts = model.supportEfforts?.filter((value) => value.length > 0) ?? []; + const knownEfforts = efforts.join(','); + const code = 'thinking-effort-not-listed'; + const message = `Thinking effort "${fallback.configured}" is not listed for model "${model.name}" (known: ${efforts.join(', ')}). Falling back to the model's default effort "${fallback.resolved}".`; + const key = [code, model.id, model.name, fallback.configured, knownEfforts].join('\u0000'); if (this.emittedThinkingEffortWarnings.has(key)) return; this.emittedThinkingEffortWarnings.add(key); void this.dispatcher.dispatch(new WarningIssued({ agentId: this.scopeContext.agentId, code, message })); diff --git a/packages/agent-core-v2/src/kosong/model/thinking.ts b/packages/agent-core-v2/src/kosong/model/thinking.ts index 96238d5e84..3532d03e9a 100644 --- a/packages/agent-core-v2/src/kosong/model/thinking.ts +++ b/packages/agent-core-v2/src/kosong/model/thinking.ts @@ -144,9 +144,11 @@ function normalizeThinkingEffortForModel( if (effort === 'off' && model?.alwaysThinking !== true) return 'off'; const efforts = effortsFor(model); if (!strictValidation) { - return effort === 'on' && efforts.length > 0 - ? defaultThinkingEffortForModel(model) - : effort; + if (efforts.length === 0) return effort; + if (effort === 'on' || !efforts.includes(effort)) { + return defaultThinkingEffortForModel(model); + } + return effort; } if (!modelSupportsThinking(model)) return 'off'; if (efforts.length === 0) return 'on'; @@ -156,12 +158,17 @@ function normalizeThinkingEffortForModel( return effort; } -export function resolveThinkingEffortForModel( +export interface ThinkingEffortFallback { + readonly configured: ThinkingEffort; + readonly resolved: ThinkingEffort; +} + +export function resolveThinkingEffortForModelWithFallback( requested: string | undefined, defaults: ThinkingDefaults | undefined, model: ModelThinkingMetadata | undefined, strictValidation = false, -): ThinkingEffort { +): { readonly effort: ThinkingEffort; readonly fallback: ThinkingEffortFallback | undefined } { const configured = normalizeRequestedThinkingEffort(defaults?.effort); const normalized = normalizeRequestedThinkingEffort(requested); let effort: ThinkingEffort; @@ -179,7 +186,22 @@ export function resolveThinkingEffortForModel( ? configured : defaultThinkingEffortForModel(model); } - return normalizeThinkingEffortForModel(effort, model, strictValidation); + const efforts = effortsFor(model); + const fallback: ThinkingEffortFallback | undefined = + effort !== 'on' && effort !== 'off' && efforts.length > 0 && !efforts.includes(effort) + ? { configured: effort, resolved: defaultThinkingEffortForModel(model) } + : undefined; + return { effort: normalizeThinkingEffortForModel(effort, model, strictValidation), fallback }; +} + +export function resolveThinkingEffortForModel( + requested: string | undefined, + defaults: ThinkingDefaults | undefined, + model: ModelThinkingMetadata | undefined, + strictValidation = false, +): ThinkingEffort { + return resolveThinkingEffortForModelWithFallback(requested, defaults, model, strictValidation) + .effort; } const KEEP_OFF_VALUES = new Set(['0', 'false', 'no', 'off', 'none', 'null']); diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts index 5df2000f84..f51a9893aa 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts @@ -298,7 +298,7 @@ describe('AgentLLMRequesterService measured anchors', () => { }); }); -describe('AgentLLMRequesterService Anthropic effort diagnostics', () => { +describe('AgentLLMRequesterService thinking effort diagnostics', () => { it('warns and sends when the effort is not listed by the model', async () => { const calls = { value: 0 }; const requester = createRequester(calls, null); @@ -312,9 +312,28 @@ describe('AgentLLMRequesterService Anthropic effort diagnostics', () => { expect(events.filter((event) => event.type === 'warning')).toEqual([ expect.objectContaining({ type: 'warning', - code: 'anthropic-thinking-effort-not-listed', + code: 'thinking-effort-not-listed', message: - 'Thinking effort "high" is not listed for model "wire-model" (known: max). The configured value will be sent unchanged to the Anthropic-compatible backend.', + 'Thinking effort "high" is not listed for model "wire-model" (known: max). The value will be sent unchanged to the backend.', + }), + ]); + }); + + it('warns for unlisted efforts on any protocol', async () => { + const calls = { value: 0 }; + const requester = createRequester(calls, null); + Object.defineProperty(requester.model, 'protocol', { value: 'openai' }); + Object.defineProperty(requester.model, 'supportEfforts', { value: ['max'] }); + const { service, events } = createService(requester, undefined, { thinkingLevel: 'high' }); + + await service.request(); + + expect(events.filter((event) => event.type === 'warning')).toEqual([ + expect.objectContaining({ + type: 'warning', + code: 'thinking-effort-not-listed', + message: + 'Thinking effort "high" is not listed for model "wire-model" (known: max). The value will be sent unchanged to the backend.', }), ]); }); diff --git a/packages/agent-core-v2/test/agent/profile/config-state.test.ts b/packages/agent-core-v2/test/agent/profile/config-state.test.ts index e9e949ceb8..5e2d3f7ca3 100644 --- a/packages/agent-core-v2/test/agent/profile/config-state.test.ts +++ b/packages/agent-core-v2/test/agent/profile/config-state.test.ts @@ -432,20 +432,20 @@ describe('ConfigState thinking clamp for always-thinking models', () => { expect(profile.data().thinkingLevel).toBe('max'); }); - it('preserves unlisted efforts with a warning for Kimi-managed Anthropic models', () => { + it('falls back to the model default with a warning for an unlisted effort', () => { profile.update({ modelAlias: 'kimi-code/compatible', thinkingLevel: 'max' }); expect(() => { profile.setThinking('high'); }).not.toThrow(); - expect(profile.data().thinkingLevel).toBe('high'); + expect(profile.data().thinkingLevel).toBe('max'); expect(ctx.allEvents).toContainEqual({ type: '[rpc]', event: 'warning', args: expect.objectContaining({ - code: 'anthropic-thinking-effort-not-listed', + code: 'thinking-effort-not-listed', message: - 'Thinking effort "high" is not listed for model "compatible-model" (known: max). The configured value will be sent unchanged to the Anthropic-compatible backend.', + 'Thinking effort "high" is not listed for model "compatible-model" (known: max). Falling back to the model\'s default effort "max".', }), }); }); diff --git a/packages/agent-core-v2/test/agent/profile/thinking.test.ts b/packages/agent-core-v2/test/agent/profile/thinking.test.ts index e5e063c942..5983e9bf72 100644 --- a/packages/agent-core-v2/test/agent/profile/thinking.test.ts +++ b/packages/agent-core-v2/test/agent/profile/thinking.test.ts @@ -187,6 +187,29 @@ describe('resolveThinkingEffortForModel', () => { ); }); + it('falls back to the declared default for an unlisted effort on non-strict protocols', () => { + const declared = { + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'xhigh'], + defaultEffort: 'xhigh', + protocol: 'openai', + providerType: 'openai', + }; + expect(resolveThinkingEffortForModel(undefined, { effort: 'high' }, declared, false)).toBe( + 'xhigh', + ); + expect(resolveThinkingEffortForModel('high', undefined, declared, false)).toBe('xhigh'); + expect(resolveThinkingEffortForModel('medium', undefined, declared, false)).toBe('medium'); + expect(resolveThinkingEffortForModel('xhigh', undefined, declared, false)).toBe('xhigh'); + }); + + it('still passes unlisted efforts through when the model declares no list', () => { + expect(resolveThinkingEffortForModel('ultra', undefined, booleanModel, false)).toBe('ultra'); + expect(resolveThinkingEffortForModel(undefined, { effort: 'ultra' }, booleanModel, false)).toBe( + 'ultra', + ); + }); + it('projects a concrete effort to on for a boolean-only Kimi model', () => { expect(resolveThinkingEffortForModel('ultra', undefined, kimiBooleanModel, true)).toBe('on'); }); diff --git a/packages/agent-core-v2/test/kosong/model/thinking.test.ts b/packages/agent-core-v2/test/kosong/model/thinking.test.ts index f5fb7ed8ff..36ec0df8f4 100644 --- a/packages/agent-core-v2/test/kosong/model/thinking.test.ts +++ b/packages/agent-core-v2/test/kosong/model/thinking.test.ts @@ -65,10 +65,38 @@ describe('resolveThinkingEffortForModel', () => { expect(defaultThinkingEffortForModel(undefined)).toBe('off'); }); - it('normalizes unknown efforts back to the model default under kimi semantics', () => { + it('normalizes unknown efforts back to the model default on any wire', () => { expect(resolveThinkingEffortForModel('extreme', undefined, thinkingModel, true)).toBe('high'); - expect(resolveThinkingEffortForModel('extreme', undefined, thinkingModel, false)).toBe('extreme'); + expect(resolveThinkingEffortForModel('extreme', undefined, thinkingModel, false)).toBe('high'); expect(resolveThinkingEffortForModel('on', undefined, thinkingModel, true)).toBe('high'); + expect(resolveThinkingEffortForModel('on', undefined, thinkingModel, false)).toBe('high'); + }); + + it('falls back to the declared default for an unlisted effort without strict validation', () => { + const declared = { + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'xhigh'], + defaultEffort: 'xhigh', + }; + expect(resolveThinkingEffortForModel(undefined, { effort: 'high' }, declared, false)).toBe( + 'xhigh', + ); + expect(resolveThinkingEffortForModel('high', undefined, declared, false)).toBe('xhigh'); + expect(resolveThinkingEffortForModel('medium', undefined, declared, false)).toBe('medium'); + }); + + it('passes concrete efforts through when the model declares no effort list', () => { + expect( + resolveThinkingEffortForModel('extreme', undefined, { capabilities: ['thinking'] }, false), + ).toBe('extreme'); + expect( + resolveThinkingEffortForModel( + undefined, + { effort: 'extreme' }, + { capabilities: ['thinking'] }, + false, + ), + ).toBe('extreme'); }); it('keeps always-thinking models on under kimi semantics', () => { diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts index 5696060429..5fc24a3877 100644 --- a/packages/agent-core/src/agent/config/index.ts +++ b/packages/agent-core/src/agent/config/index.ts @@ -17,9 +17,10 @@ import type { Agent } from '..'; import { ErrorCodes, KimiError } from '../../errors'; import type { AgentConfigData, AgentConfigUpdateData } from './types'; import { - resolveThinkingEffort, + resolveThinkingEffortWithFallback, supportsThinkingEffort, type ThinkingEffort, + type ThinkingEffortFallback, } from './thinking'; import type { ModelAlias } from '../../config/schema'; import type { ResolvedRuntimeProvider } from '../../session/provider-manager'; @@ -68,25 +69,30 @@ export class ConfigState { const kimiProvider = targetProvider?.type === 'kimi'; let unforcedThinkingEffort: ThinkingEffort | undefined; let thinkingEffort: ThinkingEffort | undefined; + let thinkingFallback: ThinkingEffortFallback | undefined; if (changed.thinkingEffort !== undefined) { - unforcedThinkingEffort = resolveThinkingEffort( + const resolution = resolveThinkingEffortWithFallback( changed.thinkingEffort, this.agent.kimiConfig?.thinking, targetModel, kimiProtocol, ); + unforcedThinkingEffort = resolution.effort; + thinkingFallback = resolution.fallback; } else if (changed.modelAlias !== undefined) { // A bare model switch carries the previously resolved effort over to the // new model. Before any effort was resolved (fresh session bootstrap) // `undefined` lets resolveThinkingEffort fall through to the model // default — computed from the resolved provider, whose capabilities and // efforts include the provider-level protocol inference. - unforcedThinkingEffort = resolveThinkingEffort( + const resolution = resolveThinkingEffortWithFallback( this._unforcedThinkingEffort, this.agent.kimiConfig?.thinking, targetModel, kimiProtocol, ); + unforcedThinkingEffort = resolution.effort; + thinkingFallback = resolution.fallback; } if (unforcedThinkingEffort !== undefined) { thinkingEffort = @@ -129,8 +135,8 @@ export class ConfigState { if (this.hasProvider && (changed.cwd !== undefined || changed.modelAlias)) { this.agent.tools.initializeBuiltinTools(); } - if (thinkingEffort !== undefined || changed.modelAlias !== undefined) { - this.agent.warnAboutCurrentAnthropicThinkingEffort(); + if (thinkingFallback !== undefined) { + this.agent.warnAboutThinkingEffortFallback(targetAlias, targetModel, thinkingFallback); } this.agent.emitStatusUpdated(thinkingEffort !== undefined); } diff --git a/packages/agent-core/src/agent/config/thinking.ts b/packages/agent-core/src/agent/config/thinking.ts index a4166e9ad0..9822d5e65e 100644 --- a/packages/agent-core/src/agent/config/thinking.ts +++ b/packages/agent-core/src/agent/config/thinking.ts @@ -71,9 +71,14 @@ function normalizeThinkingEffortForModel( const efforts = effortsFor(effective); if (!kimiProtocol) { - return effort === 'on' && efforts.length > 0 - ? defaultThinkingEffortFor(effective) - : effort; + // Compatible protocols pass values through only while the model declares + // no effort list — with a declared list, an unlisted effort is a config + // mistake the backend would reject, so fall back like the Kimi wire does. + if (efforts.length === 0) return effort; + if (effort === 'on' || !efforts.includes(effort)) { + return defaultThinkingEffortFor(effective); + } + return effort; } if (!supportsThinking(effective)) return 'off'; if (efforts.length === 0) return 'on'; @@ -84,25 +89,28 @@ function normalizeThinkingEffortForModel( } /** - * 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. - * - * A model that declares `always_thinking` can never resolve to `'off'`, on - * any wire — a claimed off state would be a lie, since upstream keeps - * reasoning at its default when no off encoding exists. (Compatible - * protocols still receive every other requested value unchanged so their - * backend can make the final capability decision.) + * A thinking-effort fallback: the configured value is not in the model's + * declared `support_efforts` list, so `resolved` (the model's default effort) + * is applied instead. */ -export function resolveThinkingEffort( +export interface ThinkingEffortFallback { + readonly configured: ThinkingEffort; + readonly resolved: ThinkingEffort; +} + +/** + * Resolve the effective thinking effort for a session, and report whether the + * resolution had to fall back to the model's default effort because the + * configured value is not in the model's declared `support_efforts` list. + * `'on'`/`'off'` are protocol encodings, not list members, so they never + * count as a fallback. + */ +export function resolveThinkingEffortWithFallback( requested: ThinkingEffort | undefined, config: ThinkingConfig | undefined, model: ModelAlias | undefined, kimiProtocol = false, -): ThinkingEffort { +): { readonly effort: ThinkingEffort; readonly fallback: ThinkingEffortFallback | undefined } { const effectiveModel = model === undefined ? undefined : effectiveModelAlias(model); // Normalize the configured value once: 'OFF' / ' off ' must be read as off // on every path, not passed upstream as a concrete effort; whitespace-only @@ -132,5 +140,31 @@ export function resolveThinkingEffort( : defaultThinkingEffortFor(effectiveModel); } - return normalizeThinkingEffortForModel(effort, effectiveModel, kimiProtocol); + const efforts = effortsFor(effectiveModel); + const fallback: ThinkingEffortFallback | undefined = + effort !== 'on' && effort !== 'off' && efforts.length > 0 && !efforts.includes(effort) + ? { configured: effort, resolved: defaultThinkingEffortFor(effectiveModel) } + : undefined; + return { effort: normalizeThinkingEffortForModel(effort, effectiveModel, kimiProtocol), fallback }; +} + +/** + * 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. + * + * A model that declares `always_thinking` can never resolve to `'off'`, on + * any wire — a claimed off state would be a lie, since upstream keeps + * reasoning at its default when no off encoding exists. + */ +export function resolveThinkingEffort( + requested: ThinkingEffort | undefined, + config: ThinkingConfig | undefined, + model: ModelAlias | undefined, + kimiProtocol = false, +): ThinkingEffort { + return resolveThinkingEffortWithFallback(requested, config, model, kimiProtocol).effort; } diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index c54bd0896b..9309a65f49 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -1,12 +1,13 @@ import { join } from 'pathe'; import { randomUUID } from 'node:crypto'; -import { normalizeAdditionalDirs } from '../config'; +import { effectiveModelAlias, normalizeAdditionalDirs } from '../config'; +import type { ModelAlias } from '../config/schema'; import { ErrorCodes, KimiError, makeErrorPayload } from '#/errors'; import { log } from '#/logging/logger'; import type { Logger } from '#/logging/types'; import type { AgentAPI, AgentEvent, KimiConfig, SDKAgentRPC, UsageStatus } from '#/rpc'; -import { generate, type ChatProvider } from '@moonshot-ai/kosong'; +import { generate } from '@moonshot-ai/kosong'; import type { EnabledPluginSessionStart, EnabledPluginSystemPrompt, PluginCommandDef } from '#/plugin'; import { expandCommandArguments } from '../plugin/commands'; @@ -34,6 +35,7 @@ import { } from './compaction'; import { CronManager } from './cron'; import { ConfigState } from './config'; +import type { ThinkingEffortFallback } from './config/thinking'; import { ContextMemory } from './context'; import { GoalMode } from './goal'; import { HookEngine } from '../session/hooks'; @@ -186,6 +188,7 @@ export class Agent { readonly model: string; readonly effort: string; readonly knownEfforts: string | undefined; + readonly fallbackEffort: string; }> = []; private readonly systemPromptContextProvider?: (() => Promise) | undefined; @@ -295,7 +298,6 @@ export class Agent { // before dispatching), so it must not leave a request trace or a // diagnostic log line claiming a request was sent. if (requestOptions?.signal?.aborted !== true) { - this.warnAboutAnthropicThinkingEffort(provider, modelAlias); this.llmRequestLogger.logRequest({ provider, modelAlias, @@ -330,60 +332,48 @@ export class Agent { }; } - private warnAboutAnthropicThinkingEffort( - provider: ChatProvider, + /** + * One-shot warning for a configured thinking effort that is not in the + * model's declared support list: the effective effort falls back to the + * model's default instead of being sent upstream as-is. Emitted at config + * resolution time, where the pre-fallback value is still known; during + * record replay the warning is queued and flushed by {@link resume}. + */ + warnAboutThinkingEffortFallback( modelAlias: string | undefined, + model: ModelAlias | undefined, + fallback: ThinkingEffortFallback, ): void { - if (provider.name !== 'anthropic') return; - const effort = provider.thinkingEffort; - if (effort === null || effort === 'on' || effort === 'off') return; - - let warning: - | { readonly code: string; readonly message: string; readonly knownEfforts?: string } - | undefined; try { - const resolved = - modelAlias === undefined - ? undefined - : this.modelProvider?.resolveProviderConfig(modelAlias); - if (resolved === undefined) return; - - const supportEfforts = resolved.supportEfforts?.filter((value) => value.length > 0); - if (supportEfforts === undefined || supportEfforts.length === 0) return; - if (supportEfforts.includes(effort)) return; - warning = { - code: 'anthropic-thinking-effort-not-listed', - message: `Thinking effort "${effort}" is not listed for model "${provider.modelName}" (known: ${supportEfforts.join(', ')}). The configured value will be sent unchanged to the Anthropic-compatible backend.`, - knownEfforts: supportEfforts.join(','), + const effective = model === undefined ? undefined : effectiveModelAlias(model); + const supportEfforts = effective?.supportEfforts?.filter((value) => value.length > 0) ?? []; + const modelName = effective?.model ?? modelAlias ?? 'unknown'; + const code = 'thinking-effort-not-listed'; + const message = `Thinking effort "${fallback.configured}" is not listed for model "${modelName}" (known: ${supportEfforts.join(', ')}). Falling back to the model's default effort "${fallback.resolved}".`; + const knownEfforts = supportEfforts.join(','); + const key = [code, modelAlias, modelName, fallback.configured, knownEfforts].join('\u0000'); + if (this.emittedThinkingEffortWarnings.has(key)) return; + this.emittedThinkingEffortWarnings.add(key); + const pending = { + code, + message, + modelAlias, + model: modelName, + effort: fallback.configured, + knownEfforts, + fallbackEffort: fallback.resolved, }; + if (this.records.restoring) { + this.pendingThinkingEffortWarnings.push(pending); + return; + } + this.publishThinkingEffortWarning(pending); } catch { - // Capability diagnostics must never turn an otherwise sendable request - // into a client-side failure. - return; - } - - if (warning === undefined) return; - const key = [warning.code, modelAlias, provider.modelName, effort, warning.knownEfforts].join( - '\u0000', - ); - if (this.emittedThinkingEffortWarnings.has(key)) return; - this.emittedThinkingEffortWarnings.add(key); - const pending = { - code: warning.code, - message: warning.message, - modelAlias, - model: provider.modelName, - effort, - knownEfforts: warning.knownEfforts, - }; - if (this.records.restoring) { - this.pendingThinkingEffortWarnings.push(pending); - return; + // A capability warning must never make config replay or session resume fail. } - this.publishAnthropicThinkingEffortWarning(pending); } - private publishAnthropicThinkingEffortWarning( + private publishThinkingEffortWarning( warning: (typeof this.pendingThinkingEffortWarnings)[number], ): void { try { @@ -392,6 +382,7 @@ export class Agent { model: warning.model, effort: warning.effort, knownEfforts: warning.knownEfforts, + fallbackEffort: warning.fallbackEffort, }); } catch { // Diagnostics must never block resume or request dispatch. @@ -408,18 +399,9 @@ export class Agent { } } - private flushPendingAnthropicThinkingEffortWarnings(): void { + private flushPendingThinkingEffortWarnings(): void { for (const warning of this.pendingThinkingEffortWarnings.splice(0)) { - this.publishAnthropicThinkingEffortWarning(warning); - } - } - - warnAboutCurrentAnthropicThinkingEffort(): void { - try { - if (!this.config.hasProvider) return; - this.warnAboutAnthropicThinkingEffort(this.config.provider, this.config.modelAlias); - } catch { - // A capability warning must never make config replay or session resume fail. + this.publishThinkingEffortWarning(warning); } } @@ -532,7 +514,7 @@ export class Agent { async resume(options?: AgentRecordsReplayOptions): Promise<{ warning?: string }> { const result = await this.records.replay(options); - this.flushPendingAnthropicThinkingEffortWarnings(); + this.flushPendingThinkingEffortWarnings(); try { this.replayBuilder.postRestoring = true; this.goal.normalizeAfterReplay(); diff --git a/packages/agent-core/test/agent/config-state.test.ts b/packages/agent-core/test/agent/config-state.test.ts index b486e0beb3..b2fa2116ce 100644 --- a/packages/agent-core/test/agent/config-state.test.ts +++ b/packages/agent-core/test/agent/config-state.test.ts @@ -152,7 +152,7 @@ describe('ConfigState model capabilities', () => { expect(requestMaxTokens).toBe(131072); }); - it('warns and sends when an Anthropic effort is not listed by the model', async () => { + it('warns and falls back when an Anthropic effort is not listed by the model', async () => { let requests = 0; const config: KimiConfig = { providers: { @@ -178,7 +178,7 @@ describe('ConfigState model capabilities', () => { providerManager: new ProviderManager({ config }), generate: async (provider) => { requests += 1; - expect(provider.thinkingEffort).toBe('high'); + expect(provider.thinkingEffort).toBe('max'); return { id: 'response-1', message: { role: 'assistant', content: [], toolCalls: [] }, @@ -205,9 +205,9 @@ describe('ConfigState model capabilities', () => { type: '[rpc]', event: 'warning', args: { - code: 'anthropic-thinking-effort-not-listed', + code: 'thinking-effort-not-listed', message: - 'Thinking effort "high" is not listed for model "compatible-model" (known: max). The configured value will be sent unchanged to the Anthropic-compatible backend.', + 'Thinking effort "high" is not listed for model "compatible-model" (known: max). Falling back to the model\'s default effort "max".', }, }); }); diff --git a/packages/agent-core/test/agent/config/thinking.test.ts b/packages/agent-core/test/agent/config/thinking.test.ts index bb08bf86e6..1ba537c5ac 100644 --- a/packages/agent-core/test/agent/config/thinking.test.ts +++ b/packages/agent-core/test/agent/config/thinking.test.ts @@ -156,7 +156,7 @@ describe('resolveThinkingEffort', () => { it('normalizes the requested effort (case/whitespace) on every wire', () => { expect(resolveThinkingEffort(' OFF ', undefined, effortModel, false)).toBe('off'); - expect(resolveThinkingEffort(' Max ', undefined, effortModel, false)).toBe('max'); + expect(resolveThinkingEffort(' High ', undefined, effortModel, false)).toBe('high'); expect(resolveThinkingEffort(' ', undefined, effortModel, false)).toBe('medium'); }); @@ -185,6 +185,30 @@ describe('resolveThinkingEffort', () => { expect(resolveThinkingEffort('ultra', undefined, effortModel, true)).toBe('medium'); }); + it('falls back to the model default for an unlisted effort on any protocol', () => { + // A declared supportEfforts list is authoritative on every wire: an + // unlisted configured or requested effort resolves to the model default + // instead of being sent upstream as-is. + const declared = model({ + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'xhigh'], + defaultEffort: 'xhigh', + }); + expect(resolveThinkingEffort(undefined, { effort: 'high' }, declared, false)).toBe('xhigh'); + expect(resolveThinkingEffort('high', undefined, declared, false)).toBe('xhigh'); + expect(resolveThinkingEffort('medium', undefined, declared, false)).toBe('medium'); + expect(resolveThinkingEffort('xhigh', undefined, declared, false)).toBe('xhigh'); + // no declared defaultEffort -> middle entry of the list. + expect(resolveThinkingEffort('ultra', undefined, effortModel, false)).toBe('medium'); + }); + + it('passes concrete efforts through unchanged when no effort list is declared', () => { + expect(resolveThinkingEffort('ultra', undefined, booleanModel, false)).toBe('ultra'); + expect(resolveThinkingEffort(undefined, { effort: 'ultra' }, booleanModel, false)).toBe( + 'ultra', + ); + }); + it('projects a concrete effort to on for a boolean-only Kimi model', () => { expect(resolveThinkingEffort('ultra', undefined, booleanModel, true)).toBe('on'); }); diff --git a/packages/agent-core/test/harness/model-alias-session.test.ts b/packages/agent-core/test/harness/model-alias-session.test.ts index 35d914a38b..c2bde76714 100644 --- a/packages/agent-core/test/harness/model-alias-session.test.ts +++ b/packages/agent-core/test/harness/model-alias-session.test.ts @@ -190,9 +190,9 @@ max_context_size = 200000 sessionId, agentId: 'main', type: 'warning', - code: 'anthropic-thinking-effort-not-listed', + code: 'thinking-effort-not-listed', message: - 'Thinking effort "high" is not listed for model "compatible-model" (known: max). The configured value will be sent unchanged to the Anthropic-compatible backend.', + 'Thinking effort "high" is not listed for model "compatible-model" (known: max). Falling back to the model\'s default effort "max".', }); const restored = await freshRpc.getConfig({ sessionId, agentId: 'main' }); expect(restored.modelAlias).toBe('compatible/model'); From be43c7e5bbc51a71f681c129f7cacad728864dda Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Thu, 20 Aug 2026 17:33:59 +0000 Subject: [PATCH 02/34] fix: update thinking-effort rejection hint for declared-list fallback --- packages/agent-core-v2/src/kosong/contract/errors.ts | 2 +- packages/kosong/src/errors.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agent-core-v2/src/kosong/contract/errors.ts b/packages/agent-core-v2/src/kosong/contract/errors.ts index 3a6bafde1d..7d7d7bc162 100644 --- a/packages/agent-core-v2/src/kosong/contract/errors.ts +++ b/packages/agent-core-v2/src/kosong/contract/errors.ts @@ -316,7 +316,7 @@ function appendThinkingEffortConfigHint(statusCode: number, message: string): st if (message.includes(THINKING_EFFORT_CONFIG_DOCS_URL)) return message; return `${message} -The provider rejected the configured thinking effort. Non-Kimi providers receive effort strings without client-side mapping; choose an effort supported by the selected model. For Kimi models, check support_efforts and default_effort. See ${THINKING_EFFORT_CONFIG_DOCS_URL}`; +The provider rejected the configured thinking effort. Efforts outside a model's declared support_efforts fall back to the model default; models without a declared list pass efforts to non-Kimi providers unchanged. Choose an effort supported by the selected model. See ${THINKING_EFFORT_CONFIG_DOCS_URL}`; } export function isContextOverflowErrorCode(code: string | null | undefined): boolean { diff --git a/packages/kosong/src/errors.ts b/packages/kosong/src/errors.ts index 467a6301f3..6fa0ca50ed 100644 --- a/packages/kosong/src/errors.ts +++ b/packages/kosong/src/errors.ts @@ -412,7 +412,7 @@ function appendThinkingEffortConfigHint(statusCode: number, message: string): st if (message.includes(THINKING_EFFORT_CONFIG_DOCS_URL)) return message; return `${message} -The provider rejected the configured thinking effort. Non-Kimi providers receive effort strings without client-side mapping; choose an effort supported by the selected model. For Kimi models, check support_efforts and default_effort. See ${THINKING_EFFORT_CONFIG_DOCS_URL}`; +The provider rejected the configured thinking effort. Efforts outside a model's declared support_efforts fall back to the model default; models without a declared list pass efforts to non-Kimi providers unchanged. Choose an effort supported by the selected model. See ${THINKING_EFFORT_CONFIG_DOCS_URL}`; } export function isContextOverflowErrorCode(code: string | null | undefined): boolean { From b19194675be24e3005e3a64271cdece65cbe4af0 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Thu, 20 Aug 2026 18:12:55 +0000 Subject: [PATCH 03/34] test(kosong): update thinking-effort rejection hint expectation --- packages/kosong/test/openai-common-errors.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/kosong/test/openai-common-errors.test.ts b/packages/kosong/test/openai-common-errors.test.ts index 0b7b7ca898..2255c6672b 100644 --- a/packages/kosong/test/openai-common-errors.test.ts +++ b/packages/kosong/test/openai-common-errors.test.ts @@ -405,7 +405,9 @@ describe('normalizeAPIStatusError thinking effort guidance', () => { it('adds configuration guidance when a provider rejects reasoning_effort', () => { const error = normalizeAPIStatusError(400, 'Invalid reasoning_effort: xhigh'); - expect(error.message).toContain('Non-Kimi providers receive effort strings'); + expect(error.message).toContain( + "Efforts outside a model's declared support_efforts fall back to the model default", + ); expect(error.message).toContain( 'https://moonshotai.github.io/kimi-code/en/configuration/config-files.html#thinking', ); From a24ef25445e6d86ca53cbc6a0b66df9f472b0c7f Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Thu, 20 Aug 2026 18:12:55 +0000 Subject: [PATCH 04/34] fix: fall back to the declared default effort for models without a thinking capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A custom model may declare support_efforts/default_effort while omitting the thinking capability. The unlisted-effort fallback went through defaultThinkingEffortFor(Model), whose capability gate resolved such a model to 'off' — silently disabling thinking instead of applying the declared default. The fallback now derives directly from the declared list (declared default_effort when listed, else the middle entry), and the fallback report reuses the normalized result so it cannot drift from what is actually applied. --- .../src/kosong/model/thinking.ts | 24 ++++++++------ .../test/kosong/model/thinking.test.ts | 28 +++++++++++++++++ .../agent-core/src/agent/config/thinking.ts | 31 +++++++++++++------ .../test/agent/config/thinking.test.ts | 25 +++++++++++++++ 4 files changed, 90 insertions(+), 18 deletions(-) diff --git a/packages/agent-core-v2/src/kosong/model/thinking.ts b/packages/agent-core-v2/src/kosong/model/thinking.ts index 3532d03e9a..84e54d1bbb 100644 --- a/packages/agent-core-v2/src/kosong/model/thinking.ts +++ b/packages/agent-core-v2/src/kosong/model/thinking.ts @@ -101,6 +101,16 @@ function effortsFor(model: ModelThinkingMetadata | undefined): readonly string[] return model?.supportEfforts?.map(nonEmpty).filter((v): v is string => v !== undefined) ?? []; } +function declaredDefaultEffortFor( + model: ModelThinkingMetadata | undefined, + efforts: readonly string[], +): ThinkingEffort { + const declaredDefault = nonEmpty(model?.defaultEffort); + return (declaredDefault !== undefined && efforts.includes(declaredDefault) + ? declaredDefault + : middleOf(efforts)) as ThinkingEffort; +} + export function modelSupportsThinking(model: ModelThinkingMetadata | undefined): boolean { if (model === undefined) return false; return ( @@ -116,12 +126,7 @@ export function defaultThinkingEffortForModel( ): ThinkingEffort { if (model === undefined || !modelSupportsThinking(model)) return 'off'; const efforts = effortsFor(model); - if (efforts.length > 0) { - const declaredDefault = nonEmpty(model.defaultEffort); - return (declaredDefault !== undefined && efforts.includes(declaredDefault) - ? declaredDefault - : middleOf(efforts)) as ThinkingEffort; - } + if (efforts.length > 0) return declaredDefaultEffortFor(model, efforts); return 'on'; } @@ -146,7 +151,7 @@ function normalizeThinkingEffortForModel( if (!strictValidation) { if (efforts.length === 0) return effort; if (effort === 'on' || !efforts.includes(effort)) { - return defaultThinkingEffortForModel(model); + return declaredDefaultEffortFor(model, efforts); } return effort; } @@ -186,12 +191,13 @@ export function resolveThinkingEffortForModelWithFallback( ? configured : defaultThinkingEffortForModel(model); } + const resolved = normalizeThinkingEffortForModel(effort, model, strictValidation); const efforts = effortsFor(model); const fallback: ThinkingEffortFallback | undefined = effort !== 'on' && effort !== 'off' && efforts.length > 0 && !efforts.includes(effort) - ? { configured: effort, resolved: defaultThinkingEffortForModel(model) } + ? { configured: effort, resolved } : undefined; - return { effort: normalizeThinkingEffortForModel(effort, model, strictValidation), fallback }; + return { effort: resolved, fallback }; } export function resolveThinkingEffortForModel( diff --git a/packages/agent-core-v2/test/kosong/model/thinking.test.ts b/packages/agent-core-v2/test/kosong/model/thinking.test.ts index 36ec0df8f4..33d3d3d36a 100644 --- a/packages/agent-core-v2/test/kosong/model/thinking.test.ts +++ b/packages/agent-core-v2/test/kosong/model/thinking.test.ts @@ -10,6 +10,7 @@ import { requiresStrictThinkingValidation, resolveForcedThinkingEffort, resolveThinkingEffortForModel, + resolveThinkingEffortForModelWithFallback, resolveThinkingKeep, usesTraitDrivenThinking, } from '#/kosong/model/thinking'; @@ -99,6 +100,33 @@ describe('resolveThinkingEffortForModel', () => { ).toBe('extreme'); }); + it('falls back to the declared default when the model omits the thinking capability', () => { + const declared = { + supportEfforts: ['low', 'medium', 'xhigh'], + defaultEffort: 'xhigh', + }; + expect(resolveThinkingEffortForModel(undefined, { effort: 'high' }, declared, false)).toBe( + 'xhigh', + ); + expect(resolveThinkingEffortForModel('high', undefined, declared, false)).toBe('xhigh'); + const withFallback = resolveThinkingEffortForModelWithFallback( + 'high', + undefined, + declared, + false, + ); + expect(withFallback.effort).toBe('xhigh'); + expect(withFallback.fallback).toEqual({ configured: 'high', resolved: 'xhigh' }); + expect( + resolveThinkingEffortForModel( + 'high', + undefined, + { supportEfforts: ['low', 'medium', 'xhigh'] }, + false, + ), + ).toBe('medium'); + }); + it('keeps always-thinking models on under kimi semantics', () => { const always = { capabilities: ['always_thinking'], diff --git a/packages/agent-core/src/agent/config/thinking.ts b/packages/agent-core/src/agent/config/thinking.ts index 9822d5e65e..a54a7c4562 100644 --- a/packages/agent-core/src/agent/config/thinking.ts +++ b/packages/agent-core/src/agent/config/thinking.ts @@ -24,6 +24,23 @@ function effortsFor(model: ModelAlias | undefined): readonly string[] { return effective?.supportEfforts?.filter((effort) => effort.length > 0) ?? []; } +/** + * Pick the fallback effort straight from the declared list: the declared + * `default_effort` when it is listed, else the middle entry. Unlike + * {@link defaultThinkingEffortFor} this skips the thinking-capability gate — + * a model that declares `support_efforts` without declaring the `thinking` + * capability still has a meaningful declared default to fall back to. + */ +function declaredDefaultEffortFor( + model: ModelAlias | undefined, + efforts: readonly string[], +): ThinkingEffort { + const declaredDefault = model?.defaultEffort; + return declaredDefault !== undefined && efforts.includes(declaredDefault) + ? declaredDefault + : middleOf(efforts); +} + /** * Resolve the default thinking effort for a model from its declared metadata: * - models that do not support thinking (or an unknown model) -> `'off'` @@ -38,12 +55,7 @@ export function defaultThinkingEffortFor(model: ModelAlias | undefined): Thinkin const effective = model === undefined ? undefined : effectiveModelAlias(model); if (!supportsThinking(effective)) return 'off'; const efforts = effortsFor(effective); - if (efforts.length > 0) { - const declaredDefault = effective?.defaultEffort; - return declaredDefault !== undefined && efforts.includes(declaredDefault) - ? declaredDefault - : middleOf(efforts); - } + if (efforts.length > 0) return declaredDefaultEffortFor(effective, efforts); return 'on'; } @@ -76,7 +88,7 @@ function normalizeThinkingEffortForModel( // mistake the backend would reject, so fall back like the Kimi wire does. if (efforts.length === 0) return effort; if (effort === 'on' || !efforts.includes(effort)) { - return defaultThinkingEffortFor(effective); + return declaredDefaultEffortFor(effective, efforts); } return effort; } @@ -140,12 +152,13 @@ export function resolveThinkingEffortWithFallback( : defaultThinkingEffortFor(effectiveModel); } + const resolved = normalizeThinkingEffortForModel(effort, effectiveModel, kimiProtocol); const efforts = effortsFor(effectiveModel); const fallback: ThinkingEffortFallback | undefined = effort !== 'on' && effort !== 'off' && efforts.length > 0 && !efforts.includes(effort) - ? { configured: effort, resolved: defaultThinkingEffortFor(effectiveModel) } + ? { configured: effort, resolved } : undefined; - return { effort: normalizeThinkingEffortForModel(effort, effectiveModel, kimiProtocol), fallback }; + return { effort: resolved, fallback }; } /** diff --git a/packages/agent-core/test/agent/config/thinking.test.ts b/packages/agent-core/test/agent/config/thinking.test.ts index 1ba537c5ac..d090ba154d 100644 --- a/packages/agent-core/test/agent/config/thinking.test.ts +++ b/packages/agent-core/test/agent/config/thinking.test.ts @@ -4,6 +4,7 @@ import type { ModelAlias } from '../../../src/config'; import { defaultThinkingEffortFor, resolveThinkingEffort, + resolveThinkingEffortWithFallback, supportsThinkingEffort, } from '../../../src/agent/config/thinking'; @@ -209,6 +210,30 @@ describe('resolveThinkingEffort', () => { ); }); + it('falls back to the declared default when the model omits the thinking capability', () => { + // A model may declare support_efforts/default_effort without declaring + // the thinking capability; the declared list is still authoritative for + // the fallback — the unlisted value must not silently become 'off'. + const declared = model({ + supportEfforts: ['low', 'medium', 'xhigh'], + defaultEffort: 'xhigh', + }); + expect(resolveThinkingEffort(undefined, { effort: 'high' }, declared, false)).toBe('xhigh'); + expect(resolveThinkingEffort('high', undefined, declared, false)).toBe('xhigh'); + const withFallback = resolveThinkingEffortWithFallback('high', undefined, declared, false); + expect(withFallback.effort).toBe('xhigh'); + expect(withFallback.fallback).toEqual({ configured: 'high', resolved: 'xhigh' }); + // no declared defaultEffort -> middle entry of the list. + expect( + resolveThinkingEffort( + 'high', + undefined, + model({ supportEfforts: ['low', 'medium', 'xhigh'] }), + false, + ), + ).toBe('medium'); + }); + it('projects a concrete effort to on for a boolean-only Kimi model', () => { expect(resolveThinkingEffort('ultra', undefined, booleanModel, true)).toBe('on'); }); From 08902a0a6539c44d35d84429ebe30bf4db4f73ee Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Thu, 20 Aug 2026 18:56:42 +0000 Subject: [PATCH 05/34] fix(agent-core-v2): normalize persisted thinking efforts after session resume Sessions persisted before the declared-list fallback restore their recorded thinkingEffort verbatim: wire replay folds ProfileBind and ConfigUpdate straight into profile state, so an effort the model does not list was still sent upstream and rejected. The profile's thinkingLevel getter now always re-resolves the stored value against the current model (idempotent for already-normalized values, and the always-thinking off-clamp it used to special-case is just the general path), and a post-restore hook emits the one-time fallback warning when the persisted value had to be corrected. --- .../src/agent/profile/profileService.ts | 16 ++++----- .../test/agent/profile/config-state.test.ts | 34 +++++++++++++++++++ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 20300e2d1b..f9d17ff24e 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -203,6 +203,12 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ } }), ); + this._register( + this.dispatcher.hooks.onDidRestore.register('profile', async (_ctx, next) => { + this.warnAboutThinkingEffortFallback(this.profileState.thinkingLevel); + await next(); + }), + ); } private get activeToolNamesOverlay(): readonly string[] | undefined { @@ -702,11 +708,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ } private get thinkingLevel(): ThinkingEffort { - const stored = this.profileState.thinkingLevel; - if (stored === 'off' && this.alwaysThinkingModel) { - return this.resolveThinkingEffort(stored, this.tryResolveRawModel()); - } - return stored; + return this.resolveThinkingEffort(this.profileState.thinkingLevel, this.tryResolveRawModel()); } private resolveThinkingState(model: Model | undefined): { @@ -747,10 +749,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ return modelSupportsThinkingEffort(effort, model, this.strictThinkingValidation(model)); } - private get alwaysThinkingModel(): boolean { - return this.tryResolveRawModel()?.alwaysThinking === true; - } - private tryResolveRawModel(): Model | undefined { const alias = this.modelAlias; return this.resolveModelForThinking(alias); diff --git a/packages/agent-core-v2/test/agent/profile/config-state.test.ts b/packages/agent-core-v2/test/agent/profile/config-state.test.ts index 5e2d3f7ca3..72ead6678b 100644 --- a/packages/agent-core-v2/test/agent/profile/config-state.test.ts +++ b/packages/agent-core-v2/test/agent/profile/config-state.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IAgentLLMRequesterService } from '#/agent/llmRequester/llmRequester'; import { IAgentProfileService } from '#/agent/profile/profile'; import type { ModelRecord } from '#/kosong/model/model'; +import { WIRE_PROTOCOL_VERSION } from '#/wire/migration/migration'; import { configServices, createTestAgent, @@ -450,6 +451,39 @@ describe('ConfigState thinking clamp for always-thinking models', () => { }); }); + it('normalizes a persisted unlisted effort to the declared default on resume', async () => { + await ctx.restore([ + { + type: 'metadata', + protocol_version: WIRE_PROTOCOL_VERSION, + created_at: 1, + }, + { + type: 'profile.bind', + modelAlias: 'kimi-code/compatible', + profileName: 'restored-profile', + thinkingEffort: 'high', + systemPrompt: 'restored prompt', + disallowedTools: [], + }, + ]); + + expect(profile.data().thinkingLevel).toBe('max'); + expect(ctx.allEvents).toContainEqual({ + type: '[rpc]', + event: 'warning', + args: expect.objectContaining({ + code: 'thinking-effort-not-listed', + message: + 'Thinking effort "high" is not listed for model "compatible-model" (known: max). Falling back to the model\'s default effort "max".', + }), + }); + + await requester.request({}, undefined, new AbortController().signal); + + expect(capturedThinking).toMatchObject({ effort: 'max' }); + }); + it('clamps off to the model default for always-on models, on any transport', () => { profile.update({ modelAlias: 'kimi-code/compatible', thinkingLevel: 'max' }); From f1217ec206d5c90550cdf76ef161ea9a11fa93d9 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Thu, 20 Aug 2026 19:35:07 +0000 Subject: [PATCH 06/34] fix(agent-core): warn when a Kimi env thinking-effort override is unlisted KIMI_MODEL_THINKING_EFFORT is applied after the model-aware resolution and bypasses the declared support_efforts list by design, so the new fallback warning never covers it and the request-time check this PR removed was the last diagnostic for an out-of-list pin. Validate the final overridden effort at the point the override is applied and emit a one-time warning that the value will be sent unchanged, mirroring the v2 requester-layer diagnostic. --- packages/agent-core/src/agent/config/index.ts | 6 + packages/agent-core/src/agent/index.ts | 82 ++++++++++---- .../test/agent/config-state.test.ts | 107 ++++++++++++++++++ 3 files changed, 175 insertions(+), 20 deletions(-) diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts index 5fc24a3877..0317fb2293 100644 --- a/packages/agent-core/src/agent/config/index.ts +++ b/packages/agent-core/src/agent/config/index.ts @@ -138,6 +138,12 @@ export class ConfigState { if (thinkingFallback !== undefined) { this.agent.warnAboutThinkingEffortFallback(targetAlias, targetModel, thinkingFallback); } + if (thinkingEffort !== undefined && thinkingEffort !== unforcedThinkingEffort) { + // The KIMI_MODEL_THINKING_EFFORT override is applied after resolution + // and bypasses support_efforts by design; warn once when the pinned + // value is outside the model's declared list. + this.agent.warnAboutUnlistedThinkingEffortOverride(targetAlias, targetModel, thinkingEffort); + } this.agent.emitStatusUpdated(thinkingEffort !== undefined); } diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 9309a65f49..5017f129f5 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -35,7 +35,7 @@ import { } from './compaction'; import { CronManager } from './cron'; import { ConfigState } from './config'; -import type { ThinkingEffortFallback } from './config/thinking'; +import type { ThinkingEffort, ThinkingEffortFallback } from './config/thinking'; import { ContextMemory } from './context'; import { GoalMode } from './goal'; import { HookEngine } from '../session/hooks'; @@ -188,7 +188,7 @@ export class Agent { readonly model: string; readonly effort: string; readonly knownEfforts: string | undefined; - readonly fallbackEffort: string; + readonly fallbackEffort?: string; }> = []; private readonly systemPromptContextProvider?: (() => Promise) | undefined; @@ -344,30 +344,72 @@ export class Agent { model: ModelAlias | undefined, fallback: ThinkingEffortFallback, ): void { + const effective = model === undefined ? undefined : effectiveModelAlias(model); + const supportEfforts = effective?.supportEfforts?.filter((value) => value.length > 0) ?? []; + const modelName = effective?.model ?? modelAlias ?? 'unknown'; + this.emitThinkingEffortWarning({ + code: 'thinking-effort-not-listed', + message: `Thinking effort "${fallback.configured}" is not listed for model "${modelName}" (known: ${supportEfforts.join(', ')}). Falling back to the model's default effort "${fallback.resolved}".`, + modelAlias, + model: modelName, + effort: fallback.configured, + knownEfforts: supportEfforts.join(','), + fallbackEffort: fallback.resolved, + }); + } + + /** + * One-shot warning for a `KIMI_MODEL_THINKING_EFFORT` override that is not + * in the model's declared support list. The override is an explicit pin + * applied after resolution: it bypasses the declared list by design and is + * sent upstream unchanged, so the warning says so instead of promising a + * fallback. + */ + warnAboutUnlistedThinkingEffortOverride( + modelAlias: string | undefined, + model: ModelAlias | undefined, + effort: ThinkingEffort, + ): void { + if (effort === 'on' || effort === 'off') return; + const effective = model === undefined ? undefined : effectiveModelAlias(model); + const supportEfforts = effective?.supportEfforts?.filter((value) => value.length > 0) ?? []; + if (supportEfforts.length === 0 || supportEfforts.includes(effort)) return; + const modelName = effective?.model ?? modelAlias ?? 'unknown'; + this.emitThinkingEffortWarning({ + code: 'thinking-effort-override-not-listed', + message: `Thinking effort "${effort}" is not listed for model "${modelName}" (known: ${supportEfforts.join(', ')}). The value will be sent unchanged to the backend.`, + modelAlias, + model: modelName, + effort, + knownEfforts: supportEfforts.join(','), + fallbackEffort: undefined, + }); + } + + private emitThinkingEffortWarning(warning: { + readonly code: string; + readonly message: string; + readonly modelAlias: string | undefined; + readonly model: string; + readonly effort: string; + readonly knownEfforts: string; + readonly fallbackEffort?: string; + }): void { try { - const effective = model === undefined ? undefined : effectiveModelAlias(model); - const supportEfforts = effective?.supportEfforts?.filter((value) => value.length > 0) ?? []; - const modelName = effective?.model ?? modelAlias ?? 'unknown'; - const code = 'thinking-effort-not-listed'; - const message = `Thinking effort "${fallback.configured}" is not listed for model "${modelName}" (known: ${supportEfforts.join(', ')}). Falling back to the model's default effort "${fallback.resolved}".`; - const knownEfforts = supportEfforts.join(','); - const key = [code, modelAlias, modelName, fallback.configured, knownEfforts].join('\u0000'); + const key = [ + warning.code, + warning.modelAlias, + warning.model, + warning.effort, + warning.knownEfforts, + ].join('\u0000'); if (this.emittedThinkingEffortWarnings.has(key)) return; this.emittedThinkingEffortWarnings.add(key); - const pending = { - code, - message, - modelAlias, - model: modelName, - effort: fallback.configured, - knownEfforts, - fallbackEffort: fallback.resolved, - }; if (this.records.restoring) { - this.pendingThinkingEffortWarnings.push(pending); + this.pendingThinkingEffortWarnings.push(warning); return; } - this.publishThinkingEffortWarning(pending); + this.publishThinkingEffortWarning(warning); } catch { // A capability warning must never make config replay or session resume fail. } diff --git a/packages/agent-core/test/agent/config-state.test.ts b/packages/agent-core/test/agent/config-state.test.ts index b2fa2116ce..52aeb51a3b 100644 --- a/packages/agent-core/test/agent/config-state.test.ts +++ b/packages/agent-core/test/agent/config-state.test.ts @@ -212,6 +212,113 @@ describe('ConfigState model capabilities', () => { }); }); + it('warns when a Kimi env effort override is not listed by the model', async () => { + // A Kimi provider routed through the Anthropic protocol still honors + // KIMI_MODEL_THINKING_EFFORT; the override is an explicit pin applied + // after resolution, so an unlisted value is sent unchanged — with a + // one-time warning instead of a fallback. + vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'high'); + try { + let requests = 0; + const config: KimiConfig = { + providers: { + compatible: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test', + }, + }, + models: { + compatible: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['max'], + }, + }, + }; + const ctx = testAgent({ + initialConfig: config, + providerManager: new ProviderManager({ config }), + generate: async (provider) => { + requests += 1; + expect(provider.thinkingEffort).toBe('high'); + return { + id: 'response-1', + message: { role: 'assistant', content: [], toolCalls: [] }, + usage: emptyUsage(), + finishReason: 'completed', + rawFinishReason: 'stop', + }; + }, + }); + ctx.agent.config.update({ + modelAlias: 'compatible', + systemPrompt: 'system', + }); + + await ctx.agent.llm.chat({ + messages: [], + tools: [], + signal: new AbortController().signal, + }); + + expect(requests).toBe(1); + expect(ctx.allEvents).toContainEqual({ + type: '[rpc]', + event: 'warning', + args: { + code: 'thinking-effort-override-not-listed', + message: + 'Thinking effort "high" is not listed for model "compatible-model" (known: max). The value will be sent unchanged to the backend.', + }, + }); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('does not warn when the env effort override is listed by the model', () => { + vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'max'); + try { + const config: KimiConfig = { + providers: { + compatible: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test', + }, + }, + models: { + compatible: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['max'], + }, + }, + }; + const ctx = testAgent({ + initialConfig: config, + providerManager: new ProviderManager({ config }), + }); + + ctx.agent.config.update({ + modelAlias: 'compatible', + systemPrompt: 'system', + }); + + expect(ctx.agent.config.thinkingEffort).toBe('max'); + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([]); + } finally { + vi.unstubAllEnvs(); + } + }); + it('uses session id as a provider prompt cache hint without storing it on Agent', () => { const ctx = testAgent({ providerManager: new ProviderManager({ From d32ed942f9274cb58af43b46aac3e0d27e80dc13 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Thu, 20 Aug 2026 20:04:23 +0000 Subject: [PATCH 07/34] fix: note the forced-effort exception in thinking-effort rejection guidance --- packages/agent-core-v2/src/kosong/contract/errors.ts | 2 +- packages/kosong/src/errors.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agent-core-v2/src/kosong/contract/errors.ts b/packages/agent-core-v2/src/kosong/contract/errors.ts index 7d7d7bc162..52b99650d9 100644 --- a/packages/agent-core-v2/src/kosong/contract/errors.ts +++ b/packages/agent-core-v2/src/kosong/contract/errors.ts @@ -316,7 +316,7 @@ function appendThinkingEffortConfigHint(statusCode: number, message: string): st if (message.includes(THINKING_EFFORT_CONFIG_DOCS_URL)) return message; return `${message} -The provider rejected the configured thinking effort. Efforts outside a model's declared support_efforts fall back to the model default; models without a declared list pass efforts to non-Kimi providers unchanged. Choose an effort supported by the selected model. See ${THINKING_EFFORT_CONFIG_DOCS_URL}`; +The provider rejected the configured thinking effort. Efforts outside a model's declared support_efforts fall back to the model default, except a forced effort (forced_effort or KIMI_MODEL_THINKING_EFFORT), which is always sent unchanged; models without a declared list pass efforts to non-Kimi providers unchanged. Choose an effort supported by the selected model. See ${THINKING_EFFORT_CONFIG_DOCS_URL}`; } export function isContextOverflowErrorCode(code: string | null | undefined): boolean { diff --git a/packages/kosong/src/errors.ts b/packages/kosong/src/errors.ts index 6fa0ca50ed..c1b3569f2e 100644 --- a/packages/kosong/src/errors.ts +++ b/packages/kosong/src/errors.ts @@ -412,7 +412,7 @@ function appendThinkingEffortConfigHint(statusCode: number, message: string): st if (message.includes(THINKING_EFFORT_CONFIG_DOCS_URL)) return message; return `${message} -The provider rejected the configured thinking effort. Efforts outside a model's declared support_efforts fall back to the model default; models without a declared list pass efforts to non-Kimi providers unchanged. Choose an effort supported by the selected model. See ${THINKING_EFFORT_CONFIG_DOCS_URL}`; +The provider rejected the configured thinking effort. Efforts outside a model's declared support_efforts fall back to the model default, except a forced effort (forced_effort or KIMI_MODEL_THINKING_EFFORT), which is always sent unchanged; models without a declared list pass efforts to non-Kimi providers unchanged. Choose an effort supported by the selected model. See ${THINKING_EFFORT_CONFIG_DOCS_URL}`; } export function isContextOverflowErrorCode(code: string | null | undefined): boolean { From 2a1f7500fc039ad2f7c2f48d3eaf9f5dc04bbc40 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Thu, 20 Aug 2026 21:12:37 +0000 Subject: [PATCH 08/34] fix: suppress the thinking-effort fallback warning when a forced effort wins When KIMI_MODEL_THINKING_EFFORT (v1) or thinking.forcedEffort (v2) decides the final effort, the configured value never reaches the wire, so warning that it will fall back to the model default misleads. Both engines now skip the fallback warning whenever a forced override applies; an out-of-list forced value is still reported by the override/passthrough warning that describes what is actually sent. --- .../src/agent/profile/profileService.ts | 11 ++- .../test/agent/profile/config-state.test.ts | 74 ++++++++++++++ packages/agent-core/src/agent/config/index.ts | 4 +- .../test/agent/config-state.test.ts | 96 +++++++++++++++++++ 4 files changed, 182 insertions(+), 3 deletions(-) diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index f9d17ff24e..97c0035b40 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -626,13 +626,20 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ try { const model = this.tryResolveRawModel(); if (model === undefined) return; - const { fallback } = resolveThinkingEffortForModelWithFallback( + const thinking = this.config.get(THINKING_SECTION); + const { effort, fallback } = resolveThinkingEffortForModelWithFallback( requested, - this.config.get(THINKING_SECTION), + thinking, model, this.strictThinkingValidation(model), ); if (fallback === undefined) return; + const forced = resolveForcedThinkingEffort( + thinking?.forcedEffort, + effort, + drivesThinkingThroughTraits(model.providerType), + ); + if (forced !== undefined) return; const efforts = model.supportEfforts?.filter((value) => value.length > 0) ?? []; const knownEfforts = efforts.join(','); const code = 'thinking-effort-not-listed'; diff --git a/packages/agent-core-v2/test/agent/profile/config-state.test.ts b/packages/agent-core-v2/test/agent/profile/config-state.test.ts index 72ead6678b..c6d3216a3d 100644 --- a/packages/agent-core-v2/test/agent/profile/config-state.test.ts +++ b/packages/agent-core-v2/test/agent/profile/config-state.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IAgentLLMRequesterService } from '#/agent/llmRequester/llmRequester'; import { IAgentProfileService } from '#/agent/profile/profile'; +import { DEFAULT_AGENT_PROFILE_NAME } from '#/app/agentProfileCatalog/agentProfileCatalog'; import type { ModelRecord } from '#/kosong/model/model'; import { WIRE_PROTOCOL_VERSION } from '#/wire/migration/migration'; import { @@ -484,6 +485,79 @@ describe('ConfigState thinking clamp for always-thinking models', () => { expect(capturedThinking).toMatchObject({ effort: 'max' }); }); + it('suppresses the fallback warning when a forced effort decides the final effort', async () => { + kimiConfig = { + providers: { kimi: { type: 'kimi', apiKey: 'test-key', baseUrl: 'https://api.example.test/v1' } }, + models: { + 'kimi-code/custom': { + provider: 'kimi', + model: 'kimi-custom-coder', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'max'], + defaultEffort: 'max', + }, + }, + thinking: { effort: 'high', forcedEffort: 'low' }, + }; + + await profile.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: 'kimi-code/custom' }); + + expect(profile.data().thinkingLevel).toBe('max'); + expect( + ctx.allEvents.filter( + (event) => + event.event === 'warning' && + String((event.args as { code?: string }).code).startsWith('thinking-effort'), + ), + ).toEqual([]); + + await requester.request({}, undefined, new AbortController().signal); + + expect(capturedThinking).toMatchObject({ effort: 'low' }); + }); + + it('warns only about an unlisted forced effort sent unchanged', async () => { + kimiConfig = { + providers: { kimi: { type: 'kimi', apiKey: 'test-key', baseUrl: 'https://api.example.test/v1' } }, + models: { + 'kimi-code/custom': { + provider: 'kimi', + model: 'kimi-custom-coder', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'max'], + defaultEffort: 'max', + }, + }, + thinking: { effort: 'high', forcedEffort: 'extreme' }, + }; + + await profile.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: 'kimi-code/custom' }); + + expect(profile.data().thinkingLevel).toBe('max'); + expect( + ctx.allEvents.filter( + (event) => + event.event === 'warning' && + String((event.args as { code?: string }).code).startsWith('thinking-effort'), + ), + ).toEqual([]); + + await requester.request({}, undefined, new AbortController().signal); + + expect(capturedThinking).toMatchObject({ effort: 'extreme' }); + expect(ctx.allEvents).toContainEqual({ + type: '[rpc]', + event: 'warning', + args: expect.objectContaining({ + code: 'thinking-effort-not-listed', + message: + 'Thinking effort "extreme" is not listed for model "kimi-custom-coder" (known: low, medium, max). The value will be sent unchanged to the backend.', + }), + }); + }); + it('clamps off to the model default for always-on models, on any transport', () => { profile.update({ modelAlias: 'kimi-code/compatible', thinkingLevel: 'max' }); diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts index 0317fb2293..1c66bba577 100644 --- a/packages/agent-core/src/agent/config/index.ts +++ b/packages/agent-core/src/agent/config/index.ts @@ -135,7 +135,9 @@ export class ConfigState { if (this.hasProvider && (changed.cwd !== undefined || changed.modelAlias)) { this.agent.tools.initializeBuiltinTools(); } - if (thinkingFallback !== undefined) { + if (thinkingFallback !== undefined && thinkingEffort === unforcedThinkingEffort) { + // When the env override decides the final effort, the fallback never + // reaches the wire — the override warning below is the accurate one. this.agent.warnAboutThinkingEffortFallback(targetAlias, targetModel, thinkingFallback); } if (thinkingEffort !== undefined && thinkingEffort !== unforcedThinkingEffort) { diff --git a/packages/agent-core/test/agent/config-state.test.ts b/packages/agent-core/test/agent/config-state.test.ts index 52aeb51a3b..abafe04a9a 100644 --- a/packages/agent-core/test/agent/config-state.test.ts +++ b/packages/agent-core/test/agent/config-state.test.ts @@ -319,6 +319,102 @@ describe('ConfigState model capabilities', () => { } }); + it('suppresses the fallback warning when the env override decides the final effort', () => { + // The configured "high" is unlisted and would fall back to "xhigh", but + // the env pin "low" decides what actually goes on the wire — warning + // about a fallback to "xhigh" would be a lie, and "low" is listed, so no + // warning fires at all. + vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'low'); + try { + const config: KimiConfig = { + providers: { + compatible: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test', + }, + }, + models: { + compatible: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'xhigh'], + defaultEffort: 'xhigh', + }, + }, + thinking: { effort: 'high' }, + }; + const ctx = testAgent({ + initialConfig: config, + providerManager: new ProviderManager({ config }), + }); + + ctx.agent.config.update({ + modelAlias: 'compatible', + systemPrompt: 'system', + }); + + expect(ctx.agent.config.thinkingEffort).toBe('low'); + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([]); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('warns only about the override when both the configured effort and the override are unlisted', () => { + vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'extreme'); + try { + const config: KimiConfig = { + providers: { + compatible: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test', + }, + }, + models: { + compatible: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'xhigh'], + defaultEffort: 'xhigh', + }, + }, + thinking: { effort: 'high' }, + }; + const ctx = testAgent({ + initialConfig: config, + providerManager: new ProviderManager({ config }), + }); + + ctx.agent.config.update({ + modelAlias: 'compatible', + systemPrompt: 'system', + }); + + expect(ctx.agent.config.thinkingEffort).toBe('extreme'); + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([ + { + type: '[rpc]', + event: 'warning', + args: { + code: 'thinking-effort-override-not-listed', + message: + 'Thinking effort "extreme" is not listed for model "compatible-model" (known: low, xhigh). The value will be sent unchanged to the backend.', + }, + }, + ]); + } finally { + vi.unstubAllEnvs(); + } + }); + it('uses session id as a provider prompt cache hint without storing it on Agent', () => { const ctx = testAgent({ providerManager: new ProviderManager({ From 4b8c8da7e83124365f916a822b1dec6dbe096dc7 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Thu, 20 Aug 2026 22:12:54 +0000 Subject: [PATCH 09/34] fix(agent-core): warn when a config reload strands the current thinking effort Live sessions resolve the thinking effort once and cache it, while the ProviderManager reads model metadata lazily from the core config; a reload that narrows a model's declared support_efforts therefore pairs the new list with the stale cached effort and sends it upstream with no diagnostic (the pre-fallback request-time checker covered this for Anthropic-routed providers). A minimal request-time check now compares the provider's effort against the freshly resolved declared list and emits a one-time warning that the value is sent unchanged; the wire value itself is untouched, as it was before this PR. --- packages/agent-core/src/agent/index.ts | 40 +++++++++- .../test/agent/config-state.test.ts | 77 +++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 5017f129f5..8cea2ab74d 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -7,7 +7,7 @@ import { ErrorCodes, KimiError, makeErrorPayload } from '#/errors'; import { log } from '#/logging/logger'; import type { Logger } from '#/logging/types'; import type { AgentAPI, AgentEvent, KimiConfig, SDKAgentRPC, UsageStatus } from '#/rpc'; -import { generate } from '@moonshot-ai/kosong'; +import { generate, type ChatProvider } from '@moonshot-ai/kosong'; import type { EnabledPluginSessionStart, EnabledPluginSystemPrompt, PluginCommandDef } from '#/plugin'; import { expandCommandArguments } from '../plugin/commands'; @@ -298,6 +298,7 @@ export class Agent { // before dispatching), so it must not leave a request trace or a // diagnostic log line claiming a request was sent. if (requestOptions?.signal?.aborted !== true) { + this.warnAboutStaleThinkingEffort(provider, modelAlias); this.llmRequestLogger.logRequest({ provider, modelAlias, @@ -386,6 +387,43 @@ export class Agent { }); } + /** + * Request-time safety net for an effort resolved against older model + * metadata: a config reload swaps the declared effort list without + * re-running config resolution, leaving the cached effort outside the new + * list. The cached value is still sent unchanged (live sessions never + * re-resolve mid-flight); this restores the one-time diagnostic for that + * drift, on every protocol. + */ + private warnAboutStaleThinkingEffort( + provider: ChatProvider, + modelAlias: string | undefined, + ): void { + try { + const effort = provider.thinkingEffort; + if (effort === null || effort === 'on' || effort === 'off') return; + const resolved = + modelAlias === undefined + ? undefined + : this.modelProvider?.resolveProviderConfig(modelAlias); + if (resolved === undefined) return; + const supportEfforts = resolved.supportEfforts?.filter((value) => value.length > 0); + if (supportEfforts === undefined || supportEfforts.length === 0) return; + if (supportEfforts.includes(effort)) return; + this.emitThinkingEffortWarning({ + code: 'thinking-effort-not-listed', + message: `Thinking effort "${effort}" is not listed for model "${provider.modelName}" (known: ${supportEfforts.join(', ')}). The value will be sent unchanged to the backend.`, + modelAlias, + model: provider.modelName, + effort, + knownEfforts: supportEfforts.join(','), + fallbackEffort: undefined, + }); + } catch { + // Capability diagnostics must never turn a sendable request into a failure. + } + } + private emitThinkingEffortWarning(warning: { readonly code: string; readonly message: string; diff --git a/packages/agent-core/test/agent/config-state.test.ts b/packages/agent-core/test/agent/config-state.test.ts index abafe04a9a..5a29f2423e 100644 --- a/packages/agent-core/test/agent/config-state.test.ts +++ b/packages/agent-core/test/agent/config-state.test.ts @@ -415,6 +415,83 @@ describe('ConfigState model capabilities', () => { } }); + it('warns on the next request when a config reload drops the current effort from the list', async () => { + // The session resolved "high" while the model declared ["high", "max"]; + // a reload then narrows the list to ["max"]. Live sessions never + // re-resolve mid-flight, so the stale value keeps going out — the + // request-time check is the diagnostic for that drift. + const compatibleModel = (supportEfforts: string[]) => ({ + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic' as const, + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts, + }); + let current: KimiConfig = { + providers: { + compatible: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test', + }, + }, + models: { compatible: compatibleModel(['high', 'max']) }, + }; + let requests = 0; + const ctx = testAgent({ + initialConfig: current, + providerManager: new ProviderManager({ config: () => current }), + generate: async (provider) => { + requests += 1; + expect(provider.thinkingEffort).toBe('high'); + return { + id: 'response-1', + message: { role: 'assistant', content: [], toolCalls: [] }, + usage: emptyUsage(), + finishReason: 'completed', + rawFinishReason: 'stop', + }; + }, + }); + ctx.agent.config.update({ + modelAlias: 'compatible', + systemPrompt: 'system', + }); + ctx.agent.config.setThinkingEffort('high'); + + await ctx.agent.llm.chat({ + messages: [], + tools: [], + signal: new AbortController().signal, + }); + + expect(requests).toBe(1); + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([]); + + current = { + ...current, + models: { compatible: compatibleModel(['max']) }, + }; + + await ctx.agent.llm.chat({ + messages: [], + tools: [], + signal: new AbortController().signal, + }); + + expect(requests).toBe(2); + expect(ctx.allEvents).toContainEqual({ + type: '[rpc]', + event: 'warning', + args: { + code: 'thinking-effort-not-listed', + message: + 'Thinking effort "high" is not listed for model "compatible-model" (known: max). The value will be sent unchanged to the backend.', + }, + }); + }); + it('uses session id as a provider prompt cache hint without storing it on Agent', () => { const ctx = testAgent({ providerManager: new ProviderManager({ From f2c856c917da10907f92262bba5b3a66d35b0b68 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Thu, 20 Aug 2026 22:50:46 +0000 Subject: [PATCH 10/34] fix(agent-core): warn on createSession efforts that fall back to the model default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createSession pre-resolved an explicit thinking effort against the raw model alias before handing it to ConfigState, so the fallback metadata was discarded and the bootstrap update saw an already-normalized, in-list value — the one-time fallback warning never fired on this path. Forward the raw requested effort and let ConfigState.update() resolve it against the resolved provider, the same single resolution point used by every other path. --- packages/agent-core/src/rpc/core-impl.ts | 21 ++++++++--------- .../test/harness/model-alias-session.test.ts | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 17b261aa30..fac7615b97 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -10,7 +10,6 @@ import { MoonshotWebSearchProvider } from '#/tools/providers/moonshot-web-search import { ImageLimits } from '#/tools/support/image-limits'; import type { PromisableMethods } from '#/utils/types'; import { getCoreVersion } from '#/version'; -import { resolveThinkingEffort } from '../agent/config/thinking'; import { Agent } from '../agent'; import { limitAgentReplayByTurns } from '../agent/replay/turns'; import { @@ -341,16 +340,14 @@ export class KimiCore implements PromisableMethods { const sessionConfig = this.withPrintModeDefaults(config); const id = options.id ?? createSessionId(); const modelAlias = options.model ?? config.defaultModel; - const model = modelAlias !== undefined ? config.models?.[modelAlias] : undefined; - // Forward only an explicitly requested effort. With no explicit value the - // initial effort is left to ConfigState.update(), which resolves it from - // the resolved provider — that carries the provider-level protocol context - // a raw model alias lacks (e.g. provider type "anthropic" with a custom - // model name must default to the inferred profile effort, not "off"). - const thinkingEffort = - options.thinking === undefined - ? undefined - : resolveThinkingEffort(options.thinking, config.thinking, model); + // Forward an explicitly requested effort verbatim: ConfigState.update() + // resolves it against the resolved provider — which carries the + // provider-level protocol context a raw model alias lacks (e.g. provider + // type "anthropic" with a custom model name) — and emits the one-time + // fallback warning when the value lands outside the declared effort + // list. With no explicit value the initial effort falls through to the + // model default on the same path. + const thinkingEffort = options.thinking; const permissionMode = options.permission ?? config.defaultPermissionMode; const baseMcpConfig = await resolveSessionMcpConfig({ cwd: workDir, @@ -481,7 +478,7 @@ export class KimiCore implements PromisableMethods { }; const mainAgent = await session.createMain(); mainAgent.config.update({ - modelAlias: options.model ?? config.defaultModel, + modelAlias, thinkingEffort, }); if (permissionMode !== undefined) { diff --git a/packages/agent-core/test/harness/model-alias-session.test.ts b/packages/agent-core/test/harness/model-alias-session.test.ts index c2bde76714..f17bae4998 100644 --- a/packages/agent-core/test/harness/model-alias-session.test.ts +++ b/packages/agent-core/test/harness/model-alias-session.test.ts @@ -176,6 +176,29 @@ max_context_size = 200000 expect(config.thinkingEffort).toBe('low'); }); + it('warns once and falls back when createSession requests an unlisted effort', async () => { + await writeFile(configPath, compatibleConfig('"xhigh"', 'xhigh')); + const events: Array[0]> = []; + const rpc = await createTestRpc({ emitEvent: (event) => events.push(event) }); + + const created = await rpc.createSession({ + workDir, + model: 'compatible/model', + thinking: 'high', + }); + + const config = await rpc.getConfig({ sessionId: created.id, agentId: 'main' }); + expect(config.thinkingEffort).toBe('xhigh'); + expect(events).toContainEqual({ + sessionId: created.id, + agentId: 'main', + type: 'warning', + code: 'thinking-effort-not-listed', + message: + 'Thinking effort "high" is not listed for model "compatible-model" (known: xhigh). Falling back to the model\'s default effort "xhigh".', + }); + }); + it('restores the final effort after replaying an earlier unlisted Anthropic effort', async () => { const sessionId = await createEffortReplaySession(); From 07b6f6d0008b021cecfad99f374774994b697aba Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Thu, 20 Aug 2026 23:34:44 +0000 Subject: [PATCH 11/34] fix(kimi-code): reject /effort values outside a model's declared list The /effort command still told Anthropic-routed models that an unlisted effort would be sent unchanged, but the engine now falls back to the declared default on every protocol, so the prompt lied about the effort the session would actually use. The command now rejects unlisted values with the available set whenever the model declares an effort list, matching the existing convention for other protocols; only models without a declared list keep the warn-and-send escape hatch, where the engine still passes the value through. --- apps/kimi-code/src/tui/commands/config.ts | 10 +++-- .../test/tui/kimi-tui-message-flow.test.ts | 38 ++++++++++++++++++- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 18f7edb5d8..09bb7b6ab8 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -314,15 +314,19 @@ export async function handleEffortCommand(host: SlashCommandHost, args: string): if (!segments.includes(arg)) { const providerType = host.state.appState.availableProviders[effective.provider]?.type; const protocol = effective.protocol ?? providerType; - if (protocol !== 'anthropic') { + // With a declared effort list the engine falls back to the model default + // for every protocol, so an unlisted value is rejected like any invalid + // input. Only Anthropic-compatible models WITHOUT a declared list keep + // the warn-and-send escape hatch — there the engine passes the value + // through for the backend to judge. + if (protocol !== 'anthropic' || (effective.supportEfforts?.length ?? 0) > 0) { host.showError( `Unsupported thinking effort "${arg}" for ${alias}. Available: ${segments.join(', ')}`, ); return; } - const knownEfforts = effective.supportEfforts?.join(', ') ?? 'none declared'; host.showStatus( - `Thinking effort "${arg}" is not listed for ${alias} (known: ${knownEfforts}). Sending "${arg}" unchanged; the configured provider will validate it.`, + `Thinking effort "${arg}" is not declared for ${alias}. Sending "${arg}" unchanged; the configured provider will validate it.`, 'warning', ); } diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index fdf369ddd0..1882392743 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -8129,7 +8129,9 @@ describe('/model status displayName override', () => { }); describe('/effort support_efforts override', () => { - it('warns and applies efforts hidden by an Anthropic support_efforts override', async () => { + it('rejects efforts hidden by an Anthropic support_efforts override', async () => { + // The engine falls back to the declared default for unlisted efforts on + // every protocol, so the TUI rejects them like any other invalid value. const session = makeSession(); const { driver } = await makeDriver(session, { getConfig: vi.fn(async () => ({ @@ -8155,6 +8157,38 @@ describe('/effort support_efforts override', () => { driver.handleUserInput('/effort max'); + await vi.waitFor(() => { + expect(renderTranscript(driver)).toContain( + 'Unsupported thinking effort "max" for k2. Available: off, low, high', + ); + }); + expect(session.setThinking).not.toHaveBeenCalled(); + }); + + it('still sends unlisted efforts unchanged for Anthropic models without a declared list', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'kimi', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 100, + displayName: 'Compatible Model', + capabilities: ['thinking'], + }, + }, + defaultModel: 'k2', + thinking: { enabled: true }, + })), + }); + + driver.handleUserInput('/effort max'); + await vi.waitFor(() => { expect(session.setThinking).toHaveBeenCalledWith('max'); }); @@ -8163,7 +8197,7 @@ describe('/effort support_efforts override', () => { }); const transcript = renderTranscript(driver).replaceAll(/\s+/g, ' '); expect(transcript).toContain( - 'Thinking effort "max" is not listed for k2 (known: low, high). Sending "max" unchanged; the configured provider will validate it.', + 'Thinking effort "max" is not declared for k2. Sending "max" unchanged; the configured provider will validate it.', ); expect(transcript).toContain('Thinking set to max.'); }); From 5b6415176c62fd82a92b66ea3b2b123278f89bfd Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Fri, 21 Aug 2026 00:06:55 +0000 Subject: [PATCH 12/34] fix(kimi-code): ignore blank support_efforts entries in the /effort command --- apps/kimi-code/src/tui/commands/config.ts | 4 +-- .../tui/components/dialogs/model-selector.ts | 4 ++- .../test/tui/kimi-tui-message-flow.test.ts | 34 +++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 09bb7b6ab8..313a6415a5 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -15,7 +15,7 @@ import { ExperimentsSelectorComponent, type ExperimentalFeatureDraftChange, } from '../components/dialogs/experiments-selector'; -import { modelDisplayName, segmentsFor } from '../components/dialogs/model-selector'; +import { modelDisplayName, segmentsFor, effortsOf } from '../components/dialogs/model-selector'; import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector'; import { PermissionSelectorComponent } from '../components/dialogs/permission-selector'; import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector'; @@ -319,7 +319,7 @@ export async function handleEffortCommand(host: SlashCommandHost, args: string): // input. Only Anthropic-compatible models WITHOUT a declared list keep // the warn-and-send escape hatch — there the engine passes the value // through for the backend to judge. - if (protocol !== 'anthropic' || (effective.supportEfforts?.length ?? 0) > 0) { + if (protocol !== 'anthropic' || effortsOf(effective).length > 0) { host.showError( `Unsupported thinking effort "${arg}" for ${alias}. Available: ${segments.join(', ')}`, ); diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index 2532f14a2d..c075c83d48 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -107,7 +107,9 @@ export function thinkingAvailability(model: ModelAlias): ThinkingAvailability { } export function effortsOf(model: ModelAlias): readonly string[] { - return model.supportEfforts ?? []; + // Blank entries are not real efforts: the engine resolvers discard them + // (effortsFor), so a list like [""] must not count as a declared list here. + return (model.supportEfforts ?? []).filter((effort) => effort.trim().length > 0); } /** diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 1882392743..17bbb60f68 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -8202,6 +8202,40 @@ describe('/effort support_efforts override', () => { expect(transcript).toContain('Thinking set to max.'); }); + it('treats a blank-only support_efforts list as no declared list', async () => { + // The engine resolvers discard empty entries, so support_efforts: [""] + // means "no declared list" and the Anthropic escape hatch still applies. + const session = makeSession(); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'kimi', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 100, + displayName: 'Compatible Model', + capabilities: ['thinking'], + supportEfforts: [''], + }, + }, + defaultModel: 'k2', + thinking: { enabled: true }, + })), + }); + + driver.handleUserInput('/effort max'); + + await vi.waitFor(() => { + expect(session.setThinking).toHaveBeenCalledWith('max'); + }); + const transcript = renderTranscript(driver).replaceAll(/\s+/g, ' '); + expect(transcript).toContain('Sending "max" unchanged'); + }); + it('offers the latest Opus efforts for an unknown Claude-marked Anthropic-compatible model', async () => { const { driver } = await makeDriver(makeSession(), { getConfig: vi.fn(async () => ({ From 119db0f72faf762b0505c1295e03c84eb4a9cd9d Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Fri, 21 Aug 2026 00:46:19 +0000 Subject: [PATCH 13/34] fix: treat a declared support_efforts list as thinking support under strict validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A custom model may declare support_efforts/default_effort while omitting the thinking capability. On the strict-validation path the capability gate ran before the list membership check, so an out-of-list effort (or 'on') resolved to 'off' — silently disabling thinking — instead of the declared default, and the effort validation rejected even listed values. Both engines now check list membership first when a list is declared (falling back to the declared default_effort, else the middle entry) and only consult the capability gate when no list is declared. --- .../src/kosong/model/thinking.ts | 16 +++++++++------- .../test/kosong/model/thinking.test.ts | 19 +++++++++++++++++++ .../agent-core/src/agent/config/thinking.ts | 18 +++++++++++------- .../test/agent/config/thinking.test.ts | 19 +++++++++++++++++++ 4 files changed, 58 insertions(+), 14 deletions(-) diff --git a/packages/agent-core-v2/src/kosong/model/thinking.ts b/packages/agent-core-v2/src/kosong/model/thinking.ts index 84e54d1bbb..88432ad0e3 100644 --- a/packages/agent-core-v2/src/kosong/model/thinking.ts +++ b/packages/agent-core-v2/src/kosong/model/thinking.ts @@ -136,9 +136,9 @@ export function modelSupportsThinkingEffort( strictValidation: boolean, ): boolean { if (!strictValidation || effort === 'off') return true; - if (!modelSupportsThinking(model)) return false; const efforts = effortsFor(model); - return efforts.length === 0 || effort === 'on' || efforts.includes(effort); + if (efforts.length > 0) return effort === 'on' || efforts.includes(effort); + return modelSupportsThinking(model); } function normalizeThinkingEffortForModel( @@ -155,12 +155,14 @@ function normalizeThinkingEffortForModel( } return effort; } - if (!modelSupportsThinking(model)) return 'off'; - if (efforts.length === 0) return 'on'; - if (effort === 'on' || !efforts.includes(effort)) { - return defaultThinkingEffortForModel(model); + if (efforts.length > 0) { + if (effort === 'on' || !efforts.includes(effort)) { + return declaredDefaultEffortFor(model, efforts); + } + return effort; } - return effort; + if (!modelSupportsThinking(model)) return 'off'; + return 'on'; } export interface ThinkingEffortFallback { diff --git a/packages/agent-core-v2/test/kosong/model/thinking.test.ts b/packages/agent-core-v2/test/kosong/model/thinking.test.ts index 33d3d3d36a..11b8851770 100644 --- a/packages/agent-core-v2/test/kosong/model/thinking.test.ts +++ b/packages/agent-core-v2/test/kosong/model/thinking.test.ts @@ -127,6 +127,25 @@ describe('resolveThinkingEffortForModel', () => { ).toBe('medium'); }); + it('treats a declared effort list as thinking support under strict validation', () => { + const declared = { + supportEfforts: ['low', 'medium', 'xhigh'], + defaultEffort: 'xhigh', + }; + expect(resolveThinkingEffortForModel(undefined, { effort: 'high' }, declared, true)).toBe( + 'xhigh', + ); + expect(resolveThinkingEffortForModel('high', undefined, declared, true)).toBe('xhigh'); + expect(resolveThinkingEffortForModel('on', undefined, declared, true)).toBe('xhigh'); + expect(resolveThinkingEffortForModel('medium', undefined, declared, true)).toBe('medium'); + expect(modelSupportsThinkingEffort('low', declared, true)).toBe(true); + expect(modelSupportsThinkingEffort('bogus', declared, true)).toBe(false); + expect(resolveThinkingEffortForModelWithFallback('high', undefined, declared, true)).toEqual({ + effort: 'xhigh', + fallback: { configured: 'high', resolved: 'xhigh' }, + }); + }); + it('keeps always-thinking models on under kimi semantics', () => { const always = { capabilities: ['always_thinking'], diff --git a/packages/agent-core/src/agent/config/thinking.ts b/packages/agent-core/src/agent/config/thinking.ts index a54a7c4562..4bed8ab7de 100644 --- a/packages/agent-core/src/agent/config/thinking.ts +++ b/packages/agent-core/src/agent/config/thinking.ts @@ -66,9 +66,11 @@ export function supportsThinkingEffort( ): boolean { if (!kimiProtocol || effort === 'off') return true; const effective = model === undefined ? undefined : effectiveModelAlias(model); - if (!supportsThinking(effective)) return false; const efforts = effortsFor(effective); - return efforts.length === 0 || effort === 'on' || efforts.includes(effort); + // A declared effort list is itself a declaration of thinking support: list + // membership decides, even when the thinking capability was omitted. + if (efforts.length > 0) return effort === 'on' || efforts.includes(effort); + return supportsThinking(effective); } function normalizeThinkingEffortForModel( @@ -92,12 +94,14 @@ function normalizeThinkingEffortForModel( } return effort; } - if (!supportsThinking(effective)) return 'off'; - if (efforts.length === 0) return 'on'; - if (effort === 'on' || !efforts.includes(effort)) { - return defaultThinkingEffortFor(effective); + if (efforts.length > 0) { + if (effort === 'on' || !efforts.includes(effort)) { + return declaredDefaultEffortFor(effective, efforts); + } + return effort; } - return effort; + if (!supportsThinking(effective)) return 'off'; + return 'on'; } /** diff --git a/packages/agent-core/test/agent/config/thinking.test.ts b/packages/agent-core/test/agent/config/thinking.test.ts index d090ba154d..01e9d708c3 100644 --- a/packages/agent-core/test/agent/config/thinking.test.ts +++ b/packages/agent-core/test/agent/config/thinking.test.ts @@ -234,6 +234,25 @@ describe('resolveThinkingEffort', () => { ).toBe('medium'); }); + it('treats a declared effort list as thinking support on the Kimi wire', () => { + // support_efforts without the thinking capability: list membership takes + // precedence over the capability gate on the strict path too. + const declared = model({ + supportEfforts: ['low', 'medium', 'xhigh'], + defaultEffort: 'xhigh', + }); + expect(resolveThinkingEffort(undefined, { effort: 'high' }, declared, true)).toBe('xhigh'); + expect(resolveThinkingEffort('high', undefined, declared, true)).toBe('xhigh'); + expect(resolveThinkingEffort('on', undefined, declared, true)).toBe('xhigh'); + expect(resolveThinkingEffort('medium', undefined, declared, true)).toBe('medium'); + expect(supportsThinkingEffort('low', declared, true)).toBe(true); + expect(supportsThinkingEffort('bogus', declared, true)).toBe(false); + expect(resolveThinkingEffortWithFallback('high', undefined, declared, true)).toEqual({ + effort: 'xhigh', + fallback: { configured: 'high', resolved: 'xhigh' }, + }); + }); + it('projects a concrete effort to on for a boolean-only Kimi model', () => { expect(resolveThinkingEffort('ultra', undefined, booleanModel, true)).toBe('on'); }); From 2a32e569ea00d58e17b1e934cc15f26f41982d20 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Fri, 21 Aug 2026 01:14:59 +0000 Subject: [PATCH 14/34] fix(agent-core): trim declared thinking efforts before matching and fallback --- packages/agent-core/src/agent/config/thinking.ts | 5 ++++- .../agent-core/test/agent/config/thinking.test.ts | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/agent-core/src/agent/config/thinking.ts b/packages/agent-core/src/agent/config/thinking.ts index 4bed8ab7de..9cf27ceb63 100644 --- a/packages/agent-core/src/agent/config/thinking.ts +++ b/packages/agent-core/src/agent/config/thinking.ts @@ -21,7 +21,10 @@ function middleOf(efforts: readonly string[]): string { function effortsFor(model: ModelAlias | undefined): readonly string[] { const effective = model === undefined ? undefined : effectiveModelAlias(model); - return effective?.supportEfforts?.filter((effort) => effort.length > 0) ?? []; + return ( + effective?.supportEfforts?.map((effort) => effort.trim()).filter((effort) => effort.length > 0) ?? + [] + ); } /** diff --git a/packages/agent-core/test/agent/config/thinking.test.ts b/packages/agent-core/test/agent/config/thinking.test.ts index 01e9d708c3..f6d08c5861 100644 --- a/packages/agent-core/test/agent/config/thinking.test.ts +++ b/packages/agent-core/test/agent/config/thinking.test.ts @@ -210,6 +210,21 @@ describe('resolveThinkingEffort', () => { ); }); + it('ignores whitespace-only supportEfforts entries on every protocol', () => { + // A whitespace-only entry is not a real effort: the list must not count as + // declared, and a trimmed declared list must match against trimmed values. + const blankOnly = model({ capabilities: ['thinking'], supportEfforts: [' '] }); + expect(resolveThinkingEffort('ultra', undefined, blankOnly, false)).toBe('ultra'); + expect(resolveThinkingEffort('ultra', undefined, blankOnly, true)).toBe('on'); + const padded = model({ + capabilities: ['thinking'], + supportEfforts: [' low ', ' xhigh '], + defaultEffort: 'xhigh', + }); + expect(resolveThinkingEffort('high', undefined, padded, false)).toBe('xhigh'); + expect(resolveThinkingEffort('low', undefined, padded, false)).toBe('low'); + }); + it('falls back to the declared default when the model omits the thinking capability', () => { // A model may declare support_efforts/default_effort without declaring // the thinking capability; the declared list is still authoritative for From 5d3ea301b58b10d42f647297a0a8361978ef21c7 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Fri, 21 Aug 2026 01:43:38 +0000 Subject: [PATCH 15/34] fix(kimi-code): match /effort against trimmed declared effort names --- .../tui/components/dialogs/model-selector.ts | 5 ++- .../test/tui/kimi-tui-message-flow.test.ts | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index c075c83d48..c984effaec 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -109,7 +109,10 @@ export function thinkingAvailability(model: ModelAlias): ThinkingAvailability { export function effortsOf(model: ModelAlias): readonly string[] { // Blank entries are not real efforts: the engine resolvers discard them // (effortsFor), so a list like [""] must not count as a declared list here. - return (model.supportEfforts ?? []).filter((effort) => effort.trim().length > 0); + // Entries are trimmed so padded declarations match normalized /effort input. + return (model.supportEfforts ?? []) + .map((effort) => effort.trim()) + .filter((effort) => effort.length > 0); } /** diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 17bbb60f68..0acfc222e2 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -8236,6 +8236,41 @@ describe('/effort support_efforts override', () => { expect(transcript).toContain('Sending "max" unchanged'); }); + it('matches /effort against trimmed padded support_efforts entries', async () => { + // Padded declarations like [" low ", " high "] are normalized by the + // engine; the TUI must match /effort input against the trimmed values. + const session = makeSession(); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'kimi', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 100, + displayName: 'Compatible Model', + capabilities: ['thinking'], + supportEfforts: [' low ', ' high '], + }, + }, + defaultModel: 'k2', + thinking: { enabled: true }, + })), + }); + + driver.handleUserInput('/effort high'); + + await vi.waitFor(() => { + expect(session.setThinking).toHaveBeenCalledWith('high'); + }); + await vi.waitFor(() => { + expect(renderTranscript(driver)).toContain('Thinking set to high.'); + }); + }); + it('offers the latest Opus efforts for an unknown Claude-marked Anthropic-compatible model', async () => { const { driver } = await makeDriver(makeSession(), { getConfig: vi.fn(async () => ({ From 21bd0072d6117ed48b230f2b6b34c6815028a19b Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Fri, 21 Aug 2026 02:30:36 +0000 Subject: [PATCH 16/34] fix: finish normalizing declared thinking-effort lists Two loose ends of the declared-list work: the default-effort resolver still consulted the capability gate before the declared list, so a model declaring support_efforts without the thinking capability resolved its default to 'off' when nothing was configured; and default_effort was compared untrimmed against trimmed lists, so a padded declared default lost its membership check and the first/middle entry was picked instead. The declared list now wins over the capability gate in both engines, and default_effort is trimmed at every use site (both engines and the TUI model selector). --- .../tui/components/dialogs/model-selector.ts | 16 ++++++++++++---- .../components/dialogs/model-selector.test.ts | 18 ++++++++++++++++++ .../src/kosong/model/thinking.ts | 2 +- .../test/kosong/model/thinking.test.ts | 19 +++++++++++++++++++ .../agent-core/src/agent/config/thinking.ts | 13 ++++++++----- .../test/agent/config/thinking.test.ts | 19 +++++++++++++++++++ 6 files changed, 77 insertions(+), 10 deletions(-) diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index c984effaec..7b82f699af 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -146,7 +146,10 @@ export function defaultThinkingEffortFor(model: ModelAlias): ThinkingEffort { if (thinkingAvailability(model) === 'unsupported') return 'off'; const efforts = effortsOf(model); if (efforts.length > 0) { - return model.defaultEffort ?? efforts[Math.floor(efforts.length / 2)]!; + const declared = model.defaultEffort?.trim(); + return declared !== undefined && declared.length > 0 + ? declared + : efforts[Math.floor(efforts.length / 2)]!; } return 'on'; } @@ -204,9 +207,14 @@ export class ModelSelectorComponent extends Container implements Focusable { 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; + // middle entry of its supported efforts. default_effort is trimmed + // before matching, mirroring the trimmed declared entries (effortsOf). + const declared = choice.model.defaultEffort?.trim(); + const def = + declared === undefined || declared.length === 0 + ? efforts[Math.floor(efforts.length / 2)]! + : declared; + if (efforts.includes(def)) return def; return efforts[0]!; } return thinkingAvailability(choice.model) !== 'unsupported' ? 'on' : 'off'; diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts index e5159ec0d9..314e7342a2 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -504,6 +504,24 @@ describe('ModelSelectorComponent', () => { expect(text(picker)).toContain('[ Medium ]'); }); + it('trims a padded defaultEffort before matching the declared efforts', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + other: effortModel('Kimi Other', [' low ', ' medium ', ' xhigh '], ' xhigh '), + }, + currentValue: 'current', + currentThinkingEffort: 'off', + onSelect, + onCancel: vi.fn(), + }); + + // The padded declared default wins over the first/middle entry. + expect(text(picker)).toContain('[ Xhigh ]'); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ alias: 'other', thinking: 'xhigh' }); + }); + it('renders the warning line directly below the key-hint line when provided', () => { const picker = new ModelSelectorComponent({ models: { kimi: model('Kimi K2') }, diff --git a/packages/agent-core-v2/src/kosong/model/thinking.ts b/packages/agent-core-v2/src/kosong/model/thinking.ts index 88432ad0e3..7e6c7e3747 100644 --- a/packages/agent-core-v2/src/kosong/model/thinking.ts +++ b/packages/agent-core-v2/src/kosong/model/thinking.ts @@ -124,9 +124,9 @@ export function modelSupportsThinking(model: ModelThinkingMetadata | undefined): export function defaultThinkingEffortForModel( model: ModelThinkingMetadata | undefined, ): ThinkingEffort { - if (model === undefined || !modelSupportsThinking(model)) return 'off'; const efforts = effortsFor(model); if (efforts.length > 0) return declaredDefaultEffortFor(model, efforts); + if (model === undefined || !modelSupportsThinking(model)) return 'off'; return 'on'; } diff --git a/packages/agent-core-v2/test/kosong/model/thinking.test.ts b/packages/agent-core-v2/test/kosong/model/thinking.test.ts index 11b8851770..c6ba2862f3 100644 --- a/packages/agent-core-v2/test/kosong/model/thinking.test.ts +++ b/packages/agent-core-v2/test/kosong/model/thinking.test.ts @@ -146,6 +146,25 @@ describe('resolveThinkingEffortForModel', () => { }); }); + it('resolves the declared default when nothing is configured and the capability is omitted', () => { + const declared = { + supportEfforts: ['low', 'medium', 'xhigh'], + defaultEffort: 'xhigh', + }; + expect(defaultThinkingEffortForModel(declared)).toBe('xhigh'); + expect(resolveThinkingEffortForModel(undefined, undefined, declared, false)).toBe('xhigh'); + expect(resolveThinkingEffortForModel(undefined, undefined, declared, true)).toBe('xhigh'); + }); + + it('trims a padded default_effort before matching the declared list', () => { + expect( + defaultThinkingEffortForModel({ + supportEfforts: [' low ', ' medium ', ' xhigh '], + defaultEffort: ' xhigh ', + }), + ).toBe('xhigh'); + }); + it('keeps always-thinking models on under kimi semantics', () => { const always = { capabilities: ['always_thinking'], diff --git a/packages/agent-core/src/agent/config/thinking.ts b/packages/agent-core/src/agent/config/thinking.ts index 9cf27ceb63..21b9c36178 100644 --- a/packages/agent-core/src/agent/config/thinking.ts +++ b/packages/agent-core/src/agent/config/thinking.ts @@ -38,17 +38,20 @@ function declaredDefaultEffortFor( model: ModelAlias | undefined, efforts: readonly string[], ): ThinkingEffort { - const declaredDefault = model?.defaultEffort; - return declaredDefault !== undefined && efforts.includes(declaredDefault) + const declaredDefault = model?.defaultEffort?.trim(); + return declaredDefault !== undefined && + declaredDefault.length > 0 && + efforts.includes(declaredDefault) ? declaredDefault : middleOf(efforts); } /** * Resolve the default thinking effort for a model from its declared metadata: + * - models declaring `support_efforts` -> `default_effort`, else the middle + * entry of the list (a declared list is itself a thinking declaration, so + * the capability gate does not apply to it) * - 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 @@ -56,9 +59,9 @@ function declaredDefaultEffortFor( */ export function defaultThinkingEffortFor(model: ModelAlias | undefined): ThinkingEffort { const effective = model === undefined ? undefined : effectiveModelAlias(model); - if (!supportsThinking(effective)) return 'off'; const efforts = effortsFor(effective); if (efforts.length > 0) return declaredDefaultEffortFor(effective, efforts); + if (!supportsThinking(effective)) return 'off'; return 'on'; } diff --git a/packages/agent-core/test/agent/config/thinking.test.ts b/packages/agent-core/test/agent/config/thinking.test.ts index f6d08c5861..8e0797ced4 100644 --- a/packages/agent-core/test/agent/config/thinking.test.ts +++ b/packages/agent-core/test/agent/config/thinking.test.ts @@ -268,6 +268,25 @@ describe('resolveThinkingEffort', () => { }); }); + it('resolves the declared default when nothing is configured and the capability is omitted', () => { + const declared = model({ + supportEfforts: ['low', 'medium', 'xhigh'], + defaultEffort: 'xhigh', + }); + expect(defaultThinkingEffortFor(declared)).toBe('xhigh'); + expect(resolveThinkingEffort(undefined, undefined, declared, false)).toBe('xhigh'); + expect(resolveThinkingEffort(undefined, undefined, declared, true)).toBe('xhigh'); + }); + + it('trims a padded default_effort before matching the declared list', () => { + const declared = model({ + supportEfforts: [' low ', ' medium ', ' xhigh '], + defaultEffort: ' xhigh ', + }); + expect(defaultThinkingEffortFor(declared)).toBe('xhigh'); + expect(resolveThinkingEffort('high', undefined, declared, false)).toBe('xhigh'); + }); + it('projects a concrete effort to on for a boolean-only Kimi model', () => { expect(resolveThinkingEffort('ultra', undefined, booleanModel, true)).toBe('on'); }); From baff3622911c2049bb702fbaa5defe22e0224efc Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Fri, 21 Aug 2026 03:23:56 +0000 Subject: [PATCH 17/34] fix: align declared-effort handling across the TUI and request diagnostics The /effort command rejected the generic 'on' signal for models with a declared effort list even though the engine maps it to the declared default; the TUI's default-effort copy still consulted the capability gate before the declared list, hydrating capability-less custom models as 'off'; and the request-time/override diagnostics compared efforts against the raw declared list, false-warning on padded entries the resolution layer had already accepted after trimming. /effort now accepts 'on' for declared-list models, the TUI resolver prefers the declared list like the engines do, and all three diagnostics share the resolution layer's normalized declared list. --- apps/kimi-code/src/tui/commands/config.ts | 10 +- .../tui/components/dialogs/model-selector.ts | 2 +- .../components/dialogs/model-selector.test.ts | 11 ++- .../test/tui/kimi-tui-message-flow.test.ts | 53 +++++++++++ .../agent/llmRequester/llmRequesterService.ts | 10 +- .../src/agent/profile/profileService.ts | 3 +- .../src/kosong/model/thinking.ts | 11 +++ .../llmRequester/llmRequesterService.test.ts | 11 +++ .../agent-core/src/agent/config/thinking.ts | 18 +++- packages/agent-core/src/agent/index.ts | 14 ++- .../test/agent/config-state.test.ts | 91 +++++++++++++++++++ 11 files changed, 218 insertions(+), 16 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 313a6415a5..06033f867d 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -312,6 +312,14 @@ export async function handleEffortCommand(host: SlashCommandHost, args: string): return; } if (!segments.includes(arg)) { + const declared = effortsOf(effective); + // 'on' is the generic enable signal: with a declared effort list the + // engine maps it to the declared default effort, so it stays a valid + // command even though it never appears in the segment list. + if (arg === 'on' && declared.length > 0) { + await performModelSwitch(host, alias, arg, true); + return; + } const providerType = host.state.appState.availableProviders[effective.provider]?.type; const protocol = effective.protocol ?? providerType; // With a declared effort list the engine falls back to the model default @@ -319,7 +327,7 @@ export async function handleEffortCommand(host: SlashCommandHost, args: string): // input. Only Anthropic-compatible models WITHOUT a declared list keep // the warn-and-send escape hatch — there the engine passes the value // through for the backend to judge. - if (protocol !== 'anthropic' || effortsOf(effective).length > 0) { + if (protocol !== 'anthropic' || declared.length > 0) { host.showError( `Unsupported thinking effort "${arg}" for ${alias}. Available: ${segments.join(', ')}`, ); diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index 7b82f699af..4f875ea6ad 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -143,7 +143,6 @@ export function effortLabel(effort: string): string { * thinking is unsupported. */ export function defaultThinkingEffortFor(model: ModelAlias): ThinkingEffort { - if (thinkingAvailability(model) === 'unsupported') return 'off'; const efforts = effortsOf(model); if (efforts.length > 0) { const declared = model.defaultEffort?.trim(); @@ -151,6 +150,7 @@ export function defaultThinkingEffortFor(model: ModelAlias): ThinkingEffort { ? declared : efforts[Math.floor(efforts.length / 2)]!; } + if (thinkingAvailability(model) === 'unsupported') return 'off'; return 'on'; } diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts index 314e7342a2..363d9bc01b 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -2,7 +2,7 @@ import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it, vi } from 'vitest'; -import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; +import { ModelSelectorComponent, defaultThinkingEffortFor } from '#/tui/components/dialogs/model-selector'; import { currentTheme } from '#/tui/theme'; import { darkColors } from '#/tui/theme/colors'; @@ -522,6 +522,15 @@ describe('ModelSelectorComponent', () => { expect(onSelect).toHaveBeenCalledWith({ alias: 'other', thinking: 'xhigh' }); }); + it('prefers the declared default effort when the model omits the thinking capability', () => { + expect(defaultThinkingEffortFor(effortModel('Kimi Other', ['low', 'high', 'max'], 'max', []))).toBe( + 'max', + ); + expect(defaultThinkingEffortFor(effortModel('Kimi Other', ['low', 'high', 'max'], undefined, []))).toBe( + 'high', + ); + }); + it('renders the warning line directly below the key-hint line when provided', () => { const picker = new ModelSelectorComponent({ models: { kimi: model('Kimi K2') }, diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 0acfc222e2..7040633d92 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -8165,6 +8165,59 @@ describe('/effort support_efforts override', () => { expect(session.setThinking).not.toHaveBeenCalled(); }); + it('accepts /effort on for a declared-list model and shows the mapped default', async () => { + // 'on' never appears in the segment list of an effort-declaring model, + // but the engine maps it to the declared default — keep it a valid + // command and display the resolved effort the engine reports. + const session = makeSession(); + let effort = 'low'; + Object.assign(session, { + setThinking: vi.fn(async (value: string) => { + effort = value === 'on' ? 'high' : value; + }), + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: effort, + permission: 'manual', + planMode: false, + contextTokens: 0, + maxContextTokens: 100, + contextUsage: 0, + })), + }); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'kimi', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 100, + displayName: 'Compatible Model', + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + defaultEffort: 'high', + }, + }, + defaultModel: 'k2', + thinking: { enabled: true, effort: 'low' }, + })), + }); + + driver.handleUserInput('/effort on'); + + await vi.waitFor(() => { + expect(session.setThinking).toHaveBeenCalledWith('on'); + }); + await vi.waitFor(() => { + expect(renderTranscript(driver)).toContain('Thinking set to high.'); + }); + expect(renderTranscript(driver)).not.toContain('Unsupported thinking effort'); + }); + it('still sends unlisted efforts unchanged for Anthropic models without a declared list', async () => { const session = makeSession(); const { driver } = await makeDriver(session, { diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 35d21da4e5..85b3deebba 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -40,7 +40,11 @@ import { import type { ModelOverrides } from '#/kosong/model/model.types'; import { IModelService } from '#/kosong/model/model'; import { completionBudgetParams, resolveCompletionBudget } from '#/kosong/model/completionBudget'; -import { resolveThinkingKeep, type ThinkingConfig } from '#/kosong/model/thinking'; +import { + declaredThinkingEfforts, + resolveThinkingKeep, + type ThinkingConfig, +} from '#/kosong/model/thinking'; import { THINKING_SECTION } from '#/app/kosongConfig/configSection'; import type { Protocol } from '#/kosong/protocol/protocol'; import type { ApiErrorEvent } from '#/app/telemetry/events'; @@ -517,8 +521,8 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { private warnAboutThinkingEffortNotListed(request: ResolvedLLMRequest): void { const effort = request.thinkingEffort; if (effort === 'on' || effort === 'off') return; - const supportEfforts = request.model.supportEfforts?.filter((value) => value.length > 0); - if (supportEfforts === undefined || supportEfforts.length === 0) return; + const supportEfforts = declaredThinkingEfforts(request.model); + if (supportEfforts.length === 0) return; if (supportEfforts.includes(effort)) return; const code = 'thinking-effort-not-listed'; const knownEfforts = supportEfforts.join(','); diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 97c0035b40..9cef43890c 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -9,6 +9,7 @@ import { type ModelOverrides } from '#/kosong/model/model.types'; import { type ModelRequestParams } from '#/kosong/model/modelRequester'; import { IProtocolAdapterRegistry } from '#/kosong/protocol/protocol'; import { + declaredThinkingEfforts, drivesThinkingThroughTraits, modelSupportsThinkingEffort, normalizeRequestedThinkingEffort, @@ -640,7 +641,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ drivesThinkingThroughTraits(model.providerType), ); if (forced !== undefined) return; - const efforts = model.supportEfforts?.filter((value) => value.length > 0) ?? []; + const efforts = declaredThinkingEfforts(model); const knownEfforts = efforts.join(','); const code = 'thinking-effort-not-listed'; const message = `Thinking effort "${fallback.configured}" is not listed for model "${model.name}" (known: ${efforts.join(', ')}). Falling back to the model's default effort "${fallback.resolved}".`; diff --git a/packages/agent-core-v2/src/kosong/model/thinking.ts b/packages/agent-core-v2/src/kosong/model/thinking.ts index 7e6c7e3747..69804de23f 100644 --- a/packages/agent-core-v2/src/kosong/model/thinking.ts +++ b/packages/agent-core-v2/src/kosong/model/thinking.ts @@ -101,6 +101,17 @@ function effortsFor(model: ModelThinkingMetadata | undefined): readonly string[] return model?.supportEfforts?.map(nonEmpty).filter((v): v is string => v !== undefined) ?? []; } +/** + * The model's declared `support_efforts`, normalized the same way the + * resolution layer normalizes them (trimmed, blanks dropped) — for + * diagnostics that must agree with what resolution accepted. + */ +export function declaredThinkingEfforts( + model: ModelThinkingMetadata | undefined, +): readonly string[] { + return effortsFor(model); +} + function declaredDefaultEffortFor( model: ModelThinkingMetadata | undefined, efforts: readonly string[], diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts index f51a9893aa..ff28a98ef5 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts @@ -337,6 +337,17 @@ describe('AgentLLMRequesterService thinking effort diagnostics', () => { }), ]); }); + + it('does not warn when the effort matches a padded declared list entry', async () => { + const calls = { value: 0 }; + const requester = createRequester(calls, null); + Object.defineProperty(requester.model, 'supportEfforts', { value: [' max '] }); + const { service, events } = createService(requester, undefined, { thinkingLevel: 'max' }); + + await service.request(); + + expect(events.filter((event) => event.type === 'warning')).toEqual([]); + }); }); describe('AgentLLMRequesterService strict resend', () => { diff --git a/packages/agent-core/src/agent/config/thinking.ts b/packages/agent-core/src/agent/config/thinking.ts index 21b9c36178..fc2a3ea887 100644 --- a/packages/agent-core/src/agent/config/thinking.ts +++ b/packages/agent-core/src/agent/config/thinking.ts @@ -19,14 +19,24 @@ function middleOf(efforts: readonly string[]): string { return efforts[Math.floor(efforts.length / 2)]!; } -function effortsFor(model: ModelAlias | undefined): readonly string[] { - const effective = model === undefined ? undefined : effectiveModelAlias(model); +/** + * Normalize a declared `support_efforts` list: trim entries and drop blanks. + * Shared by resolution and diagnostics so a padded declaration means the same + * thing everywhere. + */ +export function normalizeDeclaredEfforts( + efforts: readonly string[] | undefined, +): readonly string[] { return ( - effective?.supportEfforts?.map((effort) => effort.trim()).filter((effort) => effort.length > 0) ?? - [] + efforts?.map((effort) => effort.trim()).filter((effort) => effort.length > 0) ?? [] ); } +function effortsFor(model: ModelAlias | undefined): readonly string[] { + const effective = model === undefined ? undefined : effectiveModelAlias(model); + return normalizeDeclaredEfforts(effective?.supportEfforts); +} + /** * Pick the fallback effort straight from the declared list: the declared * `default_effort` when it is listed, else the middle entry. Unlike diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 8cea2ab74d..98f2032c1d 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -35,7 +35,11 @@ import { } from './compaction'; import { CronManager } from './cron'; import { ConfigState } from './config'; -import type { ThinkingEffort, ThinkingEffortFallback } from './config/thinking'; +import { + normalizeDeclaredEfforts, + type ThinkingEffort, + type ThinkingEffortFallback, +} from './config/thinking'; import { ContextMemory } from './context'; import { GoalMode } from './goal'; import { HookEngine } from '../session/hooks'; @@ -346,7 +350,7 @@ export class Agent { fallback: ThinkingEffortFallback, ): void { const effective = model === undefined ? undefined : effectiveModelAlias(model); - const supportEfforts = effective?.supportEfforts?.filter((value) => value.length > 0) ?? []; + const supportEfforts = normalizeDeclaredEfforts(effective?.supportEfforts); const modelName = effective?.model ?? modelAlias ?? 'unknown'; this.emitThinkingEffortWarning({ code: 'thinking-effort-not-listed', @@ -373,7 +377,7 @@ export class Agent { ): void { if (effort === 'on' || effort === 'off') return; const effective = model === undefined ? undefined : effectiveModelAlias(model); - const supportEfforts = effective?.supportEfforts?.filter((value) => value.length > 0) ?? []; + const supportEfforts = normalizeDeclaredEfforts(effective?.supportEfforts); if (supportEfforts.length === 0 || supportEfforts.includes(effort)) return; const modelName = effective?.model ?? modelAlias ?? 'unknown'; this.emitThinkingEffortWarning({ @@ -407,8 +411,8 @@ export class Agent { ? undefined : this.modelProvider?.resolveProviderConfig(modelAlias); if (resolved === undefined) return; - const supportEfforts = resolved.supportEfforts?.filter((value) => value.length > 0); - if (supportEfforts === undefined || supportEfforts.length === 0) return; + const supportEfforts = normalizeDeclaredEfforts(resolved.supportEfforts); + if (supportEfforts.length === 0) return; if (supportEfforts.includes(effort)) return; this.emitThinkingEffortWarning({ code: 'thinking-effort-not-listed', diff --git a/packages/agent-core/test/agent/config-state.test.ts b/packages/agent-core/test/agent/config-state.test.ts index 5a29f2423e..394861678a 100644 --- a/packages/agent-core/test/agent/config-state.test.ts +++ b/packages/agent-core/test/agent/config-state.test.ts @@ -319,6 +319,97 @@ describe('ConfigState model capabilities', () => { } }); + it('does not warn when the effort matches a padded declared list entry', async () => { + let requests = 0; + const config: KimiConfig = { + providers: { + compatible: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test', + }, + }, + models: { + compatible: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: [' high ', ' max '], + }, + }, + }; + const ctx = testAgent({ + initialConfig: config, + providerManager: new ProviderManager({ config }), + generate: async (provider) => { + requests += 1; + expect(provider.thinkingEffort).toBe('high'); + return { + id: 'response-1', + message: { role: 'assistant', content: [], toolCalls: [] }, + usage: emptyUsage(), + finishReason: 'completed', + rawFinishReason: 'stop', + }; + }, + }); + ctx.agent.config.update({ + modelAlias: 'compatible', + systemPrompt: 'system', + }); + ctx.agent.config.setThinkingEffort('high'); + + await ctx.agent.llm.chat({ + messages: [], + tools: [], + signal: new AbortController().signal, + }); + + expect(requests).toBe(1); + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([]); + }); + + it('does not warn when the env override matches a padded declared list entry', () => { + vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'low'); + try { + const config: KimiConfig = { + providers: { + compatible: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test', + }, + }, + models: { + compatible: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: [' low ', ' high '], + }, + }, + }; + const ctx = testAgent({ + initialConfig: config, + providerManager: new ProviderManager({ config }), + }); + + ctx.agent.config.update({ + modelAlias: 'compatible', + systemPrompt: 'system', + }); + + expect(ctx.agent.config.thinkingEffort).toBe('low'); + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([]); + } finally { + vi.unstubAllEnvs(); + } + }); + it('suppresses the fallback warning when the env override decides the final effort', () => { // The configured "high" is unlisted and would fall back to "xhigh", but // the env pin "low" decides what actually goes on the wire — warning From 5c71641eacd7c6000e807ec3f562a51e5a5b8835 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Fri, 21 Aug 2026 04:08:31 +0000 Subject: [PATCH 18/34] fix(kimi-code): resolve /effort on to the model default before lazy session creation With no live session (v2 lazy creation), /effort on stored the raw 'on' signal into the session-only thinking override and displayed "Thinking set to on", even though the first session would resolve it to the model's declared default effort. The sessionless branch of the model/effort switch now maps 'on' through the model metadata up front; the concrete default is in the declared list, so the engine's re-resolution at creation time leaves it unchanged. --- apps/kimi-code/src/tui/commands/config.ts | 19 ++++++- .../test/tui/kimi-tui-message-flow.test.ts | 52 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 06033f867d..51b7a51477 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -15,7 +15,12 @@ import { ExperimentsSelectorComponent, type ExperimentalFeatureDraftChange, } from '../components/dialogs/experiments-selector'; -import { modelDisplayName, segmentsFor, effortsOf } from '../components/dialogs/model-selector'; +import { + defaultThinkingEffortFor, + modelDisplayName, + segmentsFor, + effortsOf, +} from '../components/dialogs/model-selector'; import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector'; import { PermissionSelectorComponent } from '../components/dialogs/permission-selector'; import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector'; @@ -524,7 +529,17 @@ async function performModelSwitch( try { if (session === undefined && runtimeChanged) { - await host.authFlow.activateModelAfterLogin(alias, effort); + // Session-less (lazy creation): no engine is around to map the generic + // 'on' onto the model's declared default yet, so resolve it here — the + // carried state and the status line should show the effort the first + // session will actually use. The engine re-resolves the concrete value + // at creation, which is in-list and therefore unchanged. + const selectedModel = host.state.appState.availableModels[alias]; + const sessionlessEffort = + effort === 'on' && selectedModel !== undefined + ? defaultThinkingEffortFor(effectiveModelForHost(host, selectedModel)) + : effort; + await host.authFlow.activateModelAfterLogin(alias, sessionlessEffort); } else if (session !== undefined) { if (alias !== prevModel) { await session.setModel(alias); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 7040633d92..f69853c5f8 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -8218,6 +8218,58 @@ describe('/effort support_efforts override', () => { expect(renderTranscript(driver)).not.toContain('Unsupported thinking effort'); }); + it('resolves /effort on to the declared default before a session exists (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver( + session, + { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'kimi', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 100, + displayName: 'Compatible Model', + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + defaultEffort: 'high', + }, + }, + defaultModel: 'k2', + thinking: { enabled: true, effort: 'low' }, + })), + }, + startupInput, + ); + expect(driver.state.appState.sessionId).toBe(''); + + driver.handleUserInput('/effort on'); + + await vi.waitFor(() => { + expect(renderTranscript(driver)).toContain('Thinking set to high.'); + }); + expect(driver.state.appState.thinkingEffort).toBe('high'); + expect(driver.state.appState.lazySessionThinking).toBe('high'); + + driver.handleUserInput('hello'); + + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalled(); + }); + expect(harness.createSession).toHaveBeenCalledWith( + expect.objectContaining({ model: 'k2', thinking: 'high' }), + ); + }); + it('still sends unlisted efforts unchanged for Anthropic models without a declared list', async () => { const session = makeSession(); const { driver } = await makeDriver(session, { From 15ce9aa068330ce4d52f305d6d94963963b35bdc Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Fri, 21 Aug 2026 04:37:53 +0000 Subject: [PATCH 19/34] fix(kimi-code): reject an unlisted declared default effort in the TUI --- .../kimi-code/src/tui/components/dialogs/model-selector.ts | 2 +- .../test/tui/components/dialogs/model-selector.test.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index 4f875ea6ad..d80da8fd47 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -146,7 +146,7 @@ export function defaultThinkingEffortFor(model: ModelAlias): ThinkingEffort { const efforts = effortsOf(model); if (efforts.length > 0) { const declared = model.defaultEffort?.trim(); - return declared !== undefined && declared.length > 0 + return declared !== undefined && declared.length > 0 && efforts.includes(declared) ? declared : efforts[Math.floor(efforts.length / 2)]!; } diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts index 363d9bc01b..85e973498e 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -531,6 +531,13 @@ describe('ModelSelectorComponent', () => { ); }); + it('falls back to the middle entry when the declared default is not listed', () => { + // Matches the engine resolvers: an unlisted default_effort is rejected. + expect(defaultThinkingEffortFor(effortModel('Kimi Other', ['low', 'high'], 'max', []))).toBe( + 'high', + ); + }); + it('renders the warning line directly below the key-hint line when provided', () => { const picker = new ModelSelectorComponent({ models: { kimi: model('Kimi K2') }, From bfc60b91579b0724b73c39d3c103e0da46393b63 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Fri, 21 Aug 2026 05:13:01 +0000 Subject: [PATCH 20/34] fix(kimi-code): match the top thinking tier against the normalized declared list thinkingEffortToConfig compared the selected effort against the raw support_efforts entries, so with a padded declaration the top-tier pick missed the exact match and was persisted as the global thinking.effort default instead of staying session-only. The helper now trims and drops blank entries before the top-tier comparison, which covers both persistence call sites (/effort and /model default selection). --- .../src/tui/utils/thinking-config.ts | 7 +++- .../test/tui/kimi-tui-message-flow.test.ts | 36 ++++++++++++++++++- .../test/tui/utils/thinking-config.test.ts | 14 ++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/apps/kimi-code/src/tui/utils/thinking-config.ts b/apps/kimi-code/src/tui/utils/thinking-config.ts index da3ea13604..d06edc6764 100644 --- a/apps/kimi-code/src/tui/utils/thinking-config.ts +++ b/apps/kimi-code/src/tui/utils/thinking-config.ts @@ -27,7 +27,12 @@ export function thinkingEffortToConfig( } { if (effort === 'off') return { enabled: false }; if (effort === 'on') return { enabled: true }; - const top = supportEfforts?.at(-1); + // Declared entries are padded-tolerant, matching the engine resolvers: + // trim and drop blanks before identifying the top tier. + const declared = (supportEfforts ?? []) + .map((value) => value.trim()) + .filter((value) => value.length > 0); + const top = declared.at(-1); if (top !== undefined && effort === top) return { enabled: true }; return { enabled: true, effort }; } diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index f69853c5f8..ada357ba76 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -8270,8 +8270,42 @@ describe('/effort support_efforts override', () => { ); }); - it('still sends unlisted efforts unchanged for Anthropic models without a declared list', async () => { + it('persists only the enabled flag when a padded list top tier is selected', async () => { const session = makeSession(); + const setConfig = vi.fn(async () => ({})); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'kimi', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 100, + displayName: 'Compatible Model', + capabilities: ['thinking'], + supportEfforts: [' low ', ' max '], + }, + }, + defaultModel: 'k2', + thinking: { enabled: true, effort: 'low' }, + })), + setConfig, + }); + + driver.handleUserInput('/effort max'); + + await vi.waitFor(() => { + expect(setConfig).toHaveBeenCalledWith({ + defaultModel: 'k2', + thinking: { enabled: true }, + }); + }); + }); + + it('still sends unlisted efforts unchanged for Anthropic models without a declared list', async () => { const session = makeSession(); const { driver } = await makeDriver(session, { getConfig: vi.fn(async () => ({ providers: { diff --git a/apps/kimi-code/test/tui/utils/thinking-config.test.ts b/apps/kimi-code/test/tui/utils/thinking-config.test.ts index e0a953595a..6bb00ceef4 100644 --- a/apps/kimi-code/test/tui/utils/thinking-config.test.ts +++ b/apps/kimi-code/test/tui/utils/thinking-config.test.ts @@ -35,6 +35,20 @@ describe('thinkingEffortToConfig', () => { it('treats a single declared level as the top tier', () => { expect(thinkingEffortToConfig('max', ['max'])).toEqual({ enabled: true }); }); + + it('matches the top tier against a padded declared list', () => { + expect(thinkingEffortToConfig('max', [' low ', ' high ', ' max '])).toEqual({ + enabled: true, + }); + expect(thinkingEffortToConfig('high', [' low ', ' high ', ' max '])).toEqual({ + enabled: true, + effort: 'high', + }); + expect(thinkingEffortToConfig('max', ['', ' '])).toEqual({ + enabled: true, + effort: 'max', + }); + }); }); describe('isThinkingOn', () => { From b8fa01677e725552c50600e26343b2ff46bac06d Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Fri, 21 Aug 2026 05:50:26 +0000 Subject: [PATCH 21/34] fix: align the picker's unlisted-default fallback and the 400 hint with engine behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model picker fell back to the first declared effort when a model's default_effort was not in its own support_efforts, while both engines pick the middle entry — confirming without toggling sent a different effort than the engine would have resolved. And the provider-rejection hint claimed every out-of-list effort falls back to the model default, which is wrong for the value a live session locked in before a config reload (kept unchanged by design). The picker now shares the default resolver, and the hint lists both send-unchanged exceptions. --- .../tui/components/dialogs/model-selector.ts | 15 +-------------- .../components/dialogs/model-selector.test.ts | 19 +++++++++++++++++++ .../src/kosong/contract/errors.ts | 2 +- packages/kosong/src/errors.ts | 2 +- .../kosong/test/openai-common-errors.test.ts | 1 + 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index d80da8fd47..74469a2869 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -204,20 +204,7 @@ export class ModelSelectorComponent extends Container implements Focusable { const override = this.thinkingOverrides.get(choice.alias); if (override !== undefined) return override; if (choice.alias === this.opts.currentValue) return this.opts.currentThinkingEffort; - const efforts = effortsOf(choice.model); - if (efforts.length > 0) { - // A model with support_efforts but no default_effort defaults to the - // middle entry of its supported efforts. default_effort is trimmed - // before matching, mirroring the trimmed declared entries (effortsOf). - const declared = choice.model.defaultEffort?.trim(); - const def = - declared === undefined || declared.length === 0 - ? efforts[Math.floor(efforts.length / 2)]! - : declared; - if (efforts.includes(def)) return def; - return efforts[0]!; - } - return thinkingAvailability(choice.model) !== 'unsupported' ? 'on' : 'off'; + return defaultThinkingEffortFor(choice.model); } /** Draft coerced onto the model's segment list so rendering/selection never diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts index 85e973498e..98f1312b55 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -504,6 +504,25 @@ describe('ModelSelectorComponent', () => { expect(text(picker)).toContain('[ Medium ]'); }); + it('falls back to the middle effort when the declared defaultEffort is unlisted', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + other: effortModel('Kimi Other', ['low', 'high'], 'max'), + }, + currentValue: 'current', + currentThinkingEffort: 'off', + onSelect, + onCancel: vi.fn(), + }); + + // An unlisted default_effort is not selectable: the middle entry wins, + // matching the engine resolvers. + expect(text(picker)).toContain('[ High ]'); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ alias: 'other', thinking: 'high' }); + }); + it('trims a padded defaultEffort before matching the declared efforts', () => { const onSelect = vi.fn(); const picker = new ModelSelectorComponent({ diff --git a/packages/agent-core-v2/src/kosong/contract/errors.ts b/packages/agent-core-v2/src/kosong/contract/errors.ts index 52b99650d9..ac2df088f3 100644 --- a/packages/agent-core-v2/src/kosong/contract/errors.ts +++ b/packages/agent-core-v2/src/kosong/contract/errors.ts @@ -316,7 +316,7 @@ function appendThinkingEffortConfigHint(statusCode: number, message: string): st if (message.includes(THINKING_EFFORT_CONFIG_DOCS_URL)) return message; return `${message} -The provider rejected the configured thinking effort. Efforts outside a model's declared support_efforts fall back to the model default, except a forced effort (forced_effort or KIMI_MODEL_THINKING_EFFORT), which is always sent unchanged; models without a declared list pass efforts to non-Kimi providers unchanged. Choose an effort supported by the selected model. See ${THINKING_EFFORT_CONFIG_DOCS_URL}`; +The provider rejected the configured thinking effort. Efforts outside a model's declared support_efforts fall back to the model default, except a forced effort (forced_effort or KIMI_MODEL_THINKING_EFFORT) or an effort a running session locked in before a config reload, which are always sent unchanged; models without a declared list pass efforts to non-Kimi providers unchanged. Choose an effort supported by the selected model. See ${THINKING_EFFORT_CONFIG_DOCS_URL}`; } export function isContextOverflowErrorCode(code: string | null | undefined): boolean { diff --git a/packages/kosong/src/errors.ts b/packages/kosong/src/errors.ts index c1b3569f2e..68122f2073 100644 --- a/packages/kosong/src/errors.ts +++ b/packages/kosong/src/errors.ts @@ -412,7 +412,7 @@ function appendThinkingEffortConfigHint(statusCode: number, message: string): st if (message.includes(THINKING_EFFORT_CONFIG_DOCS_URL)) return message; return `${message} -The provider rejected the configured thinking effort. Efforts outside a model's declared support_efforts fall back to the model default, except a forced effort (forced_effort or KIMI_MODEL_THINKING_EFFORT), which is always sent unchanged; models without a declared list pass efforts to non-Kimi providers unchanged. Choose an effort supported by the selected model. See ${THINKING_EFFORT_CONFIG_DOCS_URL}`; +The provider rejected the configured thinking effort. Efforts outside a model's declared support_efforts fall back to the model default, except a forced effort (forced_effort or KIMI_MODEL_THINKING_EFFORT) or an effort a running session locked in before a config reload, which are always sent unchanged; models without a declared list pass efforts to non-Kimi providers unchanged. Choose an effort supported by the selected model. See ${THINKING_EFFORT_CONFIG_DOCS_URL}`; } export function isContextOverflowErrorCode(code: string | null | undefined): boolean { diff --git a/packages/kosong/test/openai-common-errors.test.ts b/packages/kosong/test/openai-common-errors.test.ts index 2255c6672b..5798719b8a 100644 --- a/packages/kosong/test/openai-common-errors.test.ts +++ b/packages/kosong/test/openai-common-errors.test.ts @@ -408,6 +408,7 @@ describe('normalizeAPIStatusError thinking effort guidance', () => { expect(error.message).toContain( "Efforts outside a model's declared support_efforts fall back to the model default", ); + expect(error.message).toContain('locked in before a config reload'); expect(error.message).toContain( 'https://moonshotai.github.io/kimi-code/en/configuration/config-files.html#thinking', ); From 04ad3e2cc86f9458a2bc34e3468177b850f326e2 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Fri, 21 Aug 2026 06:23:31 +0000 Subject: [PATCH 22/34] fix(agent-core): report an unlisted env thinking-effort override only once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A KIMI_MODEL_THINKING_EFFORT pin outside the declared list triggered both the apply-time override warning and the request-time stale check, so the same value was reported twice per session with different codes. ConfigState now exposes whether the current effort came from the env override, and the request-time check skips that case — the override warning already describes it. Genuine staleness from a config reload (no override) still warns at request time. --- packages/agent-core/src/agent/config/index.ts | 12 +++++++++++ packages/agent-core/src/agent/index.ts | 3 +++ .../test/agent/config-state.test.ts | 20 +++++++++++-------- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts index 1c66bba577..fce696e1cd 100644 --- a/packages/agent-core/src/agent/config/index.ts +++ b/packages/agent-core/src/agent/config/index.ts @@ -252,6 +252,18 @@ export class ConfigState { return this._thinkingEffort; } + /** + * Whether the current effort was pinned by the KIMI_MODEL_THINKING_EFFORT + * override rather than model-aware resolution. Diagnostics use this to + * avoid double-reporting the pinned value. + */ + get thinkingEffortOverridden(): boolean { + return ( + this._unforcedThinkingEffort !== undefined && + this._thinkingEffort !== this._unforcedThinkingEffort + ); + } + private get currentModel(): ModelAlias | undefined { const resolved = this.tryResolvedProviderConfig(); return this.modelForThinking(this._modelAlias, resolved); diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 98f2032c1d..144fef4e94 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -406,6 +406,9 @@ export class Agent { try { const effort = provider.thinkingEffort; if (effort === null || effort === 'on' || effort === 'off') return; + // An effort pinned by KIMI_MODEL_THINKING_EFFORT is already covered by + // the apply-time override warning — do not double-report it here. + if (this.config.thinkingEffortOverridden) return; const resolved = modelAlias === undefined ? undefined diff --git a/packages/agent-core/test/agent/config-state.test.ts b/packages/agent-core/test/agent/config-state.test.ts index 394861678a..79e13e47ed 100644 --- a/packages/agent-core/test/agent/config-state.test.ts +++ b/packages/agent-core/test/agent/config-state.test.ts @@ -266,15 +266,19 @@ describe('ConfigState model capabilities', () => { }); expect(requests).toBe(1); - expect(ctx.allEvents).toContainEqual({ - type: '[rpc]', - event: 'warning', - args: { - code: 'thinking-effort-override-not-listed', - message: - 'Thinking effort "high" is not listed for model "compatible-model" (known: max). The value will be sent unchanged to the backend.', + // The apply-time override warning is the single diagnostic for the + // pinned value — the request-time stale check must not repeat it. + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([ + { + type: '[rpc]', + event: 'warning', + args: { + code: 'thinking-effort-override-not-listed', + message: + 'Thinking effort "high" is not listed for model "compatible-model" (known: max). The value will be sent unchanged to the backend.', + }, }, - }); + ]); } finally { vi.unstubAllEnvs(); } From 63980b0f0e43de6debda58d371f5ff0514d43118 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Fri, 21 Aug 2026 07:00:39 +0000 Subject: [PATCH 23/34] fix(agent-core): recheck an env-pinned effort against reloaded declared lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The request-time dedup skipped env-pinned efforts unconditionally, so a pin that was listed when applied kept going out silently after a config reload dropped it from support_efforts. The stale check now re-checks a pinned effort against the current list and emits the override warning for it — the dedup key carries the list, so an unchanged list still reports exactly once while a narrowed list reports the pin once more. --- packages/agent-core/src/agent/index.ts | 22 ++- .../test/agent/config-state.test.ts | 145 ++++++++++++++++++ 2 files changed, 164 insertions(+), 3 deletions(-) diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 144fef4e94..3d1aa116e9 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -406,14 +406,30 @@ export class Agent { try { const effort = provider.thinkingEffort; if (effort === null || effort === 'on' || effort === 'off') return; - // An effort pinned by KIMI_MODEL_THINKING_EFFORT is already covered by - // the apply-time override warning — do not double-report it here. - if (this.config.thinkingEffortOverridden) return; const resolved = modelAlias === undefined ? undefined : this.modelProvider?.resolveProviderConfig(modelAlias); if (resolved === undefined) return; + if (this.config.thinkingEffortOverridden) { + // The pin may have been listed when applied (no warning then) and + // dropped by a later reload: re-check it against the current list + // and emit the override warning. Its dedup key carries the list, so + // an unchanged list dedups against the apply-time warning. + this.warnAboutUnlistedThinkingEffortOverride( + modelAlias, + { + provider: resolved.providerName, + model: resolved.provider.model, + maxContextSize: Math.max(resolved.modelCapabilities.max_context_tokens, 1), + supportEfforts: + resolved.supportEfforts === undefined ? undefined : [...resolved.supportEfforts], + defaultEffort: resolved.defaultEffort, + }, + effort, + ); + return; + } const supportEfforts = normalizeDeclaredEfforts(resolved.supportEfforts); if (supportEfforts.length === 0) return; if (supportEfforts.includes(effort)) return; diff --git a/packages/agent-core/test/agent/config-state.test.ts b/packages/agent-core/test/agent/config-state.test.ts index 79e13e47ed..dbb704701c 100644 --- a/packages/agent-core/test/agent/config-state.test.ts +++ b/packages/agent-core/test/agent/config-state.test.ts @@ -587,6 +587,151 @@ describe('ConfigState model capabilities', () => { }); }); + it('warns once when a reload drops the env-pinned effort from the declared list', async () => { + // The pin was listed when applied (no apply-time warning); the reload + // narrows the list to ["max"], so the next request must surface the + // override diagnostic against the new list — exactly once. + vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'high'); + try { + const compatibleModel = (supportEfforts: string[]) => ({ + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic' as const, + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts, + }); + let current: KimiConfig = { + providers: { + compatible: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test', + }, + }, + models: { compatible: compatibleModel(['high', 'max']) }, + }; + let requests = 0; + const ctx = testAgent({ + initialConfig: current, + providerManager: new ProviderManager({ config: () => current }), + generate: async (provider) => { + requests += 1; + expect(provider.thinkingEffort).toBe('high'); + return { + id: 'response-1', + message: { role: 'assistant', content: [], toolCalls: [] }, + usage: emptyUsage(), + finishReason: 'completed', + rawFinishReason: 'stop', + }; + }, + }); + ctx.agent.config.update({ + modelAlias: 'compatible', + systemPrompt: 'system', + }); + + await ctx.agent.llm.chat({ + messages: [], + tools: [], + signal: new AbortController().signal, + }); + + expect(requests).toBe(1); + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([]); + + current = { + ...current, + models: { compatible: compatibleModel(['max']) }, + }; + + await ctx.agent.llm.chat({ + messages: [], + tools: [], + signal: new AbortController().signal, + }); + + expect(requests).toBe(2); + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([ + { + type: '[rpc]', + event: 'warning', + args: { + code: 'thinking-effort-override-not-listed', + message: + 'Thinking effort "high" is not listed for model "compatible-model" (known: max). The value will be sent unchanged to the backend.', + }, + }, + ]); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('stays silent when the env-pinned effort survives a config reload', async () => { + vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'high'); + try { + const compatibleModel = (supportEfforts: string[]) => ({ + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic' as const, + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts, + }); + let current: KimiConfig = { + providers: { + compatible: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test', + }, + }, + models: { compatible: compatibleModel(['high', 'max']) }, + }; + const ctx = testAgent({ + initialConfig: current, + providerManager: new ProviderManager({ config: () => current }), + generate: async (provider) => { + expect(provider.thinkingEffort).toBe('high'); + return { + id: 'response-1', + message: { role: 'assistant', content: [], toolCalls: [] }, + usage: emptyUsage(), + finishReason: 'completed', + rawFinishReason: 'stop', + }; + }, + }); + ctx.agent.config.update({ + modelAlias: 'compatible', + systemPrompt: 'system', + }); + + await ctx.agent.llm.chat({ + messages: [], + tools: [], + signal: new AbortController().signal, + }); + + current = { + ...current, + models: { compatible: compatibleModel(['low', 'high']) }, + }; + + await ctx.agent.llm.chat({ + messages: [], + tools: [], + signal: new AbortController().signal, + }); + + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([]); + } finally { + vi.unstubAllEnvs(); + } + }); + it('uses session id as a provider prompt cache hint without storing it on Agent', () => { const ctx = testAgent({ providerManager: new ProviderManager({ From 2318d630d7959192d9b848c4c508a1174218b871 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Fri, 21 Aug 2026 07:44:54 +0000 Subject: [PATCH 24/34] fix(kimi-code): hydrate unlisted configured thinking efforts to the model default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session-less startup copied a configured thinking.effort into appState verbatim, so a value outside the model's declared support_efforts was coerced to the first picker segment ('off') — merely confirming the current model turned thinking off, while the engine would have fallen back to the declared default at session creation. Hydration now mirrors the engine rule through a shared TUI helper: 'on' or an unlisted value becomes the model's default effort when a list is declared; listed values and list-less models pass through. --- .../tui/components/dialogs/model-selector.ts | 16 ++++++ apps/kimi-code/src/tui/kimi-tui.ts | 28 +++++++--- .../test/tui/kimi-tui-message-flow.test.ts | 54 +++++++++++++++++++ 3 files changed, 92 insertions(+), 6 deletions(-) diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index 74469a2869..2acb02f6b9 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -154,6 +154,22 @@ export function defaultThinkingEffortFor(model: ModelAlias): ThinkingEffort { return 'on'; } +/** + * Mirror of the engine's configured-effort resolution, for session-less + * display state: with a declared effort list, `'on'` or an unlisted value + * becomes the model's default effort; listed values (and anything, when no + * list is declared) pass through. `'off'` always stays `'off'`. + */ +export function resolveConfiguredEffortForModel( + effort: ThinkingEffort, + model: ModelAlias, +): ThinkingEffort { + const efforts = effortsOf(model); + if (efforts.length === 0 || effort === 'off') return effort; + if (effort !== 'on' && efforts.includes(effort)) return effort; + return defaultThinkingEffortFor(model); +} + /** * Normalize a draft effort before committing a selection. A boolean `'on'` * never leaks past the UI boundary — it becomes the model's default effort diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index cb0231d4c0..e430912ffe 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -74,7 +74,10 @@ import { } from './components/dialogs/approval-preview'; import { CompactionComponent } from './components/dialogs/compaction'; import { HelpPanelComponent } from './components/dialogs/help-panel'; -import { defaultThinkingEffortFor } from './components/dialogs/model-selector'; +import { + defaultThinkingEffortFor, + resolveConfiguredEffortForModel, +} from './components/dialogs/model-selector'; import { QuestionDialogComponent } from './components/dialogs/question-dialog'; import { SessionPickerComponent, type SessionRow } from './components/dialogs/session-picker'; import { TrustPromptComponent, type TrustPromptChoice } from './components/dialogs/trust-prompt'; @@ -2154,16 +2157,29 @@ export class KimiTUI { patch.planMode = config.defaultPlanMode === true; } const effort = thinkingEffortFromConfig(config.thinking); + const startupModelConfig = + startupModel === undefined ? undefined : config.models?.[startupModel]; + const startupProviderType = + startupModelConfig === undefined + ? undefined + : (config.providers?.[startupModelConfig.provider]?.type ?? startupModelConfig.protocol); if (effort !== undefined) { - patch.thinkingEffort = effort; + // A configured effort outside the model's declared list falls back to + // the declared default at session creation — hydrate the same way so + // the footer and the picker never coerce it to 'off'. + patch.thinkingEffort = + startupModelConfig === undefined + ? effort + : resolveConfiguredEffortForModel( + effort, + effectiveModelAlias(startupModelConfig, startupProviderType), + ); } else if (startupModel !== undefined) { // No concrete effort configured: mirror the engine, which resolves the // model's default effort at createSession time. - const raw = config.models?.[startupModel]; - if (raw !== undefined) { - const providerType = config.providers?.[raw.provider]?.type; + if (startupModelConfig !== undefined) { patch.thinkingEffort = defaultThinkingEffortFor( - effectiveModelAlias(raw, providerType ?? raw.protocol), + effectiveModelAlias(startupModelConfig, startupProviderType), ); } } diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index ada357ba76..6d800959b1 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -8270,6 +8270,60 @@ describe('/effort support_efforts override', () => { ); }); + it('hydrates an unlisted configured effort to the declared default (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const setConfig = vi.fn(async () => ({})); + const { driver } = await makeDriver( + session, + { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'kimi', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 100, + displayName: 'Compatible Model', + capabilities: ['thinking'], + supportEfforts: ['low', 'xhigh'], + defaultEffort: 'xhigh', + }, + }, + defaultModel: 'k2', + thinking: { enabled: true, effort: 'high' }, + })), + setConfig, + }, + startupInput, + ); + + // "high" is outside the declared list: hydration mirrors the engine + // fallback instead of copying the configured value verbatim. + expect(driver.state.appState.thinkingEffort).toBe('xhigh'); + + // Confirming the current model in the picker must not turn thinking off. + driver.handleUserInput('/model'); + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); + }); + (driver.state.editorContainer.children[0] as TabbedModelSelectorComponent).handleInput('\r'); + + await vi.waitFor(() => { + expect(renderTranscript(driver)).toContain( + 'Already using Compatible Model with thinking xhigh.', + ); + }); + expect(setConfig).not.toHaveBeenCalled(); + }); + it('persists only the enabled flag when a padded list top tier is selected', async () => { const session = makeSession(); const setConfig = vi.fn(async () => ({})); From f8bbc7a3b6bb160dc9737fb16d2d32b9d82b9e29 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Fri, 21 Aug 2026 08:42:05 +0000 Subject: [PATCH 25/34] fix: normalize hydrated efforts and warn when a reload strands the stored effort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session-less hydration helper compared the raw configured effort against the declared list, so a padded or upper-case value fell back to the default instead of matching its trimmed form. And on the v2 engine a config reload that drops the stored effort from the declared list silently fell back to the new default — the profile getter re-resolves live but nothing reported the correction. The helper now trims and lowercases before matching, and the v2 profile service re-checks the persisted effort whenever the model records change, emitting the one-time fallback warning with the resolved default. --- .../tui/components/dialogs/model-selector.ts | 8 +++-- .../components/dialogs/model-selector.test.ts | 13 ++++++- .../src/agent/profile/profileService.ts | 7 ++++ .../test/agent/profile/config-state.test.ts | 36 +++++++++++++++++++ .../test/agent/profile/profileOps.test.ts | 6 ++++ .../agentLifecycle/agentLifecycle.test.ts | 6 ++++ 6 files changed, 73 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index 2acb02f6b9..9e1356dd05 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -164,9 +164,13 @@ export function resolveConfiguredEffortForModel( effort: ThinkingEffort, model: ModelAlias, ): ThinkingEffort { + // Normalize like the engine does before any comparison (trim + lowercase); + // a blank value reads as unconfigured and resolves to the model default. + const normalized = effort.trim().toLowerCase(); + if (normalized.length === 0) return defaultThinkingEffortFor(model); const efforts = effortsOf(model); - if (efforts.length === 0 || effort === 'off') return effort; - if (effort !== 'on' && efforts.includes(effort)) return effort; + if (efforts.length === 0 || normalized === 'off') return normalized; + if (normalized !== 'on' && efforts.includes(normalized)) return normalized; return defaultThinkingEffortFor(model); } diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts index 98f1312b55..3d8deba886 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -2,7 +2,11 @@ import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it, vi } from 'vitest'; -import { ModelSelectorComponent, defaultThinkingEffortFor } from '#/tui/components/dialogs/model-selector'; +import { + ModelSelectorComponent, + defaultThinkingEffortFor, + resolveConfiguredEffortForModel, +} from '#/tui/components/dialogs/model-selector'; import { currentTheme } from '#/tui/theme'; import { darkColors } from '#/tui/theme/colors'; @@ -550,6 +554,13 @@ describe('ModelSelectorComponent', () => { ); }); + it('normalizes a padded configured effort before matching the declared list', () => { + const declared = effortModel('Kimi Other', ['low', 'high', 'max'], 'high'); + expect(resolveConfiguredEffortForModel(' LOW ', declared)).toBe('low'); + expect(resolveConfiguredEffortForModel(' ', declared)).toBe('high'); + expect(resolveConfiguredEffortForModel(' ULTRA ', declared)).toBe('high'); + }); + it('falls back to the middle entry when the declared default is not listed', () => { // Matches the engine resolvers: an unlisted default_effort is rejected. expect(defaultThinkingEffortFor(effortModel('Kimi Other', ['low', 'high'], 'max', []))).toBe( diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 9cef43890c..87d96f852d 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -5,6 +5,7 @@ import { defineState } from '#/state/state'; import { UNKNOWN_CAPABILITY, type ModelCapability } from '#/kosong/contract/capability'; import { type SamplingOptions, type ThinkingEffort } from '#/kosong/contract/provider'; import { IModelCatalog, type Model } from '#/kosong/model/catalog'; +import { IModelService } from '#/kosong/model/model'; import { type ModelOverrides } from '#/kosong/model/model.types'; import { type ModelRequestParams } from '#/kosong/model/modelRequester'; import { IProtocolAdapterRegistry } from '#/kosong/protocol/protocol'; @@ -151,6 +152,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ @IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService, @IConfigService private readonly config: IConfigService, @IModelCatalog private readonly modelCatalog: IModelCatalog, + @IModelService private readonly models: IModelService, @IProtocolAdapterRegistry private readonly protocolAdapters: IProtocolAdapterRegistry, @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, @IHostClock private readonly clock: IHostClock, @@ -197,6 +199,11 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ } }), ); + this._register( + this.models.onDidChangeModels(() => { + this.warnAboutThinkingEffortFallback(this.profileState.thinkingLevel); + }), + ); this._register( this.skillCatalog.onDidChange((sourceId) => { if (sourceId === BUILTIN_SKILL_SOURCE_ID) { diff --git a/packages/agent-core-v2/test/agent/profile/config-state.test.ts b/packages/agent-core-v2/test/agent/profile/config-state.test.ts index c6d3216a3d..45fd902469 100644 --- a/packages/agent-core-v2/test/agent/profile/config-state.test.ts +++ b/packages/agent-core-v2/test/agent/profile/config-state.test.ts @@ -397,6 +397,42 @@ describe('ConfigState thinking clamp for always-thinking models', () => { expect(profile.data().thinkingLevel).toBe('max'); }); + it('warns once when a model metadata reload strands the stored effort', async () => { + profile.update({ modelAlias: 'kimi-code/ultra', thinkingLevel: 'high' }); + expect(profile.data().thinkingLevel).toBe('high'); + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([]); + + kimiConfig = { + ...kimiConfig, + models: { + ...kimiConfig.models, + 'kimi-code/ultra': { + provider: 'kimi', + model: 'kimi-ultra', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'ultra'], + defaultEffort: 'ultra', + }, + }, + }; + + await vi.waitFor(() => { + expect(profile.data().thinkingLevel).toBe('ultra'); + }); + await vi.waitFor(() => { + expect(ctx.allEvents).toContainEqual({ + type: '[rpc]', + event: 'warning', + args: expect.objectContaining({ + code: 'thinking-effort-not-listed', + message: + 'Thinking effort "high" is not listed for model "kimi-ultra" (known: low, ultra). Falling back to the model\'s default effort "ultra".', + }), + }); + }); + }); + it('projects an inherited concrete effort to on when switching to a boolean model', () => { profile.update({ modelAlias: 'kimi-code/ultra', thinkingLevel: 'ultra' }); diff --git a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts index 0f04bee73a..ef71833c79 100644 --- a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts +++ b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts @@ -16,6 +16,7 @@ import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalo import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { IModelCatalog, type Model } from '#/kosong/model/catalog'; +import { IModelService } from '#/kosong/model/model'; import { IProtocolAdapterRegistry, type Protocol } from '#/kosong/protocol/protocol'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; @@ -205,6 +206,11 @@ function buildHost(key: string): { ); host.stub(IConfigService, createConfigStub()); host.stub(IModelCatalog, modelCatalog); + host.stub(IModelService, { + _serviceBrand: undefined, + onDidChangeModels: Event.None, + onDidChangeDefaultModel: Event.None, + } as unknown as IModelService); host.stub(IProtocolAdapterRegistry, createProtocolRegistryStub()); host.stub(IHostEnvironment, stubUnused()); host.stub(IHostFileSystem, stubUnused()); diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index 39cf68cb2b..714772b963 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -32,6 +32,7 @@ import { agentContextOf } from '#/agent/scopeContext/scopeContext'; import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IBuiltinAgentProfileLoader } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; import { IModelCatalog } from '#/kosong/model/catalog'; +import { IModelService } from '#/kosong/model/model'; import type { ToolCall } from '#/kosong/contract/message'; import { IProtocolAdapterRegistry } from '#/kosong/protocol/protocol'; import { IHostClock } from '#/os/interface/hostClock'; @@ -367,6 +368,11 @@ describe('AgentLifecycleService', () => { ix.stub(IHostFileSystem, { _serviceBrand: undefined } as IHostFileSystem); ix.stub(IHostClock, { _serviceBrand: undefined } as IHostClock); ix.stub(IModelCatalog, { _serviceBrand: undefined } as IModelCatalog); + ix.stub(IModelService, { + _serviceBrand: undefined, + onDidChangeModels: Event.None, + onDidChangeDefaultModel: Event.None, + } as unknown as IModelService); ix.stub(IFlagService, stubFlag()); ix.stub(IProtocolAdapterRegistry, { _serviceBrand: undefined, From f468c16a634895f7704cea89d13032086aa62c92 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Fri, 21 Aug 2026 09:25:19 +0000 Subject: [PATCH 26/34] fix(agent-core-v2): republish status on reload fallback and watch provider changes The model-change listener warned about a stranded stored effort but never republished the agent status, so clients kept showing the stale effort and a picker confirmation could persist 'off'. And provider-only changes (which alter the inferred effort list without touching model records) bypassed the listener entirely. The revalidation now runs on both model and provider record changes, emits the fallback warning once per distinct list, and republishes the status with the resolved effort only when a fallback actually fired. --- .../src/agent/profile/profileService.ts | 27 ++++-- .../test/agent/profile/config-state.test.ts | 83 +++++++++++++++++++ .../test/agent/profile/profileOps.test.ts | 5 ++ .../agentLifecycle/agentLifecycle.test.ts | 5 ++ 4 files changed, 114 insertions(+), 6 deletions(-) diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 87d96f852d..16efe42de7 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -6,6 +6,7 @@ import { UNKNOWN_CAPABILITY, type ModelCapability } from '#/kosong/contract/capa import { type SamplingOptions, type ThinkingEffort } from '#/kosong/contract/provider'; import { IModelCatalog, type Model } from '#/kosong/model/catalog'; import { IModelService } from '#/kosong/model/model'; +import { IProviderService } from '#/kosong/provider/provider'; import { type ModelOverrides } from '#/kosong/model/model.types'; import { type ModelRequestParams } from '#/kosong/model/modelRequester'; import { IProtocolAdapterRegistry } from '#/kosong/protocol/protocol'; @@ -153,6 +154,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ @IConfigService private readonly config: IConfigService, @IModelCatalog private readonly modelCatalog: IModelCatalog, @IModelService private readonly models: IModelService, + @IProviderService private readonly providers: IProviderService, @IProtocolAdapterRegistry private readonly protocolAdapters: IProtocolAdapterRegistry, @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, @IHostClock private readonly clock: IHostClock, @@ -201,7 +203,12 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ ); this._register( this.models.onDidChangeModels(() => { - this.warnAboutThinkingEffortFallback(this.profileState.thinkingLevel); + this.revalidateStoredThinkingEffort(); + }), + ); + this._register( + this.providers.onDidChangeProviders(() => { + this.revalidateStoredThinkingEffort(); }), ); this._register( @@ -630,10 +637,16 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ ); } - private warnAboutThinkingEffortFallback(requested: string | undefined): void { + private revalidateStoredThinkingEffort(): void { + if (this.warnAboutThinkingEffortFallback(this.profileState.thinkingLevel)) { + this.emitStatusUpdated(true); + } + } + + private warnAboutThinkingEffortFallback(requested: string | undefined): boolean { try { const model = this.tryResolveRawModel(); - if (model === undefined) return; + if (model === undefined) return false; const thinking = this.config.get(THINKING_SECTION); const { effort, fallback } = resolveThinkingEffortForModelWithFallback( requested, @@ -641,22 +654,24 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ model, this.strictThinkingValidation(model), ); - if (fallback === undefined) return; + if (fallback === undefined) return false; const forced = resolveForcedThinkingEffort( thinking?.forcedEffort, effort, drivesThinkingThroughTraits(model.providerType), ); - if (forced !== undefined) return; + if (forced !== undefined) return false; const efforts = declaredThinkingEfforts(model); const knownEfforts = efforts.join(','); const code = 'thinking-effort-not-listed'; const message = `Thinking effort "${fallback.configured}" is not listed for model "${model.name}" (known: ${efforts.join(', ')}). Falling back to the model's default effort "${fallback.resolved}".`; const key = [code, model.id, model.name, fallback.configured, knownEfforts].join('\u0000'); - if (this.emittedThinkingEffortWarnings.has(key)) return; + if (this.emittedThinkingEffortWarnings.has(key)) return false; this.emittedThinkingEffortWarnings.add(key); void this.dispatcher.dispatch(new WarningIssued({ agentId: this.scopeContext.agentId, code, message })); + return true; } catch { + return false; } } diff --git a/packages/agent-core-v2/test/agent/profile/config-state.test.ts b/packages/agent-core-v2/test/agent/profile/config-state.test.ts index 45fd902469..f202c4baa3 100644 --- a/packages/agent-core-v2/test/agent/profile/config-state.test.ts +++ b/packages/agent-core-v2/test/agent/profile/config-state.test.ts @@ -431,6 +431,89 @@ describe('ConfigState thinking clamp for always-thinking models', () => { }), }); }); + await vi.waitFor(() => { + const statuses = ctx.allEvents.filter((event) => event.event === 'agent.status.updated'); + expect(statuses.at(-1)?.args).toMatchObject({ thinkingEffort: 'ultra' }); + }); + }); + + it('warns once when a provider-only reload changes the inferred effort list', async () => { + kimiConfig = { + providers: { + compat: { type: 'openai', apiKey: 'test-key', baseUrl: 'https://api.example.test/v1' }, + }, + models: { + 'compat/claude': { + provider: 'compat', + model: 'joint-claude-custom', + maxContextSize: 128_000, + capabilities: ['thinking'], + }, + }, + }; + profile.update({ modelAlias: 'compat/claude', thinkingLevel: 'ultra' }); + expect(profile.data().thinkingLevel).toBe('ultra'); + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([]); + + kimiConfig = { + ...kimiConfig, + providers: { + compat: { type: 'anthropic', apiKey: 'test-key', baseUrl: 'https://api.example.test/v1' }, + }, + }; + + await vi.waitFor(() => { + expect(profile.data().thinkingLevel).toBe('high'); + }); + await vi.waitFor(() => { + expect(ctx.allEvents).toContainEqual({ + type: '[rpc]', + event: 'warning', + args: expect.objectContaining({ + code: 'thinking-effort-not-listed', + message: + 'Thinking effort "ultra" is not listed for model "joint-claude-custom" (known: low, medium, high, xhigh, max). Falling back to the model\'s default effort "high".', + }), + }); + }); + + kimiConfig = { + providers: { + compat: { type: 'anthropic', apiKey: 'test-key', baseUrl: 'https://api.example.test/v2' }, + }, + models: { + 'compat/claude': { + provider: 'compat', + model: 'joint-claude-custom', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + }, + }, + }; + + await vi.waitFor(() => { + expect(profile.data().thinkingLevel).toBe('high'); + }); + await vi.waitFor(() => { + expect(ctx.allEvents).toContainEqual({ + type: '[rpc]', + event: 'warning', + args: expect.objectContaining({ + code: 'thinking-effort-not-listed', + message: + 'Thinking effort "ultra" is not listed for model "joint-claude-custom" (known: low, high). Falling back to the model\'s default effort "high".', + }), + }); + }); + for (let i = 0; i < 10; i++) await new Promise((resolve) => setImmediate(resolve)); + expect( + ctx.allEvents.filter( + (event) => + event.event === 'warning' && + (event.args as { code?: string }).code === 'thinking-effort-not-listed', + ), + ).toHaveLength(2); }); it('projects an inherited concrete effort to on when switching to a boolean model', () => { diff --git a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts index ef71833c79..198a0bffee 100644 --- a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts +++ b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts @@ -17,6 +17,7 @@ import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { IModelCatalog, type Model } from '#/kosong/model/catalog'; import { IModelService } from '#/kosong/model/model'; +import { IProviderService } from '#/kosong/provider/provider'; import { IProtocolAdapterRegistry, type Protocol } from '#/kosong/protocol/protocol'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; @@ -211,6 +212,10 @@ function buildHost(key: string): { onDidChangeModels: Event.None, onDidChangeDefaultModel: Event.None, } as unknown as IModelService); + host.stub(IProviderService, { + _serviceBrand: undefined, + onDidChangeProviders: Event.None, + } as unknown as IProviderService); host.stub(IProtocolAdapterRegistry, createProtocolRegistryStub()); host.stub(IHostEnvironment, stubUnused()); host.stub(IHostFileSystem, stubUnused()); diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index 714772b963..656bd5e5eb 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -33,6 +33,7 @@ import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IBuiltinAgentProfileLoader } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; import { IModelCatalog } from '#/kosong/model/catalog'; import { IModelService } from '#/kosong/model/model'; +import { IProviderService } from '#/kosong/provider/provider'; import type { ToolCall } from '#/kosong/contract/message'; import { IProtocolAdapterRegistry } from '#/kosong/protocol/protocol'; import { IHostClock } from '#/os/interface/hostClock'; @@ -373,6 +374,10 @@ describe('AgentLifecycleService', () => { onDidChangeModels: Event.None, onDidChangeDefaultModel: Event.None, } as unknown as IModelService); + ix.stub(IProviderService, { + _serviceBrand: undefined, + onDidChangeProviders: Event.None, + } as unknown as IProviderService); ix.stub(IFlagService, stubFlag()); ix.stub(IProtocolAdapterRegistry, { _serviceBrand: undefined, From 134c1f6a309331e659a78023da92c5d2a1a55df3 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Fri, 21 Aug 2026 10:27:48 +0000 Subject: [PATCH 27/34] fix: republish status on effort restoration and match declared efforts case-insensitively The reload revalidation only republished the agent status when the fallback warning fired, so a reload that made a stranded effort valid again left clients showing the fallback value. It now compares the effective effort across the event and republishes on any change. Separately, declared support_efforts entries kept their declared casing while inputs are lowercased, so a mixed-case declaration turned a legal request into a fallback to the wrong tier; matching is now case-insensitive in both engines and the TUI, resolving to the declared canonical form that the backend recognizes. --- apps/kimi-code/src/tui/commands/config.ts | 5 +- .../tui/components/dialogs/model-selector.ts | 21 +++++++-- .../components/dialogs/model-selector.test.ts | 6 +++ .../test/tui/kimi-tui-message-flow.test.ts | 34 ++++++++++++++ .../src/agent/profile/profileService.ts | 23 ++++++---- .../src/kosong/model/thinking.ts | 33 +++++++------ .../test/agent/profile/config-state.test.ts | 46 +++++++++++++++++++ .../test/kosong/model/thinking.test.ts | 15 ++++++ .../agent-core/src/agent/config/thinking.ts | 41 +++++++++++------ .../test/agent/config/thinking.test.ts | 13 ++++++ 10 files changed, 194 insertions(+), 43 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 51b7a51477..b59fc18bad 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -316,7 +316,8 @@ export async function handleEffortCommand(host: SlashCommandHost, args: string): showEffortPicker(host, effective, segments); return; } - if (!segments.includes(arg)) { + const canonical = segments.find((segment) => segment.toLowerCase() === arg); + if (canonical === undefined) { const declared = effortsOf(effective); // 'on' is the generic enable signal: with a declared effort list the // engine maps it to the declared default effort, so it stays a valid @@ -343,7 +344,7 @@ export async function handleEffortCommand(host: SlashCommandHost, args: string): 'warning', ); } - await performModelSwitch(host, alias, arg, true); + await performModelSwitch(host, alias, canonical ?? arg, true); } function showEffortPicker( diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index 9e1356dd05..3204c08908 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -146,14 +146,24 @@ export function defaultThinkingEffortFor(model: ModelAlias): ThinkingEffort { const efforts = effortsOf(model); if (efforts.length > 0) { const declared = model.defaultEffort?.trim(); - return declared !== undefined && declared.length > 0 && efforts.includes(declared) - ? declared - : efforts[Math.floor(efforts.length / 2)]!; + const matched = declared === undefined ? undefined : matchDeclaredEffort(efforts, declared); + return (matched ?? efforts[Math.floor(efforts.length / 2)]!) as ThinkingEffort; } if (thinkingAvailability(model) === 'unsupported') return 'off'; return 'on'; } +/** + * Case-insensitive membership against the declared list, returning the + * declared (canonical) entry — the backend recognizes the declared casing. + */ +function matchDeclaredEffort( + efforts: readonly string[], + effort: string, +): string | undefined { + return efforts.find((candidate) => candidate.toLowerCase() === effort.toLowerCase()); +} + /** * Mirror of the engine's configured-effort resolution, for session-less * display state: with a declared effort list, `'on'` or an unlisted value @@ -170,7 +180,10 @@ export function resolveConfiguredEffortForModel( if (normalized.length === 0) return defaultThinkingEffortFor(model); const efforts = effortsOf(model); if (efforts.length === 0 || normalized === 'off') return normalized; - if (normalized !== 'on' && efforts.includes(normalized)) return normalized; + if (normalized !== 'on') { + const matched = matchDeclaredEffort(efforts, normalized); + if (matched !== undefined) return matched as ThinkingEffort; + } return defaultThinkingEffortFor(model); } diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts index 3d8deba886..5a0a80181d 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -561,6 +561,12 @@ describe('ModelSelectorComponent', () => { expect(resolveConfiguredEffortForModel(' ULTRA ', declared)).toBe('high'); }); + it('matches declared efforts case-insensitively and resolves the declared casing', () => { + const declared = effortModel('Kimi Other', ['Low', 'High', 'Max'], 'max'); + expect(resolveConfiguredEffortForModel('low', declared)).toBe('Low'); + expect(defaultThinkingEffortFor(declared)).toBe('Max'); + }); + it('falls back to the middle entry when the declared default is not listed', () => { // Matches the engine resolvers: an unlisted default_effort is rejected. expect(defaultThinkingEffortFor(effortModel('Kimi Other', ['low', 'high'], 'max', []))).toBe( diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 6d800959b1..9e3386a9cb 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -8324,6 +8324,40 @@ describe('/effort support_efforts override', () => { expect(setConfig).not.toHaveBeenCalled(); }); + it('accepts a case-insensitive /effort match and applies the declared casing', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'kimi', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 100, + displayName: 'Compatible Model', + capabilities: ['thinking'], + supportEfforts: ['Low', 'High'], + defaultEffort: 'High', + }, + }, + defaultModel: 'k2', + thinking: { enabled: true, effort: 'low' }, + })), + }); + + driver.handleUserInput('/effort low'); + + await vi.waitFor(() => { + expect(session.setThinking).toHaveBeenCalledWith('Low'); + }); + await vi.waitFor(() => { + expect(renderTranscript(driver)).toContain('Thinking set to Low.'); + }); + }); + it('persists only the enabled flag when a padded list top tier is selected', async () => { const session = makeSession(); const setConfig = vi.fn(async () => ({})); diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 16efe42de7..c7a967ff48 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -638,15 +638,18 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ } private revalidateStoredThinkingEffort(): void { - if (this.warnAboutThinkingEffortFallback(this.profileState.thinkingLevel)) { + const before = this.lastResolvedThinkingEffort; + this.warnAboutThinkingEffortFallback(this.profileState.thinkingLevel); + const after = this.getEffectiveThinkingLevel(); + if (before !== undefined && after !== before) { this.emitStatusUpdated(true); } } - private warnAboutThinkingEffortFallback(requested: string | undefined): boolean { + private warnAboutThinkingEffortFallback(requested: string | undefined): void { try { const model = this.tryResolveRawModel(); - if (model === undefined) return false; + if (model === undefined) return; const thinking = this.config.get(THINKING_SECTION); const { effort, fallback } = resolveThinkingEffortForModelWithFallback( requested, @@ -654,24 +657,22 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ model, this.strictThinkingValidation(model), ); - if (fallback === undefined) return false; + if (fallback === undefined) return; const forced = resolveForcedThinkingEffort( thinking?.forcedEffort, effort, drivesThinkingThroughTraits(model.providerType), ); - if (forced !== undefined) return false; + if (forced !== undefined) return; const efforts = declaredThinkingEfforts(model); const knownEfforts = efforts.join(','); const code = 'thinking-effort-not-listed'; const message = `Thinking effort "${fallback.configured}" is not listed for model "${model.name}" (known: ${efforts.join(', ')}). Falling back to the model's default effort "${fallback.resolved}".`; const key = [code, model.id, model.name, fallback.configured, knownEfforts].join('\u0000'); - if (this.emittedThinkingEffortWarnings.has(key)) return false; + if (this.emittedThinkingEffortWarnings.has(key)) return; this.emittedThinkingEffortWarnings.add(key); void this.dispatcher.dispatch(new WarningIssued({ agentId: this.scopeContext.agentId, code, message })); - return true; } catch { - return false; } } @@ -741,6 +742,8 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ return this.resolveThinkingEffort(this.profileState.thinkingLevel, this.tryResolveRawModel()); } + private lastResolvedThinkingEffort: ThinkingEffort | undefined; + private resolveThinkingState(model: Model | undefined): { readonly effective: ThinkingEffort; readonly forced: ThinkingEffort | undefined; @@ -751,7 +754,9 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ base, drivesThinkingThroughTraits(model?.providerType), ); - return { effective: forced ?? base, forced }; + const effective = forced ?? base; + this.lastResolvedThinkingEffort = effective; + return { effective, forced }; } private strictThinkingValidation(model: Model | undefined): boolean { diff --git a/packages/agent-core-v2/src/kosong/model/thinking.ts b/packages/agent-core-v2/src/kosong/model/thinking.ts index 69804de23f..a6277af9c7 100644 --- a/packages/agent-core-v2/src/kosong/model/thinking.ts +++ b/packages/agent-core-v2/src/kosong/model/thinking.ts @@ -117,9 +117,18 @@ function declaredDefaultEffortFor( efforts: readonly string[], ): ThinkingEffort { const declaredDefault = nonEmpty(model?.defaultEffort); - return (declaredDefault !== undefined && efforts.includes(declaredDefault) - ? declaredDefault - : middleOf(efforts)) as ThinkingEffort; + if (declaredDefault !== undefined) { + const matched = matchDeclaredEffort(efforts, declaredDefault); + if (matched !== undefined) return matched as ThinkingEffort; + } + return middleOf(efforts) as ThinkingEffort; +} + +function matchDeclaredEffort( + efforts: readonly string[], + effort: string, +): string | undefined { + return efforts.find((candidate) => candidate.toLowerCase() === effort.toLowerCase()); } export function modelSupportsThinking(model: ModelThinkingMetadata | undefined): boolean { @@ -148,7 +157,7 @@ export function modelSupportsThinkingEffort( ): boolean { if (!strictValidation || effort === 'off') return true; const efforts = effortsFor(model); - if (efforts.length > 0) return effort === 'on' || efforts.includes(effort); + if (efforts.length > 0) return effort === 'on' || matchDeclaredEffort(efforts, effort) !== undefined; return modelSupportsThinking(model); } @@ -161,16 +170,14 @@ function normalizeThinkingEffortForModel( const efforts = effortsFor(model); if (!strictValidation) { if (efforts.length === 0) return effort; - if (effort === 'on' || !efforts.includes(effort)) { - return declaredDefaultEffortFor(model, efforts); - } - return effort; + if (effort === 'on') return declaredDefaultEffortFor(model, efforts); + return (matchDeclaredEffort(efforts, effort) ?? + declaredDefaultEffortFor(model, efforts)) as ThinkingEffort; } if (efforts.length > 0) { - if (effort === 'on' || !efforts.includes(effort)) { - return declaredDefaultEffortFor(model, efforts); - } - return effort; + if (effort === 'on') return declaredDefaultEffortFor(model, efforts); + return (matchDeclaredEffort(efforts, effort) ?? + declaredDefaultEffortFor(model, efforts)) as ThinkingEffort; } if (!modelSupportsThinking(model)) return 'off'; return 'on'; @@ -207,7 +214,7 @@ export function resolveThinkingEffortForModelWithFallback( const resolved = normalizeThinkingEffortForModel(effort, model, strictValidation); const efforts = effortsFor(model); const fallback: ThinkingEffortFallback | undefined = - effort !== 'on' && effort !== 'off' && efforts.length > 0 && !efforts.includes(effort) + effort !== 'on' && effort !== 'off' && efforts.length > 0 && matchDeclaredEffort(efforts, effort) === undefined ? { configured: effort, resolved } : undefined; return { effort: resolved, fallback }; diff --git a/packages/agent-core-v2/test/agent/profile/config-state.test.ts b/packages/agent-core-v2/test/agent/profile/config-state.test.ts index f202c4baa3..b701fad52e 100644 --- a/packages/agent-core-v2/test/agent/profile/config-state.test.ts +++ b/packages/agent-core-v2/test/agent/profile/config-state.test.ts @@ -437,6 +437,52 @@ describe('ConfigState thinking clamp for always-thinking models', () => { }); }); + it('republishes the status when a reload makes a stranded effort valid again', async () => { + profile.update({ modelAlias: 'kimi-code/ultra', thinkingLevel: 'high' }); + expect(profile.data().thinkingLevel).toBe('high'); + + const withUltraEfforts = (supportEfforts: string[]) => ({ + ...kimiConfig, + models: { + ...kimiConfig.models, + 'kimi-code/ultra': { + provider: 'kimi', + model: 'kimi-ultra', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts, + defaultEffort: 'ultra', + }, + }, + }); + + kimiConfig = withUltraEfforts(['low', 'ultra']); + await vi.waitFor(() => { + expect(profile.data().thinkingLevel).toBe('ultra'); + }); + await vi.waitFor(() => { + const statuses = ctx.allEvents.filter((event) => event.event === 'agent.status.updated'); + expect(statuses.at(-1)?.args).toMatchObject({ thinkingEffort: 'ultra' }); + }); + + kimiConfig = withUltraEfforts(['low', 'high', 'ultra']); + await vi.waitFor(() => { + expect(profile.data().thinkingLevel).toBe('high'); + }); + await vi.waitFor(() => { + const statuses = ctx.allEvents.filter((event) => event.event === 'agent.status.updated'); + expect(statuses.at(-1)?.args).toMatchObject({ thinkingEffort: 'high' }); + }); + for (let i = 0; i < 10; i++) await new Promise((resolve) => setImmediate(resolve)); + expect( + ctx.allEvents.filter( + (event) => + event.event === 'warning' && + (event.args as { code?: string }).code === 'thinking-effort-not-listed', + ), + ).toHaveLength(1); + }); + it('warns once when a provider-only reload changes the inferred effort list', async () => { kimiConfig = { providers: { diff --git a/packages/agent-core-v2/test/kosong/model/thinking.test.ts b/packages/agent-core-v2/test/kosong/model/thinking.test.ts index c6ba2862f3..f1176185ce 100644 --- a/packages/agent-core-v2/test/kosong/model/thinking.test.ts +++ b/packages/agent-core-v2/test/kosong/model/thinking.test.ts @@ -165,6 +165,21 @@ describe('resolveThinkingEffortForModel', () => { ).toBe('xhigh'); }); + it('matches declared efforts case-insensitively and resolves the declared casing', () => { + const declared = { + capabilities: ['thinking'], + supportEfforts: ['Low', 'High', 'Max'], + defaultEffort: 'max', + }; + expect(resolveThinkingEffortForModel('low', undefined, declared, true)).toBe('Low'); + expect(resolveThinkingEffortForModel(undefined, { effort: 'high' }, declared, false)).toBe( + 'High', + ); + expect(defaultThinkingEffortForModel(declared)).toBe('Max'); + expect(modelSupportsThinkingEffort('low', declared, true)).toBe(true); + expect(resolveThinkingEffortForModel('ultra', undefined, declared, false)).toBe('Max'); + }); + it('keeps always-thinking models on under kimi semantics', () => { const always = { capabilities: ['always_thinking'], diff --git a/packages/agent-core/src/agent/config/thinking.ts b/packages/agent-core/src/agent/config/thinking.ts index fc2a3ea887..0b160488ab 100644 --- a/packages/agent-core/src/agent/config/thinking.ts +++ b/packages/agent-core/src/agent/config/thinking.ts @@ -49,11 +49,23 @@ function declaredDefaultEffortFor( efforts: readonly string[], ): ThinkingEffort { const declaredDefault = model?.defaultEffort?.trim(); - return declaredDefault !== undefined && - declaredDefault.length > 0 && - efforts.includes(declaredDefault) - ? declaredDefault - : middleOf(efforts); + if (declaredDefault !== undefined && declaredDefault.length > 0) { + const matched = matchDeclaredEffort(efforts, declaredDefault); + if (matched !== undefined) return matched; + } + return middleOf(efforts); +} + +/** + * Case-insensitive membership against the declared list, returning the + * declared (canonical) entry — backends recognize the declared casing, so the + * canonical form is what goes on the wire. + */ +function matchDeclaredEffort( + efforts: readonly string[], + effort: string, +): string | undefined { + return efforts.find((candidate) => candidate.toLowerCase() === effort.toLowerCase()); } /** @@ -85,7 +97,7 @@ export function supportsThinkingEffort( const efforts = effortsFor(effective); // A declared effort list is itself a declaration of thinking support: list // membership decides, even when the thinking capability was omitted. - if (efforts.length > 0) return effort === 'on' || efforts.includes(effort); + if (efforts.length > 0) return effort === 'on' || matchDeclaredEffort(efforts, effort) !== undefined; return supportsThinking(effective); } @@ -105,16 +117,12 @@ function normalizeThinkingEffortForModel( // no effort list — with a declared list, an unlisted effort is a config // mistake the backend would reject, so fall back like the Kimi wire does. if (efforts.length === 0) return effort; - if (effort === 'on' || !efforts.includes(effort)) { - return declaredDefaultEffortFor(effective, efforts); - } - return effort; + if (effort === 'on') return declaredDefaultEffortFor(effective, efforts); + return matchDeclaredEffort(efforts, effort) ?? declaredDefaultEffortFor(effective, efforts); } if (efforts.length > 0) { - if (effort === 'on' || !efforts.includes(effort)) { - return declaredDefaultEffortFor(effective, efforts); - } - return effort; + if (effort === 'on') return declaredDefaultEffortFor(effective, efforts); + return matchDeclaredEffort(efforts, effort) ?? declaredDefaultEffortFor(effective, efforts); } if (!supportsThinking(effective)) return 'off'; return 'on'; @@ -175,7 +183,10 @@ export function resolveThinkingEffortWithFallback( const resolved = normalizeThinkingEffortForModel(effort, effectiveModel, kimiProtocol); const efforts = effortsFor(effectiveModel); const fallback: ThinkingEffortFallback | undefined = - effort !== 'on' && effort !== 'off' && efforts.length > 0 && !efforts.includes(effort) + effort !== 'on' && + effort !== 'off' && + efforts.length > 0 && + matchDeclaredEffort(efforts, effort) === undefined ? { configured: effort, resolved } : undefined; return { effort: resolved, fallback }; diff --git a/packages/agent-core/test/agent/config/thinking.test.ts b/packages/agent-core/test/agent/config/thinking.test.ts index 8e0797ced4..fab7b0b63b 100644 --- a/packages/agent-core/test/agent/config/thinking.test.ts +++ b/packages/agent-core/test/agent/config/thinking.test.ts @@ -287,6 +287,19 @@ describe('resolveThinkingEffort', () => { expect(resolveThinkingEffort('high', undefined, declared, false)).toBe('xhigh'); }); + it('matches declared efforts case-insensitively and resolves the declared casing', () => { + const declared = model({ + capabilities: ['thinking'], + supportEfforts: ['Low', 'High', 'Max'], + defaultEffort: 'max', + }); + expect(resolveThinkingEffort('low', undefined, declared, true)).toBe('Low'); + expect(resolveThinkingEffort(undefined, { effort: 'high' }, declared, false)).toBe('High'); + expect(defaultThinkingEffortFor(declared)).toBe('Max'); + expect(supportsThinkingEffort('low', declared, true)).toBe(true); + expect(resolveThinkingEffort('ultra', undefined, declared, false)).toBe('Max'); + }); + it('projects a concrete effort to on for a boolean-only Kimi model', () => { expect(resolveThinkingEffort('ultra', undefined, booleanModel, true)).toBe('on'); }); From 534780d12d2be4d1d87d87ecc590d49fbe6a8863 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Fri, 21 Aug 2026 11:15:14 +0000 Subject: [PATCH 28/34] fix(agent-core-v2): coalesce thinking-effort revalidation across a config reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reload that changes both [providers] and [models] fires one service event per section, and the per-event revalidation ran against the intermediate state — e.g. a provider switch whose inferred list lacks the stored effort produced a fallback warning and status flip moments before the model record carrying the explicit declaration arrived. Both listeners now schedule a single macrotask-coalesced revalidation, so the check always runs against the fully bridged configuration. --- .../src/agent/profile/profileService.ts | 23 ++++++++-- .../test/agent/profile/profileOps.test.ts | 46 ++++++++++++++++--- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index c7a967ff48..1bb65dcd87 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -1,4 +1,4 @@ -import { Disposable } from '#/_base/di/lifecycle'; +import { Disposable, toDisposable } from '#/_base/di/lifecycle'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/state/state'; @@ -203,12 +203,19 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ ); this._register( this.models.onDidChangeModels(() => { - this.revalidateStoredThinkingEffort(); + this.scheduleThinkingEffortRevalidation(); }), ); this._register( this.providers.onDidChangeProviders(() => { - this.revalidateStoredThinkingEffort(); + this.scheduleThinkingEffortRevalidation(); + }), + ); + this._register( + toDisposable(() => { + if (this.thinkingEffortRevalidationTimer !== undefined) { + clearTimeout(this.thinkingEffortRevalidationTimer); + } }), ); this._register( @@ -637,6 +644,16 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ ); } + private thinkingEffortRevalidationTimer: ReturnType | undefined; + + private scheduleThinkingEffortRevalidation(): void { + if (this.thinkingEffortRevalidationTimer !== undefined) return; + this.thinkingEffortRevalidationTimer = setTimeout(() => { + this.thinkingEffortRevalidationTimer = undefined; + this.revalidateStoredThinkingEffort(); + }, 0); + } + private revalidateStoredThinkingEffort(): void { const before = this.lastResolvedThinkingEffort; this.warnAboutThinkingEffortFallback(this.profileState.thinkingLevel); diff --git a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts index 198a0bffee..a40e005dca 100644 --- a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts +++ b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts @@ -1,12 +1,12 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; -import { Event } from '#/_base/event'; +import { Emitter, Event } from '#/_base/event'; import { IAgentProfileService } from '#/agent/profile/profile'; import { AgentProfileService } from '#/agent/profile/profileService'; -import { profileActiveToolsKey, profileKey } from '#/agent/profile/profileOps'; +import { profileActiveToolsKey, profileKey, WarningIssued } from '#/agent/profile/profileOps'; import { DEFAULT_AGENT_PROFILE_NAME, type EnvironmentDisclosureSnapshot, @@ -16,8 +16,8 @@ import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalo import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { IModelCatalog, type Model } from '#/kosong/model/catalog'; -import { IModelService } from '#/kosong/model/model'; -import { IProviderService } from '#/kosong/provider/provider'; +import { IModelService, type ModelsChangedEvent } from '#/kosong/model/model'; +import { IProviderService, type ProvidersChangedEvent } from '#/kosong/provider/provider'; import { IProtocolAdapterRegistry, type Protocol } from '#/kosong/protocol/protocol'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; @@ -188,6 +188,8 @@ let agentState: IAgentStateService; let svc: IAgentProfileService; let configValues: Record; let modelCatalog: IModelCatalog; +let modelChangeEvents: Emitter; +let providerChangeEvents: Emitter; function buildHost(key: string): { ix: TestInstantiationService; @@ -209,12 +211,12 @@ function buildHost(key: string): { host.stub(IModelCatalog, modelCatalog); host.stub(IModelService, { _serviceBrand: undefined, - onDidChangeModels: Event.None, + onDidChangeModels: modelChangeEvents.event, onDidChangeDefaultModel: Event.None, } as unknown as IModelService); host.stub(IProviderService, { _serviceBrand: undefined, - onDidChangeProviders: Event.None, + onDidChangeProviders: providerChangeEvents.event, } as unknown as IProviderService); host.stub(IProtocolAdapterRegistry, createProtocolRegistryStub()); host.stub(IHostEnvironment, stubUnused()); @@ -277,6 +279,8 @@ beforeEach(() => { disposables = new DisposableStore(); configValues = {}; modelCatalog = createModelCatalogStub(); + modelChangeEvents = disposables.add(new Emitter()); + providerChangeEvents = disposables.add(new Emitter()); const host = buildHost(KEY); ix = host.ix; dispatcher = host.dispatcher; @@ -748,4 +752,32 @@ describe('AgentProfileService (wire-backed config.update)', () => { expect(host.svc.resolveRequestParams().cacheKey).toBe('session-test'); }); + + it('coalesces a combined provider and model reload into one revalidation', async () => { + const catalogModels: Record = { + 'kimi-code': createTestModel({ providerType: 'openai' }), + }; + modelCatalog = createModelCatalogStub(catalogModels); + const host = buildHost('profile-reload-coalesce'); + const dispatched = vi.spyOn(host.dispatcher, 'dispatch'); + + host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'ultra' }); + expect(host.svc.data().thinkingLevel).toBe('ultra'); + + catalogModels['kimi-code'] = { + ...createTestModel({ providerType: 'openai' }), + supportEfforts: ['low', 'high'], + }; + providerChangeEvents.fire({ added: [], removed: [], changed: ['compat'] }); + catalogModels['kimi-code'] = { + ...createTestModel({ providerType: 'openai' }), + supportEfforts: ['ultra'], + }; + modelChangeEvents.fire({ added: [], removed: [], changed: ['kimi-code'] }); + + for (let i = 0; i < 10; i++) await new Promise((resolve) => setImmediate(resolve)); + + expect(host.svc.data().thinkingLevel).toBe('ultra'); + expect(dispatched.mock.calls.filter(([event]) => event instanceof WarningIssued)).toEqual([]); + }); }); From 4a4a92cafcb807f6fc1b31326c3ed0bae61ea300 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Fri, 21 Aug 2026 11:55:48 +0000 Subject: [PATCH 29/34] fix: keep inherited default_effort covered by a padded override list The effective-model composition dropped an inherited default_effort whenever the raw override support_efforts list did not contain it verbatim, so a padded or differently-cased declaration discarded the declared default and resolution fell back to the middle entry. The coverage check now uses the resolvers' normalization (trimmed, case-insensitive); the inherited default is left untouched and the resolver still returns the declared canonical form. --- packages/agent-core-v2/src/kosong/model/modelAuth.ts | 4 ++-- packages/agent-core-v2/src/kosong/model/thinking.ts | 11 +++++++++++ .../agent-core-v2/test/kosong/model/modelAuth.test.ts | 10 ++++++++++ packages/agent-core/src/config/model.ts | 11 ++++++++--- .../agent-core/test/agent/config/thinking.test.ts | 10 ++++++++++ .../agent-core/test/config/model-overrides.test.ts | 9 +++++++++ 6 files changed, 50 insertions(+), 5 deletions(-) diff --git a/packages/agent-core-v2/src/kosong/model/modelAuth.ts b/packages/agent-core-v2/src/kosong/model/modelAuth.ts index b53d9a0213..4ea8f0f9a6 100644 --- a/packages/agent-core-v2/src/kosong/model/modelAuth.ts +++ b/packages/agent-core-v2/src/kosong/model/modelAuth.ts @@ -12,7 +12,7 @@ import { explainProviderEndpoint } from '../provider/providerDefinition'; import type { ModelRecord } from './model'; import type { ResolvedModelAuthMaterial } from './model.types'; -import { drivesThinkingThroughTraits } from './thinking'; +import { drivesThinkingThroughTraits, isDeclaredThinkingEffort } from './thinking'; export function resolveModelAuthMaterial( args: { @@ -87,7 +87,7 @@ export function effectiveModelConfig( overrides?.supportEfforts !== undefined && overrides.defaultEffort === undefined && effective.defaultEffort !== undefined && - !overrides.supportEfforts.includes(effective.defaultEffort) + !isDeclaredThinkingEffort(overrides.supportEfforts, effective.defaultEffort) ) { delete effective.defaultEffort; } diff --git a/packages/agent-core-v2/src/kosong/model/thinking.ts b/packages/agent-core-v2/src/kosong/model/thinking.ts index a6277af9c7..2b4d3aa103 100644 --- a/packages/agent-core-v2/src/kosong/model/thinking.ts +++ b/packages/agent-core-v2/src/kosong/model/thinking.ts @@ -112,6 +112,17 @@ export function declaredThinkingEfforts( return effortsFor(model); } +/** + * Whether a raw declared `support_efforts` list covers `effort`, using the + * resolvers' normalization: trimmed, case-insensitive comparison. + */ +export function isDeclaredThinkingEffort( + supportEfforts: readonly string[] | undefined, + effort: string, +): boolean { + return matchDeclaredEffort(effortsFor({ supportEfforts }), effort) !== undefined; +} + function declaredDefaultEffortFor( model: ModelThinkingMetadata | undefined, efforts: readonly string[], diff --git a/packages/agent-core-v2/test/kosong/model/modelAuth.test.ts b/packages/agent-core-v2/test/kosong/model/modelAuth.test.ts index 7536c04821..b39ea1bf84 100644 --- a/packages/agent-core-v2/test/kosong/model/modelAuth.test.ts +++ b/packages/agent-core-v2/test/kosong/model/modelAuth.test.ts @@ -115,6 +115,16 @@ describe('effectiveModelConfig', () => { expect(effective.defaultEffort).toBeUndefined(); }); + it('keeps an inherited defaultEffort covered by the override list after normalization', () => { + const effective = effectiveModelConfig({ + model: 'm', + supportEfforts: ['low', 'high'], + defaultEffort: 'high', + overrides: { supportEfforts: [' High ', 'max'] }, + }); + expect(effective.defaultEffort).toBe('high'); + }); + it('infers the Anthropic profile for non-trait-driven vendors only', () => { const record: ModelRecord = { model: 'claude-sonnet-4-5', protocol: 'anthropic' }; const inferred = effectiveModelConfig(record, 'anthropic'); diff --git a/packages/agent-core/src/config/model.ts b/packages/agent-core/src/config/model.ts index f65fbf20e1..d2adc9b1a6 100644 --- a/packages/agent-core/src/config/model.ts +++ b/packages/agent-core/src/config/model.ts @@ -16,10 +16,15 @@ export function effectiveModelAlias( if ( overrides?.supportEfforts !== undefined && overrides.defaultEffort === undefined && - effective.defaultEffort !== undefined && - !overrides.supportEfforts.includes(effective.defaultEffort) + effective.defaultEffort !== undefined ) { - delete effective.defaultEffort; + // The inherited default survives when the override list still covers it; + // compare normalized (trimmed, case-insensitive) like the resolvers do. + const declared = effective.defaultEffort.trim().toLowerCase(); + const covered = overrides.supportEfforts.some( + (candidate) => candidate.trim().toLowerCase() === declared, + ); + if (!covered) delete effective.defaultEffort; } // The input cap can never exceed the effective total window (an override diff --git a/packages/agent-core/test/agent/config/thinking.test.ts b/packages/agent-core/test/agent/config/thinking.test.ts index fab7b0b63b..09a1e08620 100644 --- a/packages/agent-core/test/agent/config/thinking.test.ts +++ b/packages/agent-core/test/agent/config/thinking.test.ts @@ -287,6 +287,16 @@ describe('resolveThinkingEffort', () => { expect(resolveThinkingEffort('high', undefined, declared, false)).toBe('xhigh'); }); + it('resolves the inherited default when a padded override list covers it', () => { + const declared = model({ + capabilities: ['thinking'], + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'max', + overrides: { supportEfforts: [' max ', 'high'] }, + }); + expect(resolveThinkingEffort(undefined, undefined, declared)).toBe('max'); + }); + it('matches declared efforts case-insensitively and resolves the declared casing', () => { const declared = model({ capabilities: ['thinking'], diff --git a/packages/agent-core/test/config/model-overrides.test.ts b/packages/agent-core/test/config/model-overrides.test.ts index 1af415067a..762daf8aaf 100644 --- a/packages/agent-core/test/config/model-overrides.test.ts +++ b/packages/agent-core/test/config/model-overrides.test.ts @@ -64,6 +64,15 @@ describe('effectiveModelAlias', () => { expect(effectiveModelAlias(model).defaultEffort).toBeUndefined(); }); + it('keeps an inherited defaultEffort covered by the override list after normalization', () => { + expect(effectiveModelAlias(alias({ supportEfforts: [' max ', 'high'] })).defaultEffort).toBe( + 'max', + ); + expect(effectiveModelAlias(alias({ supportEfforts: [' MAX ', 'high'] })).defaultEffort).toBe( + 'max', + ); + }); + it('keeps an explicit defaultEffort override when it is valid', () => { const model = alias({ supportEfforts: ['low', 'high'], defaultEffort: 'high' }); From 742c3700a586e8b7d1b8f6e142ba1575128229e9 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Fri, 21 Aug 2026 12:44:03 +0000 Subject: [PATCH 30/34] test(agent-core-v2): drive thinking-effort revalidation with an injected scheduler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reload revalidation is scheduled with setTimeout(0) in production, and the tests flushed the real event loop with setImmediate polling to wait for it — phase-dependent and environment-sensitive. The profile service now accepts an optional scheduler through its existing configure() options (defaulting to setTimeout(0)); tests inject a manual queue and run the coalesced revalidation explicitly, which also pins the merge itself (both reload events produce exactly one queued run). --- .../src/agent/profile/profile.ts | 1 + .../src/agent/profile/profileService.ts | 18 +++++- .../test/agent/profile/config-state.test.ts | 61 +++++++++++++++---- .../test/agent/profile/profileOps.test.ts | 11 +++- 4 files changed, 74 insertions(+), 17 deletions(-) diff --git a/packages/agent-core-v2/src/agent/profile/profile.ts b/packages/agent-core-v2/src/agent/profile/profile.ts index 529c23e428..733573620a 100644 --- a/packages/agent-core-v2/src/agent/profile/profile.ts +++ b/packages/agent-core-v2/src/agent/profile/profile.ts @@ -81,6 +81,7 @@ export interface ProfileBindingSnapshot { export interface ProfileServiceOptions { readonly emitStatusUpdated?: () => void; + readonly scheduleThinkingEffortRevalidation?: (run: () => void) => void; } export interface ApplyProfileOptions { diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 1bb65dcd87..a54e2ef919 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -264,6 +264,9 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ configure(options: ProfileServiceOptions): void { this.optionsValue = { emitStatusUpdated: options.emitStatusUpdated ?? this.optionsValue.emitStatusUpdated, + scheduleThinkingEffortRevalidation: + options.scheduleThinkingEffortRevalidation ?? + this.optionsValue.scheduleThinkingEffortRevalidation, }; } @@ -644,14 +647,23 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ ); } + private thinkingEffortRevalidationScheduled = false; private thinkingEffortRevalidationTimer: ReturnType | undefined; private scheduleThinkingEffortRevalidation(): void { - if (this.thinkingEffortRevalidationTimer !== undefined) return; - this.thinkingEffortRevalidationTimer = setTimeout(() => { + if (this.thinkingEffortRevalidationScheduled) return; + this.thinkingEffortRevalidationScheduled = true; + const run = () => { + this.thinkingEffortRevalidationScheduled = false; this.thinkingEffortRevalidationTimer = undefined; this.revalidateStoredThinkingEffort(); - }, 0); + }; + const custom = this.optionsValue.scheduleThinkingEffortRevalidation; + if (custom !== undefined) { + custom(run); + return; + } + this.thinkingEffortRevalidationTimer = setTimeout(run, 0); } private revalidateStoredThinkingEffort(): void { diff --git a/packages/agent-core-v2/test/agent/profile/config-state.test.ts b/packages/agent-core-v2/test/agent/profile/config-state.test.ts index b701fad52e..7ad4b3a455 100644 --- a/packages/agent-core-v2/test/agent/profile/config-state.test.ts +++ b/packages/agent-core-v2/test/agent/profile/config-state.test.ts @@ -398,6 +398,12 @@ describe('ConfigState thinking clamp for always-thinking models', () => { }); it('warns once when a model metadata reload strands the stored effort', async () => { + const scheduled: Array<() => void> = []; + profile.configure({ + scheduleThinkingEffortRevalidation: (run) => { + scheduled.push(run); + }, + }); profile.update({ modelAlias: 'kimi-code/ultra', thinkingLevel: 'high' }); expect(profile.data().thinkingLevel).toBe('high'); expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([]); @@ -416,10 +422,13 @@ describe('ConfigState thinking clamp for always-thinking models', () => { }, }, }; - + profile.data(); await vi.waitFor(() => { - expect(profile.data().thinkingLevel).toBe('ultra'); + expect(scheduled.length).toBeGreaterThan(0); }); + for (const run of scheduled.splice(0)) run(); + + expect(profile.data().thinkingLevel).toBe('ultra'); await vi.waitFor(() => { expect(ctx.allEvents).toContainEqual({ type: '[rpc]', @@ -438,6 +447,12 @@ describe('ConfigState thinking clamp for always-thinking models', () => { }); it('republishes the status when a reload makes a stranded effort valid again', async () => { + const scheduled: Array<() => void> = []; + profile.configure({ + scheduleThinkingEffortRevalidation: (run) => { + scheduled.push(run); + }, + }); profile.update({ modelAlias: 'kimi-code/ultra', thinkingLevel: 'high' }); expect(profile.data().thinkingLevel).toBe('high'); @@ -455,10 +470,24 @@ describe('ConfigState thinking clamp for always-thinking models', () => { }, }, }); + const revalidate = async (): Promise => { + profile.data(); + await vi.waitFor(() => { + expect(scheduled.length).toBeGreaterThan(0); + }); + for (const run of scheduled.splice(0)) run(); + }; kimiConfig = withUltraEfforts(['low', 'ultra']); + await revalidate(); + expect(profile.data().thinkingLevel).toBe('ultra'); await vi.waitFor(() => { - expect(profile.data().thinkingLevel).toBe('ultra'); + expect(ctx.allEvents).toContainEqual( + expect.objectContaining({ + event: 'warning', + args: expect.objectContaining({ code: 'thinking-effort-not-listed' }), + }), + ); }); await vi.waitFor(() => { const statuses = ctx.allEvents.filter((event) => event.event === 'agent.status.updated'); @@ -466,14 +495,12 @@ describe('ConfigState thinking clamp for always-thinking models', () => { }); kimiConfig = withUltraEfforts(['low', 'high', 'ultra']); - await vi.waitFor(() => { - expect(profile.data().thinkingLevel).toBe('high'); - }); + await revalidate(); + expect(profile.data().thinkingLevel).toBe('high'); await vi.waitFor(() => { const statuses = ctx.allEvents.filter((event) => event.event === 'agent.status.updated'); expect(statuses.at(-1)?.args).toMatchObject({ thinkingEffort: 'high' }); }); - for (let i = 0; i < 10; i++) await new Promise((resolve) => setImmediate(resolve)); expect( ctx.allEvents.filter( (event) => @@ -484,6 +511,12 @@ describe('ConfigState thinking clamp for always-thinking models', () => { }); it('warns once when a provider-only reload changes the inferred effort list', async () => { + const scheduled: Array<() => void> = []; + profile.configure({ + scheduleThinkingEffortRevalidation: (run) => { + scheduled.push(run); + }, + }); kimiConfig = { providers: { compat: { type: 'openai', apiKey: 'test-key', baseUrl: 'https://api.example.test/v1' }, @@ -507,10 +540,13 @@ describe('ConfigState thinking clamp for always-thinking models', () => { compat: { type: 'anthropic', apiKey: 'test-key', baseUrl: 'https://api.example.test/v1' }, }, }; - + profile.data(); await vi.waitFor(() => { - expect(profile.data().thinkingLevel).toBe('high'); + expect(scheduled.length).toBeGreaterThan(0); }); + for (const run of scheduled.splice(0)) run(); + + expect(profile.data().thinkingLevel).toBe('high'); await vi.waitFor(() => { expect(ctx.allEvents).toContainEqual({ type: '[rpc]', @@ -537,10 +573,12 @@ describe('ConfigState thinking clamp for always-thinking models', () => { }, }, }; - + profile.data(); await vi.waitFor(() => { - expect(profile.data().thinkingLevel).toBe('high'); + expect(scheduled.length).toBeGreaterThan(0); }); + for (const run of scheduled.splice(0)) run(); + await vi.waitFor(() => { expect(ctx.allEvents).toContainEqual({ type: '[rpc]', @@ -552,7 +590,6 @@ describe('ConfigState thinking clamp for always-thinking models', () => { }), }); }); - for (let i = 0; i < 10; i++) await new Promise((resolve) => setImmediate(resolve)); expect( ctx.allEvents.filter( (event) => diff --git a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts index a40e005dca..84559ef70b 100644 --- a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts +++ b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts @@ -753,12 +753,18 @@ describe('AgentProfileService (wire-backed config.update)', () => { expect(host.svc.resolveRequestParams().cacheKey).toBe('session-test'); }); - it('coalesces a combined provider and model reload into one revalidation', async () => { + it('coalesces a combined provider and model reload into one revalidation', () => { const catalogModels: Record = { 'kimi-code': createTestModel({ providerType: 'openai' }), }; modelCatalog = createModelCatalogStub(catalogModels); const host = buildHost('profile-reload-coalesce'); + const scheduled: Array<() => void> = []; + host.svc.configure({ + scheduleThinkingEffortRevalidation: (run) => { + scheduled.push(run); + }, + }); const dispatched = vi.spyOn(host.dispatcher, 'dispatch'); host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'ultra' }); @@ -775,7 +781,8 @@ describe('AgentProfileService (wire-backed config.update)', () => { }; modelChangeEvents.fire({ added: [], removed: [], changed: ['kimi-code'] }); - for (let i = 0; i < 10; i++) await new Promise((resolve) => setImmediate(resolve)); + expect(scheduled).toHaveLength(1); + for (const run of scheduled.splice(0)) run(); expect(host.svc.data().thinkingLevel).toBe('ultra'); expect(dispatched.mock.calls.filter(([event]) => event instanceof WarningIssued)).toEqual([]); From 9799e01b38ee7c831439c8c995daa5050bcefcb4 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Fri, 21 Aug 2026 13:24:53 +0000 Subject: [PATCH 31/34] fix: match declared thinking efforts case-insensitively in diagnostics too The request-time and override warnings still compared the effective effort against the declared list with exact string membership, so a mixed-case declaration like ["High"] false-warned about a forced "high" that the resolver had already accepted. All three diagnostic paths (v1 override, v1 stale, v2 requester) now share the resolver's normalized, case-insensitive membership check; the warning text keeps showing the declared entries in their canonical form. --- .../agent/llmRequester/llmRequesterService.ts | 3 +- .../llmRequester/llmRequesterService.test.ts | 11 +++ .../agent-core/src/agent/config/thinking.ts | 11 +++ packages/agent-core/src/agent/index.ts | 6 +- .../test/agent/config-state.test.ts | 95 +++++++++++++++++++ 5 files changed, 123 insertions(+), 3 deletions(-) diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 85b3deebba..2a87136ad1 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -42,6 +42,7 @@ import { IModelService } from '#/kosong/model/model'; import { completionBudgetParams, resolveCompletionBudget } from '#/kosong/model/completionBudget'; import { declaredThinkingEfforts, + isDeclaredThinkingEffort, resolveThinkingKeep, type ThinkingConfig, } from '#/kosong/model/thinking'; @@ -523,7 +524,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { if (effort === 'on' || effort === 'off') return; const supportEfforts = declaredThinkingEfforts(request.model); if (supportEfforts.length === 0) return; - if (supportEfforts.includes(effort)) return; + if (isDeclaredThinkingEffort(request.model.supportEfforts, effort)) return; const code = 'thinking-effort-not-listed'; const knownEfforts = supportEfforts.join(','); const message = `Thinking effort "${effort}" is not listed for model "${request.model.name}" (known: ${supportEfforts.join(', ')}). The value will be sent unchanged to the backend.`; diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts index ff28a98ef5..9b2304de9c 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts @@ -348,6 +348,17 @@ describe('AgentLLMRequesterService thinking effort diagnostics', () => { expect(events.filter((event) => event.type === 'warning')).toEqual([]); }); + + it('does not warn when the effort matches a declared entry case-insensitively', async () => { + const calls = { value: 0 }; + const requester = createRequester(calls, null); + Object.defineProperty(requester.model, 'supportEfforts', { value: [' High '] }); + const { service, events } = createService(requester, undefined, { thinkingLevel: 'high' }); + + await service.request(); + + expect(events.filter((event) => event.type === 'warning')).toEqual([]); + }); }); describe('AgentLLMRequesterService strict resend', () => { diff --git a/packages/agent-core/src/agent/config/thinking.ts b/packages/agent-core/src/agent/config/thinking.ts index 0b160488ab..2549757766 100644 --- a/packages/agent-core/src/agent/config/thinking.ts +++ b/packages/agent-core/src/agent/config/thinking.ts @@ -68,6 +68,17 @@ function matchDeclaredEffort( return efforts.find((candidate) => candidate.toLowerCase() === effort.toLowerCase()); } +/** + * Whether a raw declared `support_efforts` list covers `effort`, using the + * resolvers' normalization: trimmed, case-insensitive comparison. + */ +export function isDeclaredThinkingEffort( + supportEfforts: readonly string[] | undefined, + effort: string, +): boolean { + return matchDeclaredEffort(normalizeDeclaredEfforts(supportEfforts), effort) !== undefined; +} + /** * Resolve the default thinking effort for a model from its declared metadata: * - models declaring `support_efforts` -> `default_effort`, else the middle diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 3d1aa116e9..2c83b703e5 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -36,6 +36,7 @@ import { import { CronManager } from './cron'; import { ConfigState } from './config'; import { + isDeclaredThinkingEffort, normalizeDeclaredEfforts, type ThinkingEffort, type ThinkingEffortFallback, @@ -378,7 +379,8 @@ export class Agent { if (effort === 'on' || effort === 'off') return; const effective = model === undefined ? undefined : effectiveModelAlias(model); const supportEfforts = normalizeDeclaredEfforts(effective?.supportEfforts); - if (supportEfforts.length === 0 || supportEfforts.includes(effort)) return; + if (supportEfforts.length === 0 || isDeclaredThinkingEffort(effective?.supportEfforts, effort)) + return; const modelName = effective?.model ?? modelAlias ?? 'unknown'; this.emitThinkingEffortWarning({ code: 'thinking-effort-override-not-listed', @@ -432,7 +434,7 @@ export class Agent { } const supportEfforts = normalizeDeclaredEfforts(resolved.supportEfforts); if (supportEfforts.length === 0) return; - if (supportEfforts.includes(effort)) return; + if (isDeclaredThinkingEffort(resolved.supportEfforts, effort)) return; this.emitThinkingEffortWarning({ code: 'thinking-effort-not-listed', message: `Thinking effort "${effort}" is not listed for model "${provider.modelName}" (known: ${supportEfforts.join(', ')}). The value will be sent unchanged to the backend.`, diff --git a/packages/agent-core/test/agent/config-state.test.ts b/packages/agent-core/test/agent/config-state.test.ts index dbb704701c..2ace081922 100644 --- a/packages/agent-core/test/agent/config-state.test.ts +++ b/packages/agent-core/test/agent/config-state.test.ts @@ -414,6 +414,101 @@ describe('ConfigState model capabilities', () => { } }); + it('does not warn when the env override matches a mixed-case declared entry', () => { + vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'high'); + try { + const config: KimiConfig = { + providers: { + compatible: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test', + }, + }, + models: { + compatible: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: [' High '], + }, + }, + }; + const ctx = testAgent({ + initialConfig: config, + providerManager: new ProviderManager({ config }), + }); + + ctx.agent.config.update({ + modelAlias: 'compatible', + systemPrompt: 'system', + }); + + expect(ctx.agent.config.thinkingEffort).toBe('high'); + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([]); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('does not warn when a reloaded list matches the current effort case-insensitively', async () => { + const compatibleModel = (supportEfforts: string[]) => ({ + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic' as const, + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts, + }); + let current: KimiConfig = { + providers: { + compatible: { + type: 'kimi', + apiKey: 'test-key', + baseUrl: 'https://api.example.test', + }, + }, + models: { compatible: compatibleModel(['high']) }, + }; + let requests = 0; + const ctx = testAgent({ + initialConfig: current, + providerManager: new ProviderManager({ config: () => current }), + generate: async (provider) => { + requests += 1; + expect(provider.thinkingEffort).toBe('high'); + return { + id: 'response-1', + message: { role: 'assistant', content: [], toolCalls: [] }, + usage: emptyUsage(), + finishReason: 'completed', + rawFinishReason: 'stop', + }; + }, + }); + ctx.agent.config.update({ + modelAlias: 'compatible', + systemPrompt: 'system', + }); + ctx.agent.config.setThinkingEffort('high'); + + current = { + ...current, + models: { compatible: compatibleModel([' High ']) }, + }; + + await ctx.agent.llm.chat({ + messages: [], + tools: [], + signal: new AbortController().signal, + }); + + expect(requests).toBe(1); + expect(ctx.allEvents.filter((event) => event.event === 'warning')).toEqual([]); + }); + it('suppresses the fallback warning when the env override decides the final effort', () => { // The configured "high" is unlisted and would fall back to "xhigh", but // the env pin "low" decides what actually goes on the wire — warning From fd09758da7150ea500a343f957f92dece6d3eaec Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Fri, 21 Aug 2026 13:56:52 +0000 Subject: [PATCH 32/34] fix: include the resolved fallback in thinking-effort warning deduplication --- .../src/agent/profile/profileService.ts | 2 +- .../test/agent/profile/config-state.test.ts | 59 +++++++++++++++++++ packages/agent-core/src/agent/index.ts | 1 + 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index a54e2ef919..6b8d0a7d87 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -697,7 +697,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ const knownEfforts = efforts.join(','); const code = 'thinking-effort-not-listed'; const message = `Thinking effort "${fallback.configured}" is not listed for model "${model.name}" (known: ${efforts.join(', ')}). Falling back to the model's default effort "${fallback.resolved}".`; - const key = [code, model.id, model.name, fallback.configured, knownEfforts].join('\u0000'); + const key = [code, model.id, model.name, fallback.configured, fallback.resolved, knownEfforts].join('\u0000'); if (this.emittedThinkingEffortWarnings.has(key)) return; this.emittedThinkingEffortWarnings.add(key); void this.dispatcher.dispatch(new WarningIssued({ agentId: this.scopeContext.agentId, code, message })); diff --git a/packages/agent-core-v2/test/agent/profile/config-state.test.ts b/packages/agent-core-v2/test/agent/profile/config-state.test.ts index 7ad4b3a455..d981086753 100644 --- a/packages/agent-core-v2/test/agent/profile/config-state.test.ts +++ b/packages/agent-core-v2/test/agent/profile/config-state.test.ts @@ -446,6 +446,65 @@ describe('ConfigState thinking clamp for always-thinking models', () => { }); }); + it('warns again when a default_effort-only reload changes the fallback target', async () => { + const scheduled: Array<() => void> = []; + profile.configure({ + scheduleThinkingEffortRevalidation: (run) => { + scheduled.push(run); + }, + }); + profile.update({ modelAlias: 'kimi-code/ultra', thinkingLevel: 'high' }); + expect(profile.data().thinkingLevel).toBe('high'); + + const withUltraDefault = (defaultEffort: string) => ({ + ...kimiConfig, + models: { + ...kimiConfig.models, + 'kimi-code/ultra': { + provider: 'kimi', + model: 'kimi-ultra', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'ultra'], + defaultEffort, + }, + }, + }); + const revalidate = async (): Promise => { + profile.data(); + await vi.waitFor(() => { + expect(scheduled.length).toBeGreaterThan(0); + }); + for (const run of scheduled.splice(0)) run(); + }; + const fallbackWarnings = () => + ctx.allEvents.filter( + (event) => + event.event === 'warning' && + (event.args as { code?: string }).code === 'thinking-effort-not-listed', + ); + + kimiConfig = withUltraDefault('ultra'); + await revalidate(); + expect(profile.data().thinkingLevel).toBe('ultra'); + await vi.waitFor(() => { + expect(fallbackWarnings()).toHaveLength(1); + }); + + // Same list, new default_effort: the stored effort now falls back to a + // different value, so a materially changed fallback must warn again. + kimiConfig = withUltraDefault('low'); + await revalidate(); + expect(profile.data().thinkingLevel).toBe('low'); + await vi.waitFor(() => { + expect(fallbackWarnings()).toHaveLength(2); + }); + expect(fallbackWarnings().at(-1)?.args).toMatchObject({ + message: + 'Thinking effort "high" is not listed for model "kimi-ultra" (known: low, ultra). Falling back to the model\'s default effort "low".', + }); + }); + it('republishes the status when a reload makes a stranded effort valid again', async () => { const scheduled: Array<() => void> = []; profile.configure({ diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 2c83b703e5..3585e61c1b 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -464,6 +464,7 @@ export class Agent { warning.modelAlias, warning.model, warning.effort, + warning.fallbackEffort, warning.knownEfforts, ].join('\u0000'); if (this.emittedThinkingEffortWarnings.has(key)) return; From 611438dd703b63debdd4d65e92d1314de3fcae45 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Fri, 21 Aug 2026 14:24:47 +0000 Subject: [PATCH 33/34] test(agent-core-v2): drop an inline comment from the reload fallback case --- packages/agent-core-v2/test/agent/profile/config-state.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/agent-core-v2/test/agent/profile/config-state.test.ts b/packages/agent-core-v2/test/agent/profile/config-state.test.ts index d981086753..528986672e 100644 --- a/packages/agent-core-v2/test/agent/profile/config-state.test.ts +++ b/packages/agent-core-v2/test/agent/profile/config-state.test.ts @@ -491,8 +491,6 @@ describe('ConfigState thinking clamp for always-thinking models', () => { expect(fallbackWarnings()).toHaveLength(1); }); - // Same list, new default_effort: the stored effort now falls back to a - // different value, so a materially changed fallback must warn again. kimiConfig = withUltraDefault('low'); await revalidate(); expect(profile.data().thinkingLevel).toBe('low'); From b290ac3117c501bfe6f823743a8ce8b28b4dd365 Mon Sep 17 00:00:00 2001 From: kimi-agent-bot Date: Fri, 21 Aug 2026 15:48:34 +0000 Subject: [PATCH 34/34] fix(agent-core-v2): track the last published thinking effort for reload revalidation The reload revalidation compared the effective effort against a memo that every public read path refreshed, so any consumer reading the level between the metadata change and the scheduled revalidation moved the baseline to the new value and the status republication was skipped while clients still showed the old one. The baseline now only advances when a status carrying the effort is actually emitted, on both the dispatch and the custom emitStatusUpdated paths. --- .../src/agent/profile/profileService.ts | 19 +++++---- .../test/agent/profile/config-state.test.ts | 39 +++++++++++++++++++ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 6b8d0a7d87..eb7624fb7d 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -667,7 +667,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ } private revalidateStoredThinkingEffort(): void { - const before = this.lastResolvedThinkingEffort; + const before = this.lastPublishedThinkingEffort; this.warnAboutThinkingEffortFallback(this.profileState.thinkingLevel); const after = this.getEffectiveThinkingLevel(); if (before !== undefined && after !== before) { @@ -719,6 +719,9 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ private emitStatusUpdated(includeThinkingEffort = false): void { const custom = this.optionsValue.emitStatusUpdated; if (custom !== undefined) { + if (includeThinkingEffort) { + this.lastPublishedThinkingEffort = this.getEffectiveThinkingLevel(); + } custom(); return; } @@ -726,13 +729,15 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ if (modelAlias === undefined) return; const capabilities = this.tryResolveRawModel()?.capabilities; const maxContextTokens = capabilities?.max_input_tokens ?? capabilities?.max_context_tokens; + const thinkingEffort = includeThinkingEffort ? this.getEffectiveThinkingLevel() : undefined; + if (thinkingEffort !== undefined) { + this.lastPublishedThinkingEffort = thinkingEffort; + } void this.dispatcher.dispatch( new AgentStatusUpdated({ agentId: this.scopeContext.agentId, model: modelAlias, - thinkingEffort: includeThinkingEffort - ? this.getEffectiveThinkingLevel() - : undefined, + thinkingEffort, maxContextTokens: maxContextTokens !== undefined && maxContextTokens > 0 ? maxContextTokens : undefined, }), @@ -771,7 +776,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ return this.resolveThinkingEffort(this.profileState.thinkingLevel, this.tryResolveRawModel()); } - private lastResolvedThinkingEffort: ThinkingEffort | undefined; + private lastPublishedThinkingEffort: ThinkingEffort | undefined; private resolveThinkingState(model: Model | undefined): { readonly effective: ThinkingEffort; @@ -783,9 +788,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ base, drivesThinkingThroughTraits(model?.providerType), ); - const effective = forced ?? base; - this.lastResolvedThinkingEffort = effective; - return { effective, forced }; + return { effective: forced ?? base, forced }; } private strictThinkingValidation(model: Model | undefined): boolean { diff --git a/packages/agent-core-v2/test/agent/profile/config-state.test.ts b/packages/agent-core-v2/test/agent/profile/config-state.test.ts index 528986672e..5d15377210 100644 --- a/packages/agent-core-v2/test/agent/profile/config-state.test.ts +++ b/packages/agent-core-v2/test/agent/profile/config-state.test.ts @@ -567,6 +567,45 @@ describe('ConfigState thinking clamp for always-thinking models', () => { ).toHaveLength(1); }); + it('republishes the status even when a read happens before the revalidation runs', async () => { + const scheduled: Array<() => void> = []; + profile.configure({ + scheduleThinkingEffortRevalidation: (run) => { + scheduled.push(run); + }, + }); + profile.update({ modelAlias: 'kimi-code/ultra', thinkingLevel: 'high' }); + expect(profile.data().thinkingLevel).toBe('high'); + + kimiConfig = { + ...kimiConfig, + models: { + ...kimiConfig.models, + 'kimi-code/ultra': { + provider: 'kimi', + model: 'kimi-ultra', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'ultra'], + defaultEffort: 'ultra', + }, + }, + }; + profile.data(); + await vi.waitFor(() => { + expect(scheduled.length).toBeGreaterThan(0); + }); + + expect(profile.getEffectiveThinkingLevel()).toBe('ultra'); + + for (const run of scheduled.splice(0)) run(); + + await vi.waitFor(() => { + const statuses = ctx.allEvents.filter((event) => event.event === 'agent.status.updated'); + expect(statuses.at(-1)?.args).toMatchObject({ thinkingEffort: 'ultra' }); + }); + }); + it('warns once when a provider-only reload changes the inferred effort list', async () => { const scheduled: Array<() => void> = []; profile.configure({