From ca9f51bce683f0d32cd9af17abcd9468586a0bfb Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 8 Jul 2026 17:13:22 +0800 Subject: [PATCH 1/6] fix(agent-core-v2): hide console window when running hooks on Windows Port the v1 hooks runner fix: extract buildHookSpawnOptions and pass windowsHide:true so hook child processes no longer flash a console window on Windows, mirroring the node-local process host defaults. Includes the same regression tests as v1. --- .../src/agent/externalHooks/runner.ts | 33 ++++++++++++++----- .../test/externalHooks/runner.test.ts | 26 ++++++++++++++- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/packages/agent-core-v2/src/agent/externalHooks/runner.ts b/packages/agent-core-v2/src/agent/externalHooks/runner.ts index 9a63643244..5ab79f32a2 100644 --- a/packages/agent-core-v2/src/agent/externalHooks/runner.ts +++ b/packages/agent-core-v2/src/agent/externalHooks/runner.ts @@ -1,4 +1,8 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { + spawn, + type ChildProcessWithoutNullStreams, + type SpawnOptionsWithoutStdio, +} from 'node:child_process'; import { z } from 'zod'; @@ -11,6 +15,25 @@ export interface RunHookOptions { readonly signal?: AbortSignal; } +export function buildHookSpawnOptions(options: { + cwd?: string; + env?: Record; +}): SpawnOptionsWithoutStdio { + return { + shell: true, + cwd: options.cwd, + stdio: 'pipe', + detached: process.platform !== 'win32', + // Hide the console Windows would otherwise allocate for the shell child. + // Without `windowsHide:true`, each hook flashes a visible console window — + // the same regression the node-local process host already guards against + // (see `buildSpawnOptions` in os/backends/node-local/hostProcessService.ts) + // and the runner's own taskkill spawn. Unconditional: it is a no-op on POSIX. + windowsHide: true, + env: options.env === undefined ? undefined : { ...process.env, ...options.env }, + }; +} + const DEFAULT_TIMEOUT_SECONDS = 30; const KILL_GRACE_MS = 100; const OptionalStringSchema = z.preprocess( @@ -46,13 +69,7 @@ export async function runHook( ): Promise { let child: ChildProcessWithoutNullStreams; try { - child = spawn(command, { - shell: true, - cwd: options.cwd, - env: options.env === undefined ? undefined : { ...process.env, ...options.env }, - stdio: 'pipe', - detached: process.platform !== 'win32', - }); + child = spawn(command, buildHookSpawnOptions({ cwd: options.cwd, env: options.env })); } catch (error) { return allowResult({ stderr: errorMessage(error) }); } diff --git a/packages/agent-core-v2/test/externalHooks/runner.test.ts b/packages/agent-core-v2/test/externalHooks/runner.test.ts index 385f4412e6..114cd6bd65 100644 --- a/packages/agent-core-v2/test/externalHooks/runner.test.ts +++ b/packages/agent-core-v2/test/externalHooks/runner.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { runHook } from '#/agent/externalHooks/runner'; +import { buildHookSpawnOptions, runHook } from '#/agent/externalHooks/runner'; function nodeCommand(source: string): string { return `node -e ${JSON.stringify(source.replace(/\s*\n\s*/g, ' '))}`; @@ -112,3 +112,27 @@ describe('runHook process runner', () => { expect(result.stdout?.trim()).toBe('Write'); }); }); + +// Regression coverage for the "every hook flashes an empty console window on +// Windows" bug. With `shell:true` and no `windowsHide`, Node allocates a +// visible console for each hook child process on Windows. The fix is to pass +// `windowsHide:true` (mirrors the node-local host's `buildSpawnOptions` and +// the runner's own taskkill spawn). The flag is only observable on Windows, +// so we assert the spawn options builder directly. +describe('buildHookSpawnOptions (Windows console-window regression)', () => { + it('sets windowsHide:true so hooks do not flash a console on Windows', () => { + expect(buildHookSpawnOptions({}).windowsHide).toBe(true); + }); + + it('runs through the shell with stdio piped', () => { + const options = buildHookSpawnOptions({}); + expect(options.shell).toBe(true); + expect(options.stdio).toBe('pipe'); + }); + + it('merges hook env onto process.env and forwards cwd', () => { + const options = buildHookSpawnOptions({ cwd: '/repo', env: { FOO: 'bar' } }); + expect(options.cwd).toBe('/repo'); + expect(options.env).toMatchObject({ FOO: 'bar' }); + }); +}); From 7b4dbd7c91604de040592ed2d32d5fd035463730 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 8 Jul 2026 17:37:55 +0800 Subject: [PATCH 2/6] fix(agent-core-v2): port anthropic max_tokens ceiling and override fixes Port two v1 kosong fixes to the v2 anthropic provider: - Fall back to the nearest lower catalogued minor when resolving the Claude output ceiling, and catalogue Opus 4.8's documented 128k cap, so an uncatalogued minor no longer drops to the family baseline. - Treat an explicit defaultMaxTokens as the final max_tokens value instead of clamping it to the built-in ceiling. Mirrors the v1 regression tests in a new anthropic-max-tokens test file. --- .../app/llmProtocol/providers/anthropic.ts | 23 ++- .../llmProtocol/anthropic-max-tokens.test.ts | 138 ++++++++++++++++++ 2 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 packages/agent-core-v2/test/llmProtocol/anthropic-max-tokens.test.ts diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts index cdc7abdfc6..ec44511082 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts @@ -142,15 +142,16 @@ const ANTHROPIC_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { * can silently truncate mid-`tool_use`. * * Keys are `-[-]`. Lookups try the most specific - * key first, then fall back to the family/major-only entry, so an - * unrecognized minor version (e.g. a future `opus-4-10`) gets the - * family's baseline rather than the generic fallback. + * key first, then the nearest lower catalogued minor of the same + * family/major (a not-yet-catalogued `opus-4-8` reuses `opus-4-7`'s + * ceiling), and finally the family/major-only baseline entry. */ const CEILING_BY_FAMILY_VERSION: Readonly> = { // Claude Fable 5 documents a 128k output ceiling. 'fable-5': 128000, - // Claude Opus per minor version. 4.6 and 4.7 raised the cap to 128k; + // Claude Opus per minor version. 4.6 through 4.8 document a 128k cap; // 4.5 ships at 64k; 4.1 and the dated 4.0 release stay at 32k. + 'opus-4-8': 128000, 'opus-4-7': 128000, 'opus-4-6': 128000, 'opus-4-5': 64000, @@ -260,8 +261,16 @@ function parseClaudeFamilyVersion(model: string, requireClaudeMarker: boolean): function lookupClaudeCeiling(version: ClaudeVersion): number | undefined { const { family, major, minor } = version; if (minor !== null) { - const exact = CEILING_BY_FAMILY_VERSION[`${family}-${major}-${minor}`]; - if (exact !== undefined) return exact; + // Exact minor first, then walk down to the nearest catalogued minor: + // a newer minor release inherits at least its predecessor's ceiling + // (Anthropic has never lowered the cap within a major), so a + // not-yet-catalogued 4.8 reuses 4.7's value instead of dropping to + // the family baseline. The regex caps minors at two digits, so this + // walk is bounded. + for (let candidate = minor; candidate >= 0; candidate--) { + const ceiling = CEILING_BY_FAMILY_VERSION[`${family}-${major}-${candidate}`]; + if (ceiling !== undefined) return ceiling; + } } return CEILING_BY_FAMILY_VERSION[`${family}-${major}`]; } @@ -944,7 +953,7 @@ export class AnthropicChatProvider implements ChatProvider { this._client = this._apiKey === undefined ? undefined : this._buildClient(this._apiKey); this._explicitMaxTokens = options.defaultMaxTokens !== undefined; this._generationKwargs = { - max_tokens: resolveDefaultMaxTokens(options.model, options.defaultMaxTokens), + max_tokens: options.defaultMaxTokens ?? resolveDefaultMaxTokens(options.model), betaFeatures: options.betaFeatures ?? [INTERLEAVED_THINKING_BETA], }; } diff --git a/packages/agent-core-v2/test/llmProtocol/anthropic-max-tokens.test.ts b/packages/agent-core-v2/test/llmProtocol/anthropic-max-tokens.test.ts new file mode 100644 index 0000000000..f232db6e48 --- /dev/null +++ b/packages/agent-core-v2/test/llmProtocol/anthropic-max-tokens.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { Message } from '#/app/llmProtocol/message'; +import { + AnthropicChatProvider, + resolveDefaultMaxTokens, +} from '#/app/llmProtocol/providers/anthropic'; + +const HISTORY: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }, +]; + +function makeAnthropicResponse() { + return { + id: 'msg_test_123', + type: 'message', + role: 'assistant', + model: 'claude-opus-4-7', + content: [{ type: 'text', text: 'Hello' }], + stop_reason: 'end_turn', + usage: { input_tokens: 10, output_tokens: 5 }, + }; +} + +async function captureRequestBody( + provider: AnthropicChatProvider, +): Promise> { + let capturedParams: Record | undefined; + + (provider as unknown as { _client: { messages: { create: unknown } } })._client.messages.create = + vi.fn().mockImplementation((params: unknown) => { + capturedParams = params as Record; + return Promise.resolve(makeAnthropicResponse()); + }); + + const stream = await provider.generate('', [], HISTORY); + for await (const part of stream) void part; + + if (capturedParams === undefined) { + throw new Error('Expected provider.generate() to call messages.create'); + } + return capturedParams; +} + +async function maxTokensFor( + model: string, + opts: Partial<{ defaultMaxTokens: number }> = {}, +): Promise { + const provider = new AnthropicChatProvider({ + model, + apiKey: 'test-key', + stream: false, + ...opts, + }); + return (await captureRequestBody(provider))['max_tokens'] as number; +} + +describe('resolveDefaultMaxTokens', () => { + it('returns per-version Messages-API caps for known Claude 4 models', () => { + expect(resolveDefaultMaxTokens('claude-fable-5')).toBe(128000); + expect(resolveDefaultMaxTokens('claude-opus-4-8')).toBe(128000); + expect(resolveDefaultMaxTokens('claude-opus-4-7')).toBe(128000); + expect(resolveDefaultMaxTokens('claude-opus-4-6')).toBe(128000); + expect(resolveDefaultMaxTokens('claude-opus-4-5-20251101')).toBe(64000); + expect(resolveDefaultMaxTokens('claude-sonnet-4-6')).toBe(64000); + expect(resolveDefaultMaxTokens('claude-haiku-4-5')).toBe(64000); + }); + + it('matches dotted version separators', () => { + expect(resolveDefaultMaxTokens('claude-opus-4.8')).toBe(128000); + expect(resolveDefaultMaxTokens('claude-opus-4.7')).toBe(128000); + expect(resolveDefaultMaxTokens('claude-sonnet-4.6')).toBe(64000); + }); + + it('falls back to the nearest lower catalogued minor for unknown minors', () => { + // opus-4-9/4-10 are not in the table; they reuse opus-4-8's 128k + // ceiling (a newer minor inherits at least its predecessor's cap). + expect(resolveDefaultMaxTokens('claude-opus-4-9')).toBe(128000); + expect(resolveDefaultMaxTokens('claude-opus-4-10')).toBe(128000); + expect(resolveDefaultMaxTokens('claude-sonnet-4-9')).toBe(64000); + expect(resolveDefaultMaxTokens('claude-haiku-4-9')).toBe(64000); + // A gap between catalogued minors also resolves to the nearest lower one. + expect(resolveDefaultMaxTokens('claude-opus-4-3')).toBe(32000); + }); + + it('honors a lower override and clamps an override above the ceiling', () => { + expect(resolveDefaultMaxTokens('claude-opus-4-7', 200)).toBe(200); + expect(resolveDefaultMaxTokens('claude-opus-4-7', 999999)).toBe(128000); + }); + + it('honors the override for unknown models and falls back to 32000', () => { + expect(resolveDefaultMaxTokens('unknown-model', 12345)).toBe(12345); + expect(resolveDefaultMaxTokens('totally-unknown-model')).toBe(32000); + }); +}); + +describe('AnthropicChatProvider constructor max_tokens', () => { + it('uses per-version Messages-API caps for known Claude models', async () => { + expect(await maxTokensFor('claude-opus-4-8')).toBe(128000); + expect(await maxTokensFor('claude-opus-4-7')).toBe(128000); + expect(await maxTokensFor('claude-sonnet-4-6')).toBe(64000); + }); + + it('honors defaultMaxTokens for unknown models', async () => { + expect(await maxTokensFor('unknown-model', { defaultMaxTokens: 4321 })).toBe(4321); + }); + + it('honors a lower defaultMaxTokens on known models', async () => { + expect(await maxTokensFor('claude-opus-4-7', { defaultMaxTokens: 200 })).toBe(200); + }); + + it('honors explicit defaultMaxTokens above the ceiling for known models', async () => { + expect(await maxTokensFor('claude-opus-4-7', { defaultMaxTokens: 999999 })).toBe(999999); + }); + + it('withMaxCompletionTokens preserves explicit defaultMaxTokens above the ceiling', async () => { + const provider = new AnthropicChatProvider({ + model: 'claude-opus-4-7', + apiKey: 'test-key', + stream: false, + defaultMaxTokens: 999999, + }).withMaxCompletionTokens(1024); + const body = await captureRequestBody(provider); + + expect(body['max_tokens']).toBe(999999); + }); + + it('withMaxCompletionTokens clamps above the ceiling without an explicit override', async () => { + const provider = new AnthropicChatProvider({ + model: 'claude-opus-4-7', + apiKey: 'test-key', + stream: false, + }).withMaxCompletionTokens(999999); + const body = await captureRequestBody(provider); + + expect(body['max_tokens']).toBe(128000); + }); +}); From caf93b606541577fc23dfd42e5faa489883e337b Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 8 Jul 2026 18:49:48 +0800 Subject: [PATCH 3/6] refactor(agent-core-v2): converge thinking config to enabled/effort Port the v1 thinking-config overhaul (#1132's config side) to v2: - ThinkingConfigSchema becomes { enabled, effort, keep }; the mode enum, the separate defaultThinking section, and the KIMI_MODEL_THINKING_MODE / KIMI_MODEL_DEFAULT_THINKING env bindings are removed. - The effort resolver drops the mode/defaultThinking branches and no longer normalizes a requested 'on' to a concrete effort in core; 'on' is taken verbatim and normalization stays at the UI boundary. - OAuth login/refresh and catalog refresh now persist the thinking.enabled value computed by the shared oauth apply/restore logic instead of dropping it and writing the removed default_thinking key, so [thinking] enabled = false actually disables thinking and the login default survives on disk. Mirrors the v1 resolver regression tests and adds a persistence regression for the refresh path. --- .agents/skills/agent-core-dev/config.md | 7 +- .../src/agent/profile/configSection.ts | 31 +-- .../src/agent/profile/profileService.ts | 4 +- .../src/agent/profile/thinking.ts | 33 +-- .../agent-core-v2/src/app/auth/authService.ts | 36 +-- .../src/app/model/modelResolverService.ts | 15 +- .../agent-core-v2/src/app/model/thinking.ts | 26 +-- .../app/modelCatalog/modelCatalogService.ts | 11 +- packages/agent-core-v2/test/auth/auth.test.ts | 18 +- .../test/model/modelResolver.test.ts | 15 +- .../test/modelCatalog/modelCatalog.test.ts | 2 +- .../test/profile/config-state.test.ts | 4 +- .../test/profile/profile-wire.test.ts | 4 +- .../test/profile/thinking.test.ts | 213 +++++++++--------- 14 files changed, 188 insertions(+), 231 deletions(-) diff --git a/.agents/skills/agent-core-dev/config.md b/.agents/skills/agent-core-dev/config.md index 599325a44f..477fd05ecd 100644 --- a/.agents/skills/agent-core-dev/config.md +++ b/.agents/skills/agent-core-dev/config.md @@ -89,7 +89,7 @@ pass `ConfigTarget.Memory` for a per-run override that is never written to disk. - `src/config/config.ts` — `IConfigRegistry` / `IConfigService` tokens, `ConfigSection`, `ConfigEffectiveOverlay`, event types. - `src/config/configService.ts` — `ConfigRegistry` + `ConfigService` impl; self-registers at App scope. - `src/config/toml.ts` — generic snake_case ↔ camelCase machinery plus the registry-aware `transformTomlData` / `applySectionToToml` entry points. Per-domain normalization lives in the section owner's `configSection.ts` (registered as `fromToml` / `toToml`); this module stays free of any other domain's semantics. -- `src/profile/thinking.ts` (owner domain, not `config`) — `resolveThinkingEffort` / `resolveThinkingLevel` helpers; uses the authoritative `ThinkingConfig` from `configSection.ts`. +- `src/profile/thinking.ts` (owner domain, not `config`) — the `resolveThinkingEffort` helper; uses the authoritative `ThinkingConfig` from `configSection.ts`. - `src/config/configPure.ts` — `isPlainObject`, `deepMerge`, `omitUndefined`, `describeUnknownError`. A domain that owns a section keeps the schema in its own `configSection.ts` (e.g. `src/flag/flag.ts` for `experimental`, `src/profile/configSection.ts` for `thinking`, `src/loop/configSection.ts` for `loopControl`). A cross-section env overlay (e.g. the `KIMI_MODEL_*` synthesis) lives in the owning domain too (`src/provider/envOverlay.ts`) and is registered via `IConfigRegistry.registerEffectiveOverlay`. @@ -126,8 +126,7 @@ type-checked against the schema (no magic strings), and nested schemas recurse: ```ts registerSection('thinking', ThinkingConfigSchema, { env: envBindings(ThinkingConfigSchema, { - mode: 'KIMI_MODEL_THINKING_MODE', - effort:'KIMI_MODEL_THINKING_EFFORT', + effort: 'KIMI_MODEL_THINKING_EFFORT', }), }); @@ -238,7 +237,7 @@ When `KIMI_MODEL_NAME` is set, the `provider` domain's `kimiModelEnvOverlay` (`s |---|---|---|---| | `providers` | `provider` | L2 | owner-owned (`IProviderService` CRUD) | | `experimental` | `flag` | L3 | owner-owned | -| `thinking` / `defaultThinking` | `profile` | L4 | owner-owned | +| `thinking` | `profile` | L4 | owner-owned | | `loopControl` | `loop` | L4 | owner-owned (read by `loop` + `profile`) | | `McpServerConfig` (type) | `mcp` | L5 | owner-owned (type only; not a registered section) | | `session` | `config` | L2 | in config | diff --git a/packages/agent-core-v2/src/agent/profile/configSection.ts b/packages/agent-core-v2/src/agent/profile/configSection.ts index 94b6c8449e..0ab37d33d1 100644 --- a/packages/agent-core-v2/src/agent/profile/configSection.ts +++ b/packages/agent-core-v2/src/agent/profile/configSection.ts @@ -1,50 +1,29 @@ /** - * `profile` domain (L4) — `thinking` / `defaultThinking` config-section env bindings. + * `profile` domain (L4) — `thinking` config-section env bindings. * - * Declares the `KIMI_MODEL_THINKING_MODE` / `KIMI_MODEL_THINKING_EFFORT` / - * `KIMI_MODEL_DEFAULT_THINKING` environment bindings (gated on - * `KIMI_MODEL_NAME`). Applied to the effective `thinking` / `defaultThinking` - * values by `config`. + * Declares the `KIMI_MODEL_THINKING_EFFORT` environment binding (gated on + * `KIMI_MODEL_NAME`). Applied to the effective `thinking` value by `config`. */ import { z } from 'zod'; -import { parseBooleanEnv } from '#/_base/utils/env'; -import { type EnvBindings, envBindings } from '#/app/config/config'; +import { envBindings } from '#/app/config/config'; import { registerConfigSection } from '#/app/config/configSectionContributions'; export const THINKING_SECTION = 'thinking'; -export const DEFAULT_THINKING_SECTION = 'defaultThinking'; export const ThinkingConfigSchema = z.object({ - mode: z.enum(['auto', 'on', 'off']).optional(), + enabled: z.boolean().optional(), effort: z.string().optional(), keep: z.string().optional(), }); export type ThinkingConfig = z.infer; -function parseBooleanVar(raw: string): boolean { - const parsed = parseBooleanEnv(raw); - if (parsed === undefined) { - throw new Error(`KIMI_MODEL_DEFAULT_THINKING must be a boolean, got "${raw}".`); - } - return parsed; -} - export const thinkingEnvBindings = envBindings(ThinkingConfigSchema, { - mode: 'KIMI_MODEL_THINKING_MODE', effort: 'KIMI_MODEL_THINKING_EFFORT', }); -export const defaultThinkingEnvBindings: EnvBindings = { - env: 'KIMI_MODEL_DEFAULT_THINKING', - parse: parseBooleanVar, -}; - registerConfigSection(THINKING_SECTION, ThinkingConfigSchema, { env: thinkingEnvBindings, }); -registerConfigSection(DEFAULT_THINKING_SECTION, { parse: (v) => v as boolean }, { - env: defaultThinkingEnvBindings, -}); diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 1ec53ee64c..00f596878b 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -456,8 +456,10 @@ export class AgentProfileService implements IAgentProfileService { private get thinkingLevel(): ThinkingEffort { const stored = this.profileState.thinkingLevel; if (stored === 'off' && this.alwaysThinkingModel) { + // Re-run the resolver so the always_thinking clamp restores the + // configured effort (or the model default) instead of a stale 'off'. return resolveThinkingEffort( - 'on', + stored, this.config.get(THINKING_SECTION), this.tryResolveRawModel(), ); diff --git a/packages/agent-core-v2/src/agent/profile/thinking.ts b/packages/agent-core-v2/src/agent/profile/thinking.ts index a0bbe89ea7..2d737e0210 100644 --- a/packages/agent-core-v2/src/agent/profile/thinking.ts +++ b/packages/agent-core-v2/src/agent/profile/thinking.ts @@ -1,39 +1,16 @@ /** - * `profile` domain — thinking-level resolution helpers. + * `profile` domain — thinking-effort resolution helpers. * - * Resolves the effective `ThinkingEffort` from a requested level, the - * `thinking` config section (`ThinkingConfig`, owned here in `profile`), and - * the `defaultThinking` toggle. Pure functions; own no scoped state. + * Resolves the effective `ThinkingEffort` from a requested effort and the + * `thinking` config section (`ThinkingConfig`, owned here in `profile`). + * Pure functions; own no scoped state. */ import type { ThinkingEffort } from '#/app/llmProtocol/thinkingEffort'; -import { - type ModelThinkingMetadata, - resolveThinkingEffortForModel, -} from '#/app/model/thinking'; +import { type ModelThinkingMetadata, resolveThinkingEffortForModel } from '#/app/model/thinking'; import type { ThinkingConfig } from './configSection'; -export interface ResolveThinkingLevelOptions { - readonly defaultThinking?: boolean; - readonly thinking?: ThinkingConfig; - readonly model?: ModelThinkingMetadata; -} - -export function resolveThinkingLevel( - requestedThinking: string | undefined, - options: ResolveThinkingLevelOptions, -): ThinkingEffort { - const resolvedRequest = - requestedThinking !== undefined && requestedThinking.trim().length > 0 - ? requestedThinking - : options.defaultThinking === false - ? 'off' - : undefined; - - return resolveThinkingEffort(resolvedRequest, options.thinking, options.model); -} - export function resolveThinkingEffort( requested: string | undefined, defaults: ThinkingConfig | undefined, diff --git a/packages/agent-core-v2/src/app/auth/authService.ts b/packages/agent-core-v2/src/app/auth/authService.ts index 75f2135135..e52b69e488 100644 --- a/packages/agent-core-v2/src/app/auth/authService.ts +++ b/packages/agent-core-v2/src/app/auth/authService.ts @@ -77,7 +77,7 @@ import { const TERMINAL_RETENTION_MS = 5 * 60 * 1000; const DEFAULT_DEVICE_EXPIRES_IN_SEC = 15 * 60; const DEFAULT_MODEL_SECTION = 'defaultModel'; -const DEFAULT_THINKING_SECTION = 'defaultThinking'; +const THINKING_SECTION = 'thinking'; const SERVICES_SECTION = 'services'; interface FlowState { @@ -93,10 +93,6 @@ interface FlowState { resolvedAt: string | undefined; } -type ManagedKimiUserConfigShape = ManagedKimiConfigShape & { - defaultThinking?: boolean; -}; - export class OAuthService extends Disposable implements IOAuthService { declare readonly _serviceBrand: undefined; private readonly flows = new Map(); @@ -301,7 +297,7 @@ export class OAuthService extends Disposable implements IOAuthService { next, preserveUserProviderAliases(current, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys), ); - restoreDefaultSelection(next, current.defaultModel, current.defaultThinking); + restoreDefaultSelection(next, current.defaultModel, current.thinking?.enabled); clampDanglingDefault(next); if (providerModelsEqual(current, next, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys)) { @@ -314,7 +310,7 @@ export class OAuthService extends Disposable implements IOAuthService { await this.config.replace(PROVIDERS_SECTION, next.providers); await this.config.replace(MODELS_SECTION, next.models ?? {}); await this.config.set(DEFAULT_MODEL_SECTION, next.defaultModel); - await this.config.set(DEFAULT_THINKING_SECTION, next.defaultThinking); + await this.config.set(THINKING_SECTION, next.thinking); changed.push({ provider_id: KIMI_CODE_PROVIDER_NAME, provider_name: 'Kimi Code', @@ -336,20 +332,21 @@ export class OAuthService extends Disposable implements IOAuthService { return result; } - private readUserConfigShape(): ManagedKimiUserConfigShape { + private readUserConfigShape(): ManagedKimiConfigShape { const providers = this.config.inspect>(PROVIDERS_SECTION).userValue ?? {}; const models = this.config.inspect>(MODELS_SECTION).userValue ?? {}; const services = this.config.inspect(SERVICES_SECTION).userValue; const defaultModel = this.config.inspect(DEFAULT_MODEL_SECTION).userValue; - const defaultThinking = this.config.inspect(DEFAULT_THINKING_SECTION).userValue; + const thinking = + this.config.inspect(THINKING_SECTION).userValue; return { providers: { ...providers } as ManagedKimiConfigShape['providers'], models: { ...models } as ManagedKimiConfigShape['models'], services: services === undefined ? undefined : { ...services }, defaultModel, - defaultThinking, + thinking: thinking === undefined ? undefined : { ...thinking }, }; } @@ -463,7 +460,7 @@ export class OAuthService extends Disposable implements IOAuthService { return; } if (cleanup.defaultModelCleared) { - next.defaultThinking = undefined; + next.thinking = undefined; } if (cleanup.removedProvider) { await this.config.replace(PROVIDERS_SECTION, next.providers); @@ -476,7 +473,7 @@ export class OAuthService extends Disposable implements IOAuthService { } if (cleanup.defaultModelCleared) { await this.config.set(DEFAULT_MODEL_SECTION, undefined); - await this.config.set(DEFAULT_THINKING_SECTION, undefined); + await this.config.set(THINKING_SECTION, undefined); } } @@ -765,20 +762,25 @@ function restoreProviderAliases( } function restoreDefaultSelection( - config: ManagedKimiUserConfigShape, + config: ManagedKimiConfigShape, defaultModel: string | undefined, - defaultThinking: boolean | undefined, + defaultEnabled: boolean | undefined, ): void { if (defaultModel === undefined || config.models?.[defaultModel] === undefined) return; config.defaultModel = defaultModel; + // A refresh may have just learned that the default model cannot disable + // thinking — never restore a stale thinking-off selection onto it. const capabilities = managedModel(config, defaultModel)?.capabilities ?? []; - config.defaultThinking = capabilities.includes('always_thinking') ? true : defaultThinking; + const enabled = capabilities.includes('always_thinking') ? true : defaultEnabled; + if (enabled !== undefined) { + config.thinking = { ...config.thinking, enabled }; + } } -function clampDanglingDefault(config: ManagedKimiUserConfigShape): void { +function clampDanglingDefault(config: ManagedKimiConfigShape): void { if (config.defaultModel !== undefined && config.models?.[config.defaultModel] === undefined) { config.defaultModel = undefined; - config.defaultThinking = undefined; + config.thinking = undefined; } } diff --git a/packages/agent-core-v2/src/app/model/modelResolverService.ts b/packages/agent-core-v2/src/app/model/modelResolverService.ts index f35d810990..16369fbb72 100644 --- a/packages/agent-core-v2/src/app/model/modelResolverService.ts +++ b/packages/agent-core-v2/src/app/model/modelResolverService.ts @@ -48,7 +48,7 @@ import { resolveThinkingEffortForModel } from './thinking'; /** Shape of the `thinking` config section (owned by `profile`); only the * fields the resolver needs to mirror the production default are read here. */ interface ThinkingSection { - readonly mode?: string; + readonly enabled?: boolean; readonly effort?: string; } @@ -149,7 +149,7 @@ export class ModelResolverService extends Disposable implements IModelResolver { // Apply the production default thinking effort so a plain `model.request()` // behaves like the agent path (which routes through `profile` and reads the - // same `thinking` / `defaultThinking` config). Required for models whose + // same `thinking` config). Required for models whose // endpoint rejects a request that omits thinking (e.g. kimi-k2.7 over the // Anthropic protocol returns 400 unless `thinking.type === 'enabled'`). const effort = this.resolveDefaultThinking(model, alwaysThinking); @@ -157,10 +157,9 @@ export class ModelResolverService extends Disposable implements IModelResolver { } /** - * Mirror `profile`'s `resolveThinkingLevel` / `resolveThinkingEffort` so the - * god-object's default matches the production agent path: - * - an explicit `defaultThinking === false` or `thinking.mode === 'off'` - * turns thinking off; + * Mirror `profile`'s `resolveThinkingEffort` so the god-object's default + * matches the production agent path: + * - `thinking.enabled === false` turns thinking off; * - otherwise the configured `thinking.effort` is used, falling back to the * model's declared default effort / middle supported effort / boolean `on`; * - an `always_thinking` model clamps an explicit "off" back to on. @@ -169,13 +168,11 @@ export class ModelResolverService extends Disposable implements IModelResolver { model: ModelConfig, alwaysThinking: boolean, ): ThinkingEffort { - const defaultThinking = this.config.get('defaultThinking'); const thinking = this.config.get('thinking'); return resolveThinkingEffortForModel( undefined, { - defaultThinking, - mode: thinking?.mode, + enabled: thinking?.enabled, effort: thinking?.effort, }, { ...model, alwaysThinking }, diff --git a/packages/agent-core-v2/src/app/model/thinking.ts b/packages/agent-core-v2/src/app/model/thinking.ts index 20850143ad..3c373d11bf 100644 --- a/packages/agent-core-v2/src/app/model/thinking.ts +++ b/packages/agent-core-v2/src/app/model/thinking.ts @@ -10,8 +10,7 @@ import type { ModelCapability } from '#/app/llmProtocol/capability'; import type { ThinkingEffort } from '#/app/llmProtocol/thinkingEffort'; export interface ThinkingDefaults { - readonly defaultThinking?: boolean; - readonly mode?: string; + readonly enabled?: boolean; readonly effort?: string; } @@ -77,13 +76,6 @@ export function defaultThinkingEffortForModel( return 'on'; } -function enabledThinkingEffortForModel( - model: ModelThinkingMetadata | undefined, -): ThinkingEffort { - const effort = defaultThinkingEffortForModel(model); - return effort === 'off' ? 'on' : effort; -} - export function resolveThinkingEffortForModel( requested: string | undefined, defaults: ThinkingDefaults | undefined, @@ -93,19 +85,21 @@ export function resolveThinkingEffortForModel( const normalized = nonEmpty(requested)?.toLowerCase(); let effort: ThinkingEffort; if (normalized !== undefined) { - effort = - normalized === 'on' - ? configured ?? enabledThinkingEffortForModel(model) - : (normalized as ThinkingEffort); - } else if (defaults?.mode === 'on') { - effort = configured ?? enabledThinkingEffortForModel(model); - } else if (defaults?.defaultThinking === false || defaults?.mode === 'off') { + // A requested effort is taken verbatim — including 'on', which is a valid + // wire value for boolean thinking models. Normalizing 'on' to a concrete + // effort is the UI boundary's job, not the resolver's (v1 parity). + effort = normalized as ThinkingEffort; + } else if (defaults?.enabled === false) { effort = 'off'; } else { effort = configured ?? defaultThinkingEffortForModel(model); } if (effort === 'off' && model?.alwaysThinking === true) { + // always_thinking forces thinking on, but an explicitly configured effort + // is still honored — `enabled = false` only expresses the intent to + // disable, it should not also discard a chosen effort. Fall back to the + // model default only when no effort is configured. return configured ?? defaultThinkingEffortForModel(model); } return effort; diff --git a/packages/agent-core-v2/src/app/modelCatalog/modelCatalogService.ts b/packages/agent-core-v2/src/app/modelCatalog/modelCatalogService.ts index 8a89e96020..9673bdd67e 100644 --- a/packages/agent-core-v2/src/app/modelCatalog/modelCatalogService.ts +++ b/packages/agent-core-v2/src/app/modelCatalog/modelCatalogService.ts @@ -48,7 +48,7 @@ import { } from './modelCatalog'; const DEFAULT_MODEL_SECTION = 'defaultModel'; -const DEFAULT_THINKING_SECTION = 'defaultThinking'; +const THINKING_SECTION = 'thinking'; export class ModelCatalogService implements IModelCatalogService { declare readonly _serviceBrand: undefined; @@ -147,12 +147,13 @@ export class ModelCatalogService implements IModelCatalogService { const models = this.config.inspect>(MODELS_SECTION).userValue ?? {}; const defaultModel = this.config.inspect(DEFAULT_MODEL_SECTION).userValue; - const defaultThinking = this.config.inspect(DEFAULT_THINKING_SECTION).userValue; + const thinking = + this.config.inspect(THINKING_SECTION).userValue; return { providers: { ...providers } as ManagedKimiConfigShape['providers'], models: { ...models } as ManagedKimiConfigShape['models'], defaultModel, - defaultThinking, + thinking: thinking === undefined ? undefined : { ...thinking }, }; } @@ -185,8 +186,8 @@ export class ModelCatalogService implements IModelCatalogService { if (patch.defaultModel !== undefined) { await this.config.set(DEFAULT_MODEL_SECTION, patch.defaultModel); } - if (patch['defaultThinking'] !== undefined) { - await this.config.set(DEFAULT_THINKING_SECTION, patch['defaultThinking']); + if (patch.thinking !== undefined) { + await this.config.set(THINKING_SECTION, patch.thinking); } return this.readUserConfigShape(); } diff --git a/packages/agent-core-v2/test/auth/auth.test.ts b/packages/agent-core-v2/test/auth/auth.test.ts index d432f0e2ab..cd0182d242 100644 --- a/packages/agent-core-v2/test/auth/auth.test.ts +++ b/packages/agent-core-v2/test/auth/auth.test.ts @@ -55,7 +55,7 @@ describe('OAuthService', () => { let models: Record; let services: Record | undefined; let defaultModel: string | undefined; - let defaultThinking: boolean | undefined; + let thinking: { enabled?: boolean; effort?: string } | undefined; let toolkit: FakeToolkit; let providerSet: ReturnType; let configSet: ReturnType; @@ -80,14 +80,14 @@ describe('OAuthService', () => { models = {}; services = undefined; defaultModel = undefined; - defaultThinking = undefined; + thinking = undefined; configSet = vi.fn(async (domain: string, value: unknown) => { if (domain === 'defaultModel') { defaultModel = value as string | undefined; return; } - if (domain === 'defaultThinking') { - defaultThinking = value as boolean | undefined; + if (domain === 'thinking') { + thinking = value as { enabled?: boolean; effort?: string } | undefined; return; } throw new Error(`unexpected config set: ${domain}`); @@ -162,7 +162,7 @@ describe('OAuthService', () => { } function configBacking(): Record { - return { providers, models, services, defaultModel, defaultThinking }; + return { providers, models, services, defaultModel, thinking }; } function stubManagedModelsFetch(): ReturnType { @@ -432,7 +432,7 @@ describe('OAuthService', () => { }, }; defaultModel = 'kimi-code/kimi-k2'; - defaultThinking = true; + thinking = { enabled: true }; const svc = createService(); const result = await svc.logout(OAUTH_PROVIDER); @@ -449,7 +449,7 @@ describe('OAuthService', () => { }, }); expect(configSet).toHaveBeenCalledWith('defaultModel', undefined); - expect(configSet).toHaveBeenCalledWith('defaultThinking', undefined); + expect(configSet).toHaveBeenCalledWith('thinking', undefined); }); it('logout removes managed web services while preserving unrelated services', async () => { @@ -577,6 +577,10 @@ describe('OAuthService', () => { }), ); expect(configSet).toHaveBeenCalledWith('defaultModel', 'kimi-code/kimi-k2'); + // Regression: the `[thinking] enabled` value computed by the shared oauth + // apply logic must be persisted, not dropped (previously only the legacy + // `default_thinking` key was written). + expect(configSet).toHaveBeenCalledWith('thinking', { enabled: true }); expect(events).toEqual([ { type: 'event.model_catalog.changed', diff --git a/packages/agent-core-v2/test/model/modelResolver.test.ts b/packages/agent-core-v2/test/model/modelResolver.test.ts index beefba2dd3..b27d4369c6 100644 --- a/packages/agent-core-v2/test/model/modelResolver.test.ts +++ b/packages/agent-core-v2/test/model/modelResolver.test.ts @@ -7,8 +7,8 @@ * (`requireProviderApiKey`). The resolver's `AuthProvider` must return the * token as `apiKey` (not wrapped in `headers`), so a resolved Model can * authenticate against its endpoint. - * 2. Default thinking — the resolver reads the `thinking` / `defaultThinking` - * config sections and applies the same default effort the production agent + * 2. Default thinking — the resolver reads the `thinking` config section and + * applies the same default effort the production agent * path (via `profile`) does, so a plain `model.request()` behaves * identically (some endpoints reject a request that omits thinking). */ @@ -679,13 +679,8 @@ describe('ModelResolverService', () => { expect(ix.get(IModelResolver).resolve('m').thinkingEffort).toBe('medium'); }); - it('is off (null) when defaultThinking is false', () => { - configValues['defaultThinking'] = false; - expect(resolveEffort()).toBeNull(); - }); - - it('is off (null) when thinking.mode is "off"', () => { - configValues['thinking'] = { mode: 'off' }; + it('is off (null) when thinking.enabled is false', () => { + configValues['thinking'] = { enabled: false }; expect(resolveEffort()).toBeNull(); }); @@ -695,7 +690,7 @@ describe('ModelResolverService', () => { }); it('clamps an explicit off back to on for always_thinking models', () => { - configValues['defaultThinking'] = false; + configValues['thinking'] = { enabled: false }; expect(resolveEffort(['always_thinking'])).toBe('on'); }); }); diff --git a/packages/agent-core-v2/test/modelCatalog/modelCatalog.test.ts b/packages/agent-core-v2/test/modelCatalog/modelCatalog.test.ts index 01e416943c..8989dd7e96 100644 --- a/packages/agent-core-v2/test/modelCatalog/modelCatalog.test.ts +++ b/packages/agent-core-v2/test/modelCatalog/modelCatalog.test.ts @@ -29,7 +29,7 @@ interface Backing { providers: Record; models: Record; defaultModel?: string; - defaultThinking?: boolean; + thinking?: { enabled?: boolean; effort?: string }; } function seedBacking(): Backing { diff --git a/packages/agent-core-v2/test/profile/config-state.test.ts b/packages/agent-core-v2/test/profile/config-state.test.ts index 3b832e06af..8f5f447917 100644 --- a/packages/agent-core-v2/test/profile/config-state.test.ts +++ b/packages/agent-core-v2/test/profile/config-state.test.ts @@ -262,10 +262,10 @@ describe('ConfigState thinking clamp for always-thinking models', () => { expect(profile.data().thinkingLevel).toBe('off'); }); - it('maps thinking on to the model default effort', () => { + it('keeps an explicit on request verbatim (normalization is the UI boundary)', () => { profile.update({ modelAlias: 'kimi-code/custom', thinkingLevel: 'on' }); - expect(profile.data().thinkingLevel).toBe('max'); + expect(profile.data().thinkingLevel).toBe('on'); }); it('re-clamps when switching to an always-on model after thinking was off', () => { diff --git a/packages/agent-core-v2/test/profile/profile-wire.test.ts b/packages/agent-core-v2/test/profile/profile-wire.test.ts index 186447b928..8dadbf4924 100644 --- a/packages/agent-core-v2/test/profile/profile-wire.test.ts +++ b/packages/agent-core-v2/test/profile/profile-wire.test.ts @@ -194,8 +194,8 @@ describe('AgentProfileService (wire-backed config.update)', () => { const model = modelOf(wire); expect(model.profileName).toBe(DEFAULT_AGENT_PROFILE_NAME); expect(model.systemPrompt).toBe('You are helpful.'); - // Explicit 'on' persists as the boolean-thinking signal when no model - // declares named efforts. + // Explicit 'on' persists verbatim — normalizing it to a concrete effort + // is the UI boundary's job, not the resolver's. expect(model.thinkingLevel).toBe('on'); expect(svc.getSystemPrompt()).toBe('You are helpful.'); diff --git a/packages/agent-core-v2/test/profile/thinking.test.ts b/packages/agent-core-v2/test/profile/thinking.test.ts index 155b2d8237..3aa0ef9829 100644 --- a/packages/agent-core-v2/test/profile/thinking.test.ts +++ b/packages/agent-core-v2/test/profile/thinking.test.ts @@ -1,120 +1,127 @@ import { describe, expect, it } from 'vitest'; -import { - resolveThinkingEffort, - resolveThinkingLevel, -} from '#/agent/profile/thinking'; - -describe('profile/thinking', () => { - describe('resolveThinkingEffort', () => { - it('returns config effort when no request', () => { - expect(resolveThinkingEffort(undefined, { effort: 'low' })).toBe('low'); - }); - - it('defaults to off when no model supports thinking', () => { - expect(resolveThinkingEffort(undefined, undefined)).toBe('off'); - }); - - it('uses the model default effort when configured', () => { - expect( - resolveThinkingEffort(undefined, undefined, { - capabilities: ['thinking'], - supportEfforts: ['low', 'medium', 'max'], - defaultEffort: 'max', - }), - ).toBe('max'); - }); - - it('uses the middle supported effort when no default effort is configured', () => { - expect( - resolveThinkingEffort(undefined, undefined, { - capabilities: ['thinking'], - supportEfforts: ['low', 'medium', 'max'], - }), - ).toBe('medium'); - }); - - it('uses boolean on for thinking models without named efforts', () => { - expect( - resolveThinkingEffort(undefined, undefined, { - capabilities: ['thinking'], - }), - ).toBe('on'); - }); - - it('returns off when config mode is off and no request is provided', () => { - expect(resolveThinkingEffort(undefined, { mode: 'off' })).toBe('off'); - }); - - it('returns model default when config mode is on without explicit effort', () => { - expect( - resolveThinkingEffort(undefined, { mode: 'on' }, { - capabilities: ['thinking'], - supportEfforts: ['low', 'high'], - }), - ).toBe('high'); - }); +import { resolveThinkingEffort } from '#/agent/profile/thinking'; +import { defaultThinkingEffortForModel } from '#/app/model/thinking'; + +const booleanModel = { capabilities: ['thinking'] }; +const effortModel = { + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'high'], +}; +const effortModelWithDefault = { + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + defaultEffort: 'max', +}; +const alwaysThinkingModel = { + capabilities: ['thinking', 'always_thinking'], + alwaysThinking: true, +}; +const alwaysThinkingEffortModel = { + capabilities: ['thinking', 'always_thinking'], + alwaysThinking: true, + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'high', +}; +const nonThinkingModel = { capabilities: ['tool_use'] }; + +describe('defaultThinkingEffortForModel', () => { + it('returns off for models that do not support thinking (or an unknown model)', () => { + expect(defaultThinkingEffortForModel(undefined)).toBe('off'); + expect(defaultThinkingEffortForModel(nonThinkingModel)).toBe('off'); + expect(defaultThinkingEffortForModel({})).toBe('off'); + }); - it('returns explicit effort when both mode=on and effort are set', () => { - expect(resolveThinkingEffort(undefined, { mode: 'on', effort: 'medium' })).toBe('medium'); - }); + it('returns the declared defaultEffort for effort-capable models', () => { + expect(defaultThinkingEffortForModel(effortModelWithDefault)).toBe('max'); + }); - it('returns off when mode is off even if effort is set', () => { - expect(resolveThinkingEffort(undefined, { mode: 'off', effort: 'high' })).toBe('off'); - }); + it('falls back to the middle supportEfforts entry when defaultEffort is absent', () => { + // odd length -> exact middle + expect(defaultThinkingEffortForModel(effortModel)).toBe('medium'); + // even length -> upper-middle index + expect( + defaultThinkingEffortForModel({ + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + }), + ).toBe('high'); + expect( + defaultThinkingEffortForModel({ capabilities: ['thinking'], supportEfforts: ['low'] }), + ).toBe('low'); + }); - it('honors explicit "off"', () => { - expect(resolveThinkingEffort('off', { effort: 'high' })).toBe('off'); - }); + it('returns on for boolean thinking models (thinking support without supportEfforts)', () => { + expect(defaultThinkingEffortForModel(booleanModel)).toBe('on'); + expect(defaultThinkingEffortForModel({ capabilities: ['always_thinking'] })).toBe('on'); + expect(defaultThinkingEffortForModel({ adaptiveThinking: true })).toBe('on'); + }); +}); - it('maps "on" to the configured effort', () => { - expect(resolveThinkingEffort('on', { effort: 'medium' })).toBe('medium'); - }); +describe('resolveThinkingEffort', () => { + it('returns the requested effort verbatim when one is provided', () => { + expect(resolveThinkingEffort('low', undefined, effortModel)).toBe('low'); + expect(resolveThinkingEffort('on', { enabled: false }, booleanModel)).toBe('on'); + expect(resolveThinkingEffort('off', undefined, booleanModel)).toBe('off'); + // 'on' is a valid wire value, not a request for the configured effort — + // normalizing it to a concrete effort is the UI boundary's job (v1 parity). + expect(resolveThinkingEffort('on', { effort: 'medium' }, effortModel)).toBe('on'); + }); - it('maps "on" to the model default when config has no effort', () => { - expect( - resolveThinkingEffort('on', undefined, { - capabilities: ['thinking'], - supportEfforts: ['low', 'medium', 'max'], - }), - ).toBe('medium'); - }); + it('returns off when config.enabled is false and no effort is requested', () => { + expect(resolveThinkingEffort(undefined, { enabled: false }, effortModel)).toBe('off'); + expect(resolveThinkingEffort(undefined, { enabled: false, effort: 'high' }, effortModel)).toBe( + 'off', + ); + }); - it('parses a named effort', () => { - expect(resolveThinkingEffort('xhigh', undefined)).toBe('xhigh'); - }); + it('uses config.effort as the default effort', () => { + expect(resolveThinkingEffort(undefined, { effort: 'high' }, effortModel)).toBe('high'); + expect(resolveThinkingEffort(undefined, { enabled: true, effort: 'low' }, effortModel)).toBe( + 'low', + ); + }); - it('carries custom requested efforts through', () => { - expect(resolveThinkingEffort('bogus', { effort: 'low' })).toBe('bogus'); - }); + it('falls back to defaultThinkingEffortForModel(model) when no effort is configured', () => { + expect(resolveThinkingEffort(undefined, undefined, effortModel)).toBe('medium'); + expect(resolveThinkingEffort(undefined, {}, booleanModel)).toBe('on'); + expect(resolveThinkingEffort(undefined, undefined, undefined)).toBe('off'); + }); - it('normalizes requested effort case and whitespace', () => { - expect(resolveThinkingEffort(' Medium ', undefined)).toBe('medium'); - expect(resolveThinkingEffort('OFF', { mode: 'on' })).toBe('off'); - }); + it('forces always-thinking models back on when the resolved effort is off', () => { + expect(resolveThinkingEffort('off', undefined, alwaysThinkingModel)).toBe('on'); + expect(resolveThinkingEffort(undefined, { enabled: false }, alwaysThinkingModel)).toBe('on'); + }); - it('clamps off to model default for always-thinking models', () => { - expect( - resolveThinkingEffort('off', undefined, { - capabilities: ['always_thinking'], - alwaysThinking: true, - supportEfforts: ['low', 'medium', 'max'], - }), - ).toBe('medium'); - }); + it('honors a configured effort when clamping always-thinking models back on', () => { + // enabled=false resolves to 'off', then always_thinking clamps back on; + // an explicitly configured effort is preserved instead of falling back to + // the model default. + expect( + resolveThinkingEffort( + undefined, + { enabled: false, effort: 'max' }, + alwaysThinkingEffortModel, + ), + ).toBe('max'); + // without an explicit effort, fall back to the model's default effort. + expect(resolveThinkingEffort(undefined, { enabled: false }, alwaysThinkingEffortModel)).toBe( + 'high', + ); }); - describe('resolveThinkingLevel', () => { - it('uses requested level when provided', () => { - expect(resolveThinkingLevel('high', {})).toBe('high'); - }); + it('does not force on for models that are not always-thinking', () => { + expect(resolveThinkingEffort('off', undefined, booleanModel)).toBe('off'); + expect(resolveThinkingEffort(undefined, { enabled: false }, booleanModel)).toBe('off'); + }); - it('returns "off" when defaultThinking is false and no request', () => { - expect(resolveThinkingLevel(undefined, { defaultThinking: false })).toBe('off'); - }); + it('carries custom requested efforts through', () => { + expect(resolveThinkingEffort('xhigh', undefined)).toBe('xhigh'); + expect(resolveThinkingEffort('bogus', { effort: 'low' })).toBe('bogus'); + }); - it('honors thinking.mode = off', () => { - expect(resolveThinkingLevel(undefined, { thinking: { mode: 'off' } })).toBe('off'); - }); + it('normalizes requested effort case and whitespace', () => { + expect(resolveThinkingEffort(' Medium ', undefined)).toBe('medium'); + expect(resolveThinkingEffort('OFF', { effort: 'high' })).toBe('off'); }); }); From c5551da7aea544118d3719e5f930f048f8f8ac83 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 8 Jul 2026 19:01:32 +0800 Subject: [PATCH 4/6] docs(agent-core-v2): fix stale loop-event comments after wire parity The v1.4 wire-parity alignment switched the v2 live loop to stream turns as context.append_loop_event records, but three comments still described the old world (restore-only Op, "v2 never emits loop events"). Update them to match the actual write path: non-loop appends use append_message, the loop persists loop events byte-compatible with v1, and the fold runs both at live dispatch time and on replay. --- .../src/agent/contextMemory/contextOps.ts | 27 ++++++++++--------- .../src/agent/contextMemory/loopEventFold.ts | 24 +++++++++-------- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts index 13bbe7a2cf..7e02f8e503 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts @@ -2,7 +2,8 @@ * `contextMemory` domain (L4) — wire Model (`ContextModel`) and the wire-protocol * 1.4 Ops `context.append_message` (`contextAppendMessage`) / `context.clear` * (`contextClear`) / `context.apply_compaction` (`contextApplyCompaction`) / - * `context.undo` (`contextUndo`) for the per-agent conversation history, plus the + * `context.undo` (`contextUndo`) / `context.append_loop_event` + * (`contextAppendLoopEvent`) for the per-agent conversation history, plus the * legacy `context.splice` (`contextSplice`) Op. * * Declares the history as `ContextMessage[]` (initial `[]`); every Op's `apply` @@ -11,13 +12,13 @@ * carries no non-determinism — message ids are stamped at the dispatch call site * (`AgentContextMemoryService.append`), never inside `apply`. * - * The live write path emits the 1.4 Ops (`append_message` / `clear` / - * `apply_compaction` / `undo`); assistant and tool messages are persisted already - * folded (the loop appends whole messages, not raw loop events), so on-disk - * records use the 1.4 type names. Sessions written by the v1 loop stream a turn - * as `context.append_loop_event` records instead; `contextAppendLoopEvent` folds - * them back into assistant / tool messages at restore time (see - * `loopEventFold.ts`) so those sessions replay identically. `context.splice` (the + * The live write path emits the 1.4 Ops: non-loop appends (user prompts, + * injections, hook/task notices) go on the wire as `append_message`, while the + * agent loop streams each turn as `context.append_loop_event` records — the + * same on-disk shape the v1 loop writes — and `contextAppendLoopEvent` folds + * them into assistant / tool messages (see `loopEventFold.ts`) both at live + * dispatch time and on replay, so v1- and v2-written sessions reduce + * identically. `context.splice` (the * pre-1.4 primitive) stays registered so sessions written at wire protocol 1.5 * still replay (newer-version passthrough, no migration) and for the few internal * single-delete mutations that have no 1.4 spelling. @@ -145,10 +146,12 @@ export interface ContextLoopEventPayload { } /** - * Restore-only Op: folds a v1 `context.append_loop_event` record into the - * history (see `loopEventFold.ts`). Never dispatched by the v2 live loop, so it - * is never persisted by v2 — registering it lets `WireService.replay` reduce - * v1-loop sessions instead of skipping the record. + * Folds a `context.append_loop_event` record into the history (see + * `loopEventFold.ts`). Since the v1.4 wire-parity alignment the v2 live loop + * dispatches (and persists) these records itself — one per streamed step + * fragment, byte-compatible with the v1 loop — so the same fold runs both on + * live dispatch and when `WireService.replay` reduces v1- or v2-written + * sessions. */ export const contextAppendLoopEvent = defineOp(ContextModel, 'context.append_loop_event', { apply: (state, p: ContextLoopEventPayload): ContextMessage[] => diff --git a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts index 7ce9ae4dd1..d2239522ad 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts @@ -1,16 +1,18 @@ /** - * `contextMemory` loop-event fold — restore-time reduction of v1 - * `context.append_loop_event` records into folded `ContextMessage`s. + * `contextMemory` loop-event fold — reduction of `context.append_loop_event` + * records into folded `ContextMessage`s. * - * v2's agent loop persists assistant / tool messages already folded - * (`context.append_message`); it never emits loop events. Sessions written by - * the v1 loop (`packages/agent-core`), however, stream a turn as - * `context.append_loop_event` records (`step.begin` / `content.part` / - * `tool.call` / `tool.result` / `step.end`) and never write a folded assistant - * message. Without this fold, `WireService.replay` skips those records (no Op - * is registered for the type) and the restored `ContextModel` — and every - * consumer built on it (`/messages`, `/snapshot`, live resume) — shows only the - * user prompts. + * Both loops stream a turn as `context.append_loop_event` records + * (`step.begin` / `content.part` / `tool.call` / `tool.result` / `step.end`) + * and never write a folded assistant message: the v1 loop + * (`packages/agent-core`) always has, and since the v1.4 wire-parity alignment + * the v2 live loop emits the same records (`LoopService` → + * `ContextMemory.appendLoopEvent`), keeping the on-disk shape byte-compatible. + * This fold turns them into assistant / tool messages — at live dispatch time + * and again when `WireService.replay` restores a session. Without it, replay + * would skip those records (no Op is registered for the type) and the restored + * `ContextModel` — and every consumer built on it (`/messages`, `/snapshot`, + * live resume) — would show only the user prompts. * * Semantics mirror v1's `ContextMemory.appendLoopEvent` * (`packages/agent-core/src/agent/context/index.ts`) and the transcript From 7c6e4262047ac6358f3f724496e86163b574d608 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 8 Jul 2026 19:58:45 +0800 Subject: [PATCH 5/6] fix(agent-core-v2): load workspace additional dirs on session create and resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /add-dir command persisted remembered dirs to .kimi-code/local.toml, but session materialization never read them back and offered no caller additionalDirs entry point — a remembered dir silently stopped applying to new, resumed, and forked sessions. Mirror v1's createSession/resumeSession: merge the project-local local.toml dirs with caller-supplied additionalDirs (relative paths resolve against workDir) and seed the session workspace context in materializeSession, so create/resume/fork all pick them up. A broken local.toml fails the create loudly with CONFIG_INVALID, same as v1. Tests mirror v1's runtime coverage for the load/merge/dedupe/resume/fork scenarios. --- .../app/sessionLifecycle/sessionLifecycle.ts | 2 + .../sessionLifecycleService.ts | 19 +++ .../sessionLifecycle/sessionLifecycle.test.ts | 153 +++++++++++++++++- 3 files changed, 173 insertions(+), 1 deletion(-) diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts index 9f095599af..41db46b779 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts @@ -21,6 +21,8 @@ import type { Hooks } from '#/hooks'; export interface CreateSessionOptions { readonly sessionId: string; readonly workDir: string; + /** Extra workspace roots for this session; relative paths resolve against workDir. */ + readonly additionalDirs?: readonly string[]; } export interface ForkSessionOptions { diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index 47293b2340..f8e3a1f96d 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -41,6 +41,8 @@ import { ISessionExternalHooksService } from '#/session/externalHooks/externalHo import { ISessionContext, sessionContextSeed } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { IWorkspaceLocalConfigService } from '#/app/workspaceLocalConfig/workspaceLocalConfig'; import { createHooks } from '#/hooks'; import { AGENT_WIRE_PROTOCOL_VERSION, @@ -91,6 +93,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec @IAppendLogStore private readonly appendLogStore: IAppendLogStore, @IAtomicDocumentStore private readonly docs: IAtomicDocumentStore, @IWorkspaceRegistry private readonly workspaceRegistry: IWorkspaceRegistry, + @IWorkspaceLocalConfigService + private readonly workspaceLocalConfig: IWorkspaceLocalConfigService, @IEventService private readonly event: IEventService, ) { super(); @@ -120,6 +124,16 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec scope: (subKey?: string): string => subKey === undefined || subKey === '' ? sessionScope : `${sessionScope}/${subKey}`, }; + // Merge the project-local `.kimi-code/local.toml` additional dirs with the + // caller-supplied ones (relative paths resolve against workDir), mirroring + // v1's createSession/resumeSession. A broken local.toml fails the create + // loudly with CONFIG_INVALID, same as v1. + const localWorkspaceDirs = await this.workspaceLocalConfig.readAdditionalDirs(opts.workDir); + const callerAdditionalDirs = await this.workspaceLocalConfig.resolveAdditionalDirs( + opts.workDir, + opts.additionalDirs ?? [], + ); + const additionalDirs = [...localWorkspaceDirs.additionalDirs, ...callerAdditionalDirs]; // Wait for the host-environment probe to complete before creating any // Session scope — Session/Agent-scope services (bash, permission policies, // path-access) read `IHostEnvironment.osKind` / `pathClass` / `homeDir` @@ -134,6 +148,11 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec extra: [...sessionContextSeed(ctx)], }, ) as ISessionScopeHandle; + if (additionalDirs.length > 0) { + // De-duplication happens inside setAdditionalDirs (resolve + Set), + // matching v1's normalizeAdditionalDirs. + handle.accessor.get(ISessionWorkspaceContext).setAdditionalDirs(additionalDirs); + } this.sessions.set(opts.sessionId, handle); await handle.accessor.get(ISessionMetadata).ready; void handle.accessor.get(ISessionSkillCatalog).ready; diff --git a/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts index e0995f1ef0..9f4e234476 100644 --- a/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { isAbsolute, resolve } from 'node:path'; + import { InstantiationType } from '#/_base/di/extensions'; import { Disposable } from '#/_base/di/lifecycle'; import { @@ -16,15 +18,20 @@ import { type AgentTaskHooks, IAgentLifecycleService, } from '#/session/agentLifecycle/agentLifecycle'; +import { MAIN_AGENT_ID } from '#/session/agentLifecycle/mainAgent'; import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; import { SessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycleService'; import { ISessionExternalHooksService } from '#/session/externalHooks/externalHooks'; import { createHooks } from '#/hooks'; +import { ISessionActivity } from '#/session/sessionActivity/sessionActivity'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; -import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; +import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IWorkspaceLocalConfigService } from '#/app/workspaceLocalConfig/workspaceLocalConfig'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { SessionWorkspaceContextService } from '#/session/workspaceContext/workspaceContextService'; import { IWorkspaceRegistry, type Workspace } from '#/app/workspaceRegistry/workspaceRegistry'; function bootstrapStub(): IBootstrapService { @@ -111,6 +118,23 @@ function workspaceRegistryStub(): IWorkspaceRegistry { }; } +function workspaceLocalConfigStub( + localDirs: readonly string[] = [], +): IWorkspaceLocalConfigService { + return { + _serviceBrand: undefined, + readAdditionalDirs: (workDir: string) => + Promise.resolve({ + projectRoot: workDir, + configPath: `${workDir}/.kimi-code/local.toml`, + additionalDirs: [...localDirs], + }), + resolveAdditionalDirs: (baseDir: string, dirs: readonly string[]) => + Promise.resolve(dirs.map((d) => (isAbsolute(d) ? resolve(d) : resolve(baseDir, d)))), + appendAdditionalDir: () => Promise.reject(new Error('not implemented')), + }; +} + function sessionIndexStub(): ISessionIndex { return { _serviceBrand: undefined, @@ -239,6 +263,7 @@ describe('SessionLifecycleService', () => { stubPair(IAtomicDocumentStore, atomicDocumentStoreStub()), stubPair(IEventService, eventStub()), stubPair(IAgentLifecycleService, agentLifecycleStub()), + stubPair(IWorkspaceLocalConfigService, workspaceLocalConfigStub()), ...extra, ]); return host.app.accessor.get(ISessionLifecycleService); @@ -382,4 +407,130 @@ describe('SessionLifecycleService', () => { await svc.archive('s1'); expect(archived).toEqual(['s1']); }); + + // Mirrors v1's runtime.test.ts additional-dirs coverage: session + // creation/resume must merge `.kimi-code/local.toml` dirs with caller + // additionalDirs into the session workspace context. + describe('additional dirs', () => { + beforeEach(() => { + registerScopedService( + LifecycleScope.Session, + ISessionWorkspaceContext, + SessionWorkspaceContextService, + InstantiationType.Delayed, + 'workspaceContext', + ); + }); + + function dirsOf(handle: { accessor: { get(id: unknown): T } }): readonly string[] { + return (handle.accessor.get(ISessionWorkspaceContext) as ISessionWorkspaceContext) + .additionalDirs; + } + + it('loads project-local additional dirs into the session workspace on create', async () => { + const svc = build([ + stubPair(IWorkspaceLocalConfigService, workspaceLocalConfigStub(['/tmp/extra'])), + ]); + const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + expect(dirsOf(h)).toEqual(['/tmp/extra']); + }); + + it('merges caller additionalDirs and resolves relative paths against workDir', async () => { + const svc = build(); + const h = await svc.create({ + sessionId: 's1', + workDir: '/tmp/proj', + additionalDirs: ['../sibling', '/abs/dir'], + }); + expect(dirsOf(h)).toEqual(['/tmp/sibling', '/abs/dir']); + }); + + it('deduplicates project-local and caller dirs after resolving', async () => { + const svc = build([ + stubPair(IWorkspaceLocalConfigService, workspaceLocalConfigStub(['/tmp/shared'])), + ]); + const h = await svc.create({ + sessionId: 's1', + workDir: '/tmp/proj', + additionalDirs: ['../shared', '/tmp/other'], + }); + expect(dirsOf(h)).toEqual(['/tmp/shared', '/tmp/other']); + }); + + it('supports multiple project-local and caller additionalDirs', async () => { + const svc = build([ + stubPair(IWorkspaceLocalConfigService, workspaceLocalConfigStub(['/tmp/a', '/tmp/b'])), + ]); + const h = await svc.create({ + sessionId: 's1', + workDir: '/tmp/proj', + additionalDirs: ['/tmp/c', '/tmp/d'], + }); + expect(dirsOf(h)).toEqual(['/tmp/a', '/tmp/b', '/tmp/c', '/tmp/d']); + }); + + it('loads project-local dirs when resuming a closed session', async () => { + const mainHandle = { + id: MAIN_AGENT_ID, + kind: LifecycleScope.Agent, + accessor: { get: () => ({}) }, + dispose: () => {}, + } as unknown as IAgentScopeHandle; + const summary = { id: 's1', workspaceId: 'wd_stub' } as SessionSummary; + const svc = build([ + stubPair(IWorkspaceLocalConfigService, workspaceLocalConfigStub(['/tmp/extra'])), + stubPair(ISessionIndex, { + ...sessionIndexStub(), + get: () => Promise.resolve(summary), + }), + stubPair(IWorkspaceRegistry, { + ...workspaceRegistryStub(), + get: () => + Promise.resolve({ + id: 'wd_stub', + root: '/tmp/proj', + name: 'stub', + createdAt: 0, + lastOpenedAt: 0, + }), + }), + stubPair(IAgentLifecycleService, { + ...agentLifecycleStub(), + getHandle: () => mainHandle, + }), + ]); + + const h = await svc.resume('s1'); + + expect(h).toBeDefined(); + expect(dirsOf(h!)).toEqual(['/tmp/extra']); + }); + + it('fork inherits project-local dirs', async () => { + const svc = build([ + stubPair(IWorkspaceLocalConfigService, workspaceLocalConfigStub(['/tmp/extra'])), + stubPair(ISessionActivity, { + _serviceBrand: undefined, + status: () => 'idle' as const, + isIdle: () => true, + }), + stubPair(IWorkspaceRegistry, { + ...workspaceRegistryStub(), + get: () => + Promise.resolve({ + id: 'wd_stub', + root: '/tmp/proj', + name: 'stub', + createdAt: 0, + lastOpenedAt: 0, + }), + }), + ]); + + await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); + const target = await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' }); + + expect(dirsOf(target)).toEqual(['/tmp/extra']); + }); + }); }); From 85567383c7f09a8b9799492b7f3a77994d6e428c Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 8 Jul 2026 21:24:18 +0800 Subject: [PATCH 6/6] fix(kimi-code): forward create-session additional dirs from the v2 harness The in-process v2 print-mode harness dropped the SDK CreateSessionOptions additionalDirs when calling ISessionLifecycleService.create, so --add-dir never reached the v2 resolver. Pass it through. --- apps/kimi-code/src/cli/v2/create-v2-harness.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/kimi-code/src/cli/v2/create-v2-harness.ts b/apps/kimi-code/src/cli/v2/create-v2-harness.ts index ad9bc849bd..9c511431e8 100644 --- a/apps/kimi-code/src/cli/v2/create-v2-harness.ts +++ b/apps/kimi-code/src/cli/v2/create-v2-harness.ts @@ -146,6 +146,7 @@ class V2PromptHarness implements PromptHarness { const session = await this.core.accessor.get(ISessionLifecycleService).create({ sessionId: randomUUID(), workDir: options.workDir, + additionalDirs: options.additionalDirs, }); const agent = await ensureMainAgent(session); if (options.model !== undefined) {