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/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) { diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts index 502f21f07d..141aff16a8 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 a6d6cc1692..809f091ab6 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 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/src/agent/profile/configSection.ts b/packages/agent-core-v2/src/agent/profile/configSection.ts index cd7a558807..0ab37d33d1 100644 --- a/packages/agent-core-v2/src/agent/profile/configSection.ts +++ b/packages/agent-core-v2/src/agent/profile/configSection.ts @@ -1,51 +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({ enabled: z.boolean().optional(), - mode: z.enum(['auto', 'on', 'off']).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 33db652583..63e8aa7621 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -35,7 +35,7 @@ import picomatch from 'picomatch'; import { ErrorCodes, KimiError } from "#/errors"; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; -import { resolveThinkingEffort, resolveThinkingKeep, resolveThinkingLevel } from './thinking'; +import { resolveThinkingEffort, resolveThinkingKeep } from './thinking'; import type { LoopControl } from '#/agent/loop/configSection'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; @@ -63,7 +63,6 @@ import type { } from './profile'; import { IAgentProfileService } from './profile'; import { - DEFAULT_THINKING_SECTION, THINKING_SECTION, type ThinkingConfig, } from './configSection'; @@ -161,11 +160,11 @@ export class AgentProfileService implements IAgentProfileService { const { agentsMdWarning } = context; this.agentsMdWarning = agentsMdWarning; - const thinkingLevel = resolveThinkingLevel(input.thinking, { - defaultThinking: this.config.get(DEFAULT_THINKING_SECTION), - thinking: this.config.get(THINKING_SECTION), + const thinkingLevel = resolveThinkingEffort( + input.thinking, + this.config.get(THINKING_SECTION), model, - }); + ); this.update({ cwd: input.cwd, @@ -462,8 +461,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 9a35329571..93baab3a91 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/llmProtocol/providers/anthropic.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts index 0348935fd6..f6fcfc8459 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts @@ -164,15 +164,16 @@ function applyResponseFormat( * 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, @@ -282,8 +283,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}`]; } @@ -966,7 +975,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/src/app/model/modelResolverService.ts b/packages/agent-core-v2/src/app/model/modelResolverService.ts index 597ff87b53..ae01404e9b 100644 --- a/packages/agent-core-v2/src/app/model/modelResolverService.ts +++ b/packages/agent-core-v2/src/app/model/modelResolverService.ts @@ -49,7 +49,6 @@ import { resolveThinkingEffortForModel } from './thinking'; * fields the resolver needs to mirror the production default are read here. */ interface ThinkingSection { readonly enabled?: boolean; - readonly mode?: string; readonly effort?: string; } @@ -150,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); @@ -158,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. @@ -170,14 +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, enabled: thinking?.enabled, - mode: thinking?.mode, 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 a6cf520007..3c373d11bf 100644 --- a/packages/agent-core-v2/src/app/model/thinking.ts +++ b/packages/agent-core-v2/src/app/model/thinking.ts @@ -10,9 +10,7 @@ import type { ModelCapability } from '#/app/llmProtocol/capability'; import type { ThinkingEffort } from '#/app/llmProtocol/thinkingEffort'; export interface ThinkingDefaults { - readonly defaultThinking?: boolean; readonly enabled?: boolean; - readonly mode?: string; readonly effort?: string; } @@ -78,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, @@ -94,23 +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?.enabled === false || - 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/src/app/sessionLifecycle/sessionLifecycle.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts index 6546ecbf97..9ece4f0c74 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 14e1396c7b..9ee2c99229 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, @@ -95,6 +97,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(); @@ -125,6 +129,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` @@ -139,6 +153,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/auth/auth.test.ts b/packages/agent-core-v2/test/auth/auth.test.ts index 5dcba02102..27c69c7665 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/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' }); + }); +}); 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); + }); +}); diff --git a/packages/agent-core-v2/test/model/modelResolver.test.ts b/packages/agent-core-v2/test/model/modelResolver.test.ts index 880acfd1e9..3d30322a33 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 5292e04078..7822d14426 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'); }); }); diff --git a/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts index 473474b2e3..7891f93f43 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,19 +18,23 @@ 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'; import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import { MAIN_AGENT_ID } from '#/session/agentLifecycle/mainAgent'; function bootstrapStub(): IBootstrapService { return { @@ -114,6 +120,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 persistentWorkspaceRegistryStub(): IWorkspaceRegistry { const workspaces = new Map(); return { @@ -308,6 +331,7 @@ describe('SessionLifecycleService', () => { stubPair(IAtomicDocumentStore, atomicDocumentStoreStub()), stubPair(IEventService, eventStub()), stubPair(IAgentLifecycleService, agentLifecycleStub()), + stubPair(IWorkspaceLocalConfigService, workspaceLocalConfigStub()), ...extra, ]); return host.app.accessor.get(ISessionLifecycleService); @@ -536,4 +560,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']); + }); + }); });