From 77c13d990edc5321ddb24924944b783da5e1c0ec Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Tue, 21 Jul 2026 12:21:58 +0800 Subject: [PATCH 1/2] feat(acp): support selecting thinking effort levels The ACP thinking config option was a binary on/off toggle mapped to the model's default effort, so ACP clients (e.g. Zed) could not pick a concrete level even though the engine and the SDK fully support support_efforts granularity. - Advertise one select row per declared effort level (off + levels); boolean models keep the legacy off/on rows, always-thinking models drop the off row. - Track the session thinking state as an effort string and reconcile it with the engine-normalized value read back from session status after model/thinking changes. - Validate incoming levels against the current model's catalog and reject unknown ones with invalid_params before any SDK call; keep the legacy 'on' value as an alias for the model default effort. --- .changeset/acp-thinking-effort-levels.md | 5 + packages/acp-adapter/src/config-options.ts | 125 +++++++----- packages/acp-adapter/src/model-catalog.ts | 33 ++- packages/acp-adapter/src/server.ts | 90 +++++---- packages/acp-adapter/src/session.ts | 191 ++++++++++++------ .../test/_helpers/harness-stubs.ts | 6 + .../acp-adapter/test/config-options.test.ts | 127 ++++++++++-- .../acp-adapter/test/model-catalog.test.ts | 24 +++ .../test/set-session-config-option.test.ts | 165 +++++++++++++++ 9 files changed, 588 insertions(+), 178 deletions(-) create mode 100644 .changeset/acp-thinking-effort-levels.md diff --git a/.changeset/acp-thinking-effort-levels.md b/.changeset/acp-thinking-effort-levels.md new file mode 100644 index 0000000000..1dc6ba78b7 --- /dev/null +++ b/.changeset/acp-thinking-effort-levels.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Support selecting a thinking effort level from ACP clients: the thinking picker now lists the current model's declared levels (for example off / low / medium / high) instead of only an on/off toggle. Use the thinking selector in your ACP client (e.g. Zed) to pick a level; the legacy on/off values keep working. diff --git a/packages/acp-adapter/src/config-options.ts b/packages/acp-adapter/src/config-options.ts index 7f6088d836..f313fecf73 100644 --- a/packages/acp-adapter/src/config-options.ts +++ b/packages/acp-adapter/src/config-options.ts @@ -11,19 +11,17 @@ * The v0 surface has up to three options: * - `id: 'model'` (`type: 'select'`, `category: 'model'`) — one row * per {@link AcpModelEntry}, no `,thinking` variants. Thinking is - * an orthogonal axis exposed as a separate toggle. + * an orthogonal axis exposed as a separate picker. * - `id: 'thinking'` (`type: 'select'`, `category: 'thought_level'`) * — appears ONLY when the currently-selected model's catalog row has * `thinkingSupported === true`; otherwise omitted from the snapshot - * so the client doesn't render a non-actionable toggle. Phase 16 - * converted this from `SessionConfigBoolean` to a 2-entry select - * (`off` / `on`) so Zed renders it — Zed's chip strip currently - * only knows how to draw `type: 'select'` options, and the spec's - * `boolean` arm shows up as "Unknown". Effort granularity - * (`'low' | 'medium' | …`) is still hidden behind the adapter — - * kimi-code uses a single non-`'off'` level under the hood (the - * model's default effort, resolved by agent-core's - * `resolveThinkingEffort`). + * so the client doesn't render a non-actionable picker. The rows are + * `off` plus one entry per declared effort level + * (`'low' | 'medium' | …` from the model's `support_efforts`); + * boolean models (thinking support without `support_efforts`) keep + * the legacy 2-entry `off` / `on` shape. The wire form is + * `type: 'select'` rather than the spec's `boolean` arm because + * Zed's chip strip only knows how to draw `select` options. * - `id: 'mode'` (`type: 'select'`, `category: 'mode'`) — the * locked 4-mode taxonomy from PLAN D9 ({@link ACP_MODES}). * @@ -44,9 +42,8 @@ import { listModelsFromHarness, type AcpModelEntry } from './model-catalog'; * * One option row per catalog entry — Phase 15 removed the inlined * `${id},thinking` variant rows in favour of a separate - * {@link buildThinkingOption} toggle (Phase 16 then changed that toggle - * from `boolean` to a 2-entry `select` for Zed compatibility, but the - * model picker shape is unaffected), so the model dropdown stays at most + * {@link buildThinkingOption} picker (a `select` for Zed compatibility, + * but the model picker shape is unaffected), so the model dropdown stays at most * N rows even when many catalog entries support thinking. The Python * reference's `_expand_llm_models` (`kimi-cli/src/kimi_cli/acp/server.py:441-468`) * still emits twin rows, but it has no `select`-based effort @@ -79,59 +76,80 @@ export function buildModelOption( } /** - * Build the `thinking` toggle. + * Build the `thinking` picker. * * Spec category `'thought_level'` (`schema/types.gen.d.ts:4492`) is the * reserved bucket for reasoning / thinking knobs; using it lets a client - * like Zed render the toggle with the right icon / placement without the + * like Zed render the picker with the right icon / placement without the * adapter advertising a custom category. * - * Phase 16 made this a 2-entry `type: 'select'` (`off` / `on`) instead - * of `type: 'boolean'` — Zed's chip strip currently only renders - * `select` options; boolean shows as "Unknown" because the UI hasn't - * been wired up to the spec's boolean arm yet. The adapter still tracks - * the toggle internally as a boolean (`AcpSession.currentThinkingEnabled`); - * only the wire encoding is `'on'` / `'off'` strings. + * The wire form is `type: 'select'` — Zed's chip strip currently only + * renders `select` options; the spec's `boolean` arm shows up as + * "Unknown" because the UI hasn't been wired up to it yet. * - * The caller decides whether to include this option at all — when the - * currently-selected model has `thinkingSupported === false`, the - * snapshot omits it entirely (dynamic visibility), so the client never - * shows a toggle that wouldn't do anything. + * Row shape depends on the model's declared effort levels: + * - Effort-capable models (`supportEfforts` non-empty): one row per + * level, preceded by `off` — e.g. `off / low / medium / high`. The + * `currentValue` is the session's current effort; the legacy `'on'` + * alias (and any level the model does not declare) collapses to + * `defaultEffort` so the rendered value is always one of the rows. + * - Boolean models (no `support_efforts`): the legacy 2-entry + * `off` / `on` pair. Any non-`'off'` current effort renders as `on`. * * `alwaysThinking` models (declared `always_thinking` capability — the - * runtime cannot disable thinking) collapse the select to a single - * locked `on` entry: the state stays visible to the client, but there - * is no off option to pick. ACP has no "disabled entry" concept, so - * omitting `off` is the wire-level equivalent of the TUI's greyed-out - * `Off (Unsupported)` segment. + * runtime cannot disable thinking) drop the `off` row: the state stays + * visible to the client, but there is no off option to pick. ACP has no + * "disabled entry" concept, so omitting `off` is the wire-level + * equivalent of the TUI's greyed-out `Off (Unsupported)` segment. A + * recorded `'off'` current effort (which the engine clamps back to the + * model default) renders as `defaultEffort`. */ export function buildThinkingOption( - enabled: boolean, + currentEffort: string, + supportEfforts: readonly string[], + defaultEffort: string, alwaysThinking = false, ): SessionConfigOption { - if (alwaysThinking) { + const efforts = supportEfforts.filter((effort) => effort.length > 0); + if (efforts.length === 0) { + // Boolean model — the engine speaks `on`/`off`, so the picker keeps + // the legacy two-row shape. return { type: 'select', id: 'thinking', name: 'Thinking', category: 'thought_level', - currentValue: 'on', - options: [{ value: 'on', name: 'Thinking On' }], + currentValue: alwaysThinking || currentEffort !== 'off' ? 'on' : 'off', + options: alwaysThinking + ? [{ value: 'on', name: effortDisplayName('on') }] + : [ + { value: 'off', name: effortDisplayName('off') }, + { value: 'on', name: effortDisplayName('on') }, + ], }; } + const values = alwaysThinking ? [...efforts] : ['off', ...efforts]; + const currentValue = + !alwaysThinking && currentEffort === 'off' + ? 'off' + : efforts.includes(currentEffort) + ? currentEffort + : defaultEffort; return { type: 'select', id: 'thinking', name: 'Thinking', category: 'thought_level', - currentValue: enabled ? 'on' : 'off', - options: [ - { value: 'off', name: 'Thinking Off' }, - { value: 'on', name: 'Thinking On' }, - ], + currentValue, + options: values.map((value) => ({ value, name: effortDisplayName(value) })), }; } +/** Display label for one thinking-picker row — the capitalized level. */ +function effortDisplayName(effort: string): string { + return effort.charAt(0).toUpperCase() + effort.slice(1); +} + /** * Project the locked 4-mode taxonomy ({@link ACP_MODES}) into the * `SessionConfigOption` `mode` arm. Order is preserved (default → plan → @@ -158,23 +176,28 @@ export function buildModeOption(currentModeId: AcpModeId): SessionConfigOption { * Compose the v0 `SessionConfigOption[]` surface — `[modelOption, …(thinkingOption?), modeOption]`. * Order is part of the contract: ACP clients render options top-to-bottom, and * PLAN D11 fixes model on top of mode so the more frequently-used selector - * is reachable first. The thinking toggle is wedged between them so its + * is reachable first. The thinking picker is wedged between them so its * effect on the model selection above is visually adjacent. * - * The thinking toggle only appears when the currently-selected base + * The thinking picker only appears when the currently-selected base * model is `thinkingSupported`; otherwise the snapshot is just * `[modelOption, modeOption]`. This means switching from a thinking- * capable model (e.g. `kimi-coder`) to a non-thinking one (e.g. * `kimi-plain`) causes the next `config_option_update` to omit the - * toggle entirely — Zed's UI is expected to handle "option set changes + * picker entirely — Zed's UI is expected to handle "option set changes * across updates", which is the standard configOptions contract. * + * `currentThinkingEffort` is the session's current effort string + * (`'off'`, `'on'`, or a declared level); {@link buildThinkingOption} + * projects it onto the row set — `'on'` and unknown levels render as + * the model's default effort. + * * Calls {@link listModelsFromHarness} exactly once per invocation so a * session refresh after each model/mode/thinking change is a single * round-trip to the harness. The helper itself is tolerant to * partial-stub harnesses: missing `getConfig` or a throwing one resolve * to an empty catalog, so the model picker ships an empty options - * array and the thinking toggle is suppressed (no current model means + * array and the thinking picker is suppressed (no current model means * no thinkingSupported signal to read). * * Returns a mutable `SessionConfigOption[]` (rather than `readonly`) so @@ -186,18 +209,22 @@ export function buildModeOption(currentModeId: AcpModeId): SessionConfigOption { export async function buildSessionConfigOptions( harness: KimiHarness, currentBaseModelId: string, - currentThinkingEnabled: boolean, + currentThinkingEffort: string, currentModeId: AcpModeId, ): Promise { const models = await listModelsFromHarness(harness); const currentModelEntry = models.find((m) => m.id === currentBaseModelId); const showThinking = currentModelEntry?.thinkingSupported === true; - const alwaysThinking = currentModelEntry?.alwaysThinking === true; const out: SessionConfigOption[] = [buildModelOption(models, currentBaseModelId)]; - if (showThinking) { - // Always-thinking models render locked-on regardless of the session's - // recorded toggle state — agent-core clamps the runtime the same way. - out.push(buildThinkingOption(alwaysThinking || currentThinkingEnabled, alwaysThinking)); + if (showThinking && currentModelEntry !== undefined) { + out.push( + buildThinkingOption( + currentThinkingEffort, + currentModelEntry.supportEfforts, + currentModelEntry.defaultThinkingEffort, + currentModelEntry.alwaysThinking === true, + ), + ); } out.push(buildModeOption(currentModeId)); return out; diff --git a/packages/acp-adapter/src/model-catalog.ts b/packages/acp-adapter/src/model-catalog.ts index a3c87a6384..f0ebfe9c53 100644 --- a/packages/acp-adapter/src/model-catalog.ts +++ b/packages/acp-adapter/src/model-catalog.ts @@ -48,10 +48,19 @@ export interface AcpModelEntry { /** Declared 'always_thinking' capability — thinking cannot be turned off. */ readonly alwaysThinking?: boolean; /** - * The thinking effort to send when the binary ACP toggle flips on: the - * model's declared `default_effort`, else the middle `support_efforts` - * entry, else `'on'` for boolean models. Mirrors agent-core's - * `defaultThinkingEffortFor` so the ACP on-state matches the TUI. + * The model's selectable thinking-effort levels: declared + * `support_efforts` after override/provider-profile resolution (blank + * entries dropped, mirroring agent-core's `effortsFor`). Empty for + * boolean models, where the ACP picker keeps the legacy `off`/`on` + * pair instead of per-level rows. + */ + readonly supportEfforts: readonly string[]; + /** + * The thinking effort to send when the client picks the legacy `'on'` + * value: the model's declared `default_effort`, else the middle + * `support_efforts` entry, else `'on'` for boolean models. Mirrors + * agent-core's `defaultThinkingEffortFor` so the ACP on-state matches + * the TUI. */ readonly defaultThinkingEffort: string; } @@ -87,6 +96,21 @@ export function deriveAlwaysThinking(alias: ModelAlias, providerType?: ProviderT ); } +/** + * The model's selectable thinking-effort levels: declared + * `support_efforts` (after override/provider-profile resolution) with + * blank entries dropped — mirrors agent-core's `effortsFor`. Empty for + * boolean models (thinking support without `support_efforts`). + */ +export function deriveSupportEfforts( + alias: ModelAlias, + providerType?: ProviderType, +): readonly string[] { + return (effectiveModelAlias(alias, providerType).supportEfforts ?? []).filter( + (effort) => effort.length > 0, + ); +} + /** * The effort a boolean "thinking on" toggle maps to for this model: declared * `default_effort`, else the middle `support_efforts` entry, else `'on'` for @@ -133,6 +157,7 @@ export async function listModelsFromHarness( name: effective.displayName ?? effective.model ?? id, thinkingSupported: deriveThinkingSupported(alias, providerType), alwaysThinking: deriveAlwaysThinking(alias, providerType), + supportEfforts: deriveSupportEfforts(alias, providerType), defaultThinkingEffort: deriveDefaultThinkingEffort(alias, providerType), }); } diff --git a/packages/acp-adapter/src/server.ts b/packages/acp-adapter/src/server.ts index 558901b5c3..a9ef407bee 100644 --- a/packages/acp-adapter/src/server.ts +++ b/packages/acp-adapter/src/server.ts @@ -200,10 +200,10 @@ function nonEmptyString(value: string | undefined): string | undefined { return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; } -function thinkingEnabledFromEffort(effort: unknown): boolean | undefined { +function effortStringOrUndefined(effort: unknown): string | undefined { if (typeof effort !== 'string') return undefined; - const normalized = effort.trim().toLowerCase(); - return normalized.length > 0 && normalized !== 'off'; + const trimmed = effort.trim(); + return trimmed.length > 0 ? trimmed : undefined; } /** @@ -386,7 +386,7 @@ export class AcpServer implements Agent { mcpServers, }); const currentModelId = await this.resolveCurrentModelId(); - const currentThinkingEnabled = await this.resolveCurrentThinkingEnabled(session); + const currentThinkingEffort = await this.resolveCurrentThinkingEffort(session); const acpSession = new AcpSession( this.conn, session, @@ -394,7 +394,7 @@ export class AcpServer implements Agent { this.makeTelemetryTrack(), currentModelId, this.harness, - currentThinkingEnabled, + currentThinkingEffort, ); this.sessions.set(session.id, acpSession); // Phase 14 (PLAN D11) advertises both the model and mode pickers as @@ -405,14 +405,14 @@ export class AcpServer implements Agent { // `default` (PLAN D9); `currentModelId` is resolved from the harness // config (`defaultModel` if set, else the first listed alias) so // the dropdown's "current" highlight matches the session the SDK - // just constructed. Phase 15 adds the `thinking` toggle when the - // current model's catalog row advertises `thinkingSupported`; - // Phase 16 reshaped that toggle from `boolean` to a 2-entry - // `select` so Zed actually renders it. + // just constructed. The `thinking` picker is added when the + // current model's catalog row advertises `thinkingSupported` — one + // row per declared effort level (plus `off`), or the legacy + // `off` / `on` pair for boolean models. const configOptions = await buildSessionConfigOptions( this.harness, currentModelId, - currentThinkingEnabled, + currentThinkingEffort, DEFAULT_MODE_ID, ); this.scheduleAvailableCommandsUpdate(session.id); @@ -580,13 +580,14 @@ export class AcpServer implements Agent { typeof resumedModelAlias === 'string' && resumedModelAlias.length > 0 ? resumedModelAlias : await this.resolveCurrentModelId(); - // Phase 15 reads the resumed thinking effort off the main-agent - // config and projects it onto the binary toggle: any non-`'off'` - // effort reads as "thinking on" because the ACP surface only - // exposes the boolean axis. Falls back to the live session status, then - // the harness-level default, when the resume state lacks the field. + // The resumed thinking effort is read off the main-agent config and + // carried through as-is — it is the engine-resolved value + // (`'off'`, `'on'`, or a declared level), which the thinking picker + // projects onto its row set. Falls back to the live session status, + // then the harness-level default, when the resume state lacks the + // field. const resumedThinkingEffort = resumeState?.agents?.['main']?.config?.thinkingEffort; - const currentThinkingEnabled = await this.resolveCurrentThinkingEnabled( + const currentThinkingEffort = await this.resolveCurrentThinkingEffort( session, resumedThinkingEffort, ); @@ -597,13 +598,13 @@ export class AcpServer implements Agent { this.makeTelemetryTrack(), currentModelId, this.harness, - currentThinkingEnabled, + currentThinkingEffort, ); this.sessions.set(session.id, acpSession); const configOptions = await buildSessionConfigOptions( this.harness, currentModelId, - currentThinkingEnabled, + currentThinkingEffort, DEFAULT_MODE_ID, ); return { session, acpSession, configOptions }; @@ -741,6 +742,9 @@ export class AcpServer implements Agent { * {@link unstable_setSessionModel}). * - `'mode'` → {@link AcpSession.setMode} (same path as * {@link setSessionMode}). + * - `'thinking'` → {@link AcpSession.setThinking} — `'off'`, the + * legacy `'on'` alias, or a declared effort level of the current + * model. * - anything else → JSON-RPC `invalid_params` (-32602) BEFORE any * SDK call, so the client sees a structured rejection rather * than a half-applied state change. @@ -773,13 +777,14 @@ export class AcpServer implements Agent { await acpSession.setMode(String(value)); break; case 'thinking': { - // Phase 16 changed the wire shape from boolean to a 2-entry - // `select` (`'on'` / `'off'`) for Zed UI compatibility. Strict - // equality with `'on'` keeps the parse deterministic — any - // other string (including a stale `true` / `false` boolean - // sent by a pre-Phase-16 client) reads as "off" rather than - // silently flipping based on truthiness. - await acpSession.setThinking(value === 'on'); + // The accepted values mirror the picker's advertised rows: + // `'off'`, the legacy `'on'` alias (mapped to the model's + // default effort), or one of the current model's declared + // effort levels (`'low' | 'medium' | …`). AcpSession validates + // the level against the catalog and rejects unknown values with + // `invalid_params` BEFORE any SDK call, so a stale or + // hand-crafted value can never half-apply. + await acpSession.setThinking(String(value)); break; } default: @@ -792,7 +797,7 @@ export class AcpServer implements Agent { configOptions: await buildSessionConfigOptions( this.harness, acpSession.currentModelId, - acpSession.currentThinkingEnabled, + acpSession.currentThinkingEffort, acpSession.currentModeId, ), }; @@ -914,48 +919,51 @@ export class AcpServer implements Agent { } /** - * Compute the initial value for the `thinking` toggle from the session's - * effective effort. A persisted resume-state effort wins; otherwise the - * live session status is authoritative. The harness config remains a - * best-effort fallback for partial SDK stubs and status-read failures. + * Compute the initial value for the `thinking` picker's current effort + * from the session's effective effort. A persisted resume-state effort + * wins; otherwise the live session status is authoritative. The harness + * config remains a best-effort fallback for partial SDK stubs and + * status-read failures (`enabled = true` with no effort collapses to + * the legacy `'on'` alias, which the picker projects onto the model's + * default level). * * Tolerant to partial SDK/session stubs for the same reason * {@link resolveCurrentModelId} is — adapter-level unit tests routinely * omit `getStatus` or `getConfig`. The swallow-and-fallback path keeps the * test ergonomics symmetric. */ - private async resolveCurrentThinkingEnabled( + private async resolveCurrentThinkingEffort( session: Session, resumedThinkingEffort?: unknown, - ): Promise { - const resumed = thinkingEnabledFromEffort(resumedThinkingEffort); + ): Promise { + const resumed = effortStringOrUndefined(resumedThinkingEffort); if (resumed !== undefined) return resumed; if (typeof session.getStatus === 'function') { try { - const current = thinkingEnabledFromEffort((await session.getStatus()).thinkingEffort); + const current = effortStringOrUndefined((await session.getStatus()).thinkingEffort); if (current !== undefined) return current; } catch (error) { - log.warn('acp: session.getStatus threw during thinking toggle resolution; falling back', { + log.warn('acp: session.getStatus threw during thinking effort resolution; falling back', { error: error instanceof Error ? error.message : String(error), }); } } - if (typeof this.harness.getConfig !== 'function') return false; + if (typeof this.harness.getConfig !== 'function') return 'off'; try { const config = await this.harness.getConfig(); const thinking = (config as { thinking?: { enabled?: unknown; effort?: unknown } }) .thinking; - if (thinking?.enabled === false) return false; - const configured = thinkingEnabledFromEffort(thinking?.effort); + if (thinking?.enabled === false) return 'off'; + const configured = effortStringOrUndefined(thinking?.effort); if (configured !== undefined) return configured; - return thinking?.enabled === true; + return thinking?.enabled === true ? 'on' : 'off'; } catch (err) { - log.warn('acp: harness.getConfig threw during thinking toggle resolution; defaulting to off', { + log.warn('acp: harness.getConfig threw during thinking effort resolution; defaulting to off', { error: err instanceof Error ? err.message : String(err), }); - return false; + return 'off'; } } diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index 0121bde4e2..747b44ea9c 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -108,23 +108,24 @@ export class AcpSession { private currentModelIdInternal: string; /** - * The adapter-side authoritative current thinking-toggle state. - * Phase 15 split this out of the model id so the client renders a - * separate boolean `SessionConfigOption` (the spec's - * `'thought_level'` category) instead of an inlined `,thinking` - * variant row in the model dropdown. Updated by {@link setThinking} - * and by {@link setModel} when the caller passed a merged - * `${id},thinking` form (legacy `unstable_setSessionModel` - * compatibility). + * The adapter-side authoritative current thinking effort — `'off'`, + * `'on'` (legacy boolean alias), or one of the current model's + * declared effort levels (`'low' | 'medium' | 'high' | …`). Phase 15 + * split thinking out of the model id so the client renders a separate + * `SessionConfigOption` (the spec's `'thought_level'` category) + * instead of an inlined `,thinking` variant row in the model dropdown. + * Updated by {@link setThinking} and by {@link setModel} when the + * caller passed a merged `${id},thinking` form (legacy + * `unstable_setSessionModel` compatibility). * - * Maps to the SDK's effort string at the boundary: - * `true` → `'high'` (the typical default for kimi-code), `false` - * → `'off'`. The granularity of `'low' | 'medium' | 'xhigh' | 'max'` - * is intentionally not surfaced — the ACP `thinking` axis is binary - * (Phase 16 wire form: 2-entry `select` `off` / `on`; pre-Phase-16 - * was `SessionConfigBoolean`). + * The value is forwarded to the SDK as-is (`Session.setThinking`), + * then reconciled with the engine-normalized effort read back from + * `Session.getStatus()` when that channel exists — so engine-side + * clamping (e.g. `always_thinking` rejecting `'off'`, or a level the + * newly-selected model does not declare) is reflected in the next + * snapshot instead of the adapter's requested value. */ - private currentThinkingEnabledInternal = false; + private currentThinkingEffortInternal: string = 'off'; /** * The adapter-side authoritative current mode id. Updated by @@ -204,16 +205,15 @@ export class AcpSession { */ private readonly harness?: KimiHarness, /** - * Initial value of the adapter-side thinking-toggle state, supplied - * by the server when creating / loading the session. Phase 15 - * introduces this so resumed sessions whose persisted - * `thinkingEffort` was non-`'off'` start with the toggle on. - * Defaults to `false` when absent. + * Initial value of the adapter-side thinking effort, supplied + * by the server when creating / loading the session from the + * engine-resolved status (or the persisted resume-state effort). + * Defaults to `'off'` when absent. */ - initialThinkingEnabled?: boolean, + initialThinkingEffort?: string, ) { this.currentModelIdInternal = initialModelId ?? ''; - this.currentThinkingEnabledInternal = initialThinkingEnabled ?? false; + this.currentThinkingEffortInternal = initialThinkingEffort ?? 'off'; // Register the approval bridge once, at session-construction time — // NOT per-prompt — because `setApprovalHandler` is scoped to the // SDK session, not the individual turn. The handler captures `this` @@ -252,12 +252,12 @@ export class AcpSession { } /** - * Adapter-side authoritative thinking-toggle state, used by + * Adapter-side authoritative current thinking effort, used by * {@link AcpServer.setSessionConfigOption} to build the response's * `configOptions` snapshot. */ - get currentThinkingEnabled(): boolean { - return this.currentThinkingEnabledInternal; + get currentThinkingEffort(): string { + return this.currentThinkingEffortInternal; } /** @@ -317,28 +317,35 @@ export class AcpSession { * Python ref's `_ModelIDConv.from_acp_model_id` at * `kimi-cli/src/kimi_cli/acp/server.py:425-433`). Phase 15 decoupled * thinking from the model id at the ACP surface — it's now its own - * `thought_level` config option (Phase 16 wire form: 2-entry `select` - * `off` / `on`) — but this legacy compat path is - * kept: when the caller sends a merged form, we split it into the - * bare model key (forwarded to `Session.setModel`) plus a thinking - * flag (forwarded to `Session.setThinking`). + * `thought_level` config option (a `select` of effort levels) — but + * this legacy compat path is kept: when the caller sends a merged + * form, we split it into the bare model key (forwarded to + * `Session.setModel`) plus the new model's default effort (forwarded + * to `Session.setThinking`). * * Wire semantics: - * - `'kimi-v2'` → setModel('kimi-v2'); thinking state unchanged. + * - `'kimi-v2'` → setModel('kimi-v2'); requested thinking + * effort unchanged (the engine re-resolves it against the new + * model; see below). * - `'kimi-v2,thinking'` → setModel('kimi-v2') + setThinking(); thinking state flips on. + * effort for that model>); thinking flips on at the default level. * * Note the asymmetry: a bare model id does NOT turn thinking OFF. * That keeps the model / thinking axes orthogonal — model changes - * preserve thinking state. To explicitly disable thinking, the + * preserve the requested effort. To explicitly disable thinking, the * client must call `setSessionConfigOption({ configId: 'thinking', - * value: false })` (or send `setThinking('off')` directly through - * the SDK channel, but the ACP surface only exposes the boolean). + * value: 'off' })`. + * + * After the SDK calls land, the adapter-side effort is reconciled + * with `Session.getStatus()` when available: the engine re-resolves + * the requested effort against the new model (`ConfigState.update`), + * so a level the new model does not declare shows up in the next + * snapshot as the engine-normalized value, not the stale request. * * `currentModelIdInternal` is updated to the bare key — the snapshot * therefore never carries a `,thinking` suffix in the model option's * `currentValue`. Thinking visibility in the snapshot is governed - * by `currentThinkingEnabledInternal` and + * by `currentThinkingEffortInternal` and * {@link buildSessionConfigOptions}'s `thinkingSupported` gate. * * Unknown model errors bubble up from the SDK as-is; the caller in @@ -349,50 +356,110 @@ export class AcpSession { const hasSuffix = modelId.endsWith(suffix); const baseKey = hasSuffix ? modelId.slice(0, -suffix.length) : modelId; await this.session.setModel(baseKey); + // Update BEFORE resolving the on-effort so a merged `,thinking` + // switch picks the NEW model's default level, not the old one's. + this.currentModelIdInternal = baseKey; if (hasSuffix && typeof this.session.setThinking === 'function') { - await this.session.setThinking(await this.thinkingOnEffort()); - this.currentThinkingEnabledInternal = true; + const onEffort = await this.thinkingOnEffort(); + await this.session.setThinking(onEffort); + this.currentThinkingEffortInternal = + (await this.readEffectiveThinkingEffort()) ?? onEffort; + } else if (!hasSuffix) { + this.currentThinkingEffortInternal = + (await this.readEffectiveThinkingEffort()) ?? this.currentThinkingEffortInternal; } - this.currentModelIdInternal = baseKey; await this.emitConfigOptionUpdate(); } /** - * Forward an ACP thinking-toggle change to the underlying SDK. + * Forward an ACP thinking-effort change to the underlying SDK. * - * Phase 15 introduces this as the new canonical channel for the - * thinking axis. Boolean → thinking-effort mapping: - * - `true` → `Session.setThinking(effort)` where `effort` is the - * current model's default effort (see {@link thinkingOnEffort}). - * - `false` → `Session.setThinking('off')`. + * Accepted values mirror the rows advertised by the `thinking` + * config option: + * - `'off'` → `Session.setThinking('off')`; + * - `'on'` → legacy boolean alias, mapped to the current + * model's default effort (see {@link thinkingOnEffort}); + * - `` → a declared `support_efforts` level of the + * current model, forwarded unchanged. Anything else is rejected + * with JSON-RPC `invalid_params` (-32602) BEFORE the SDK call so + * the client sees a structured rejection rather than a + * half-applied state change. When the catalog is unavailable + * (harness-less unit tests) or the current model is unknown to + * it, levels pass through unvalidated — the engine's own resolve + * remains the final arbiter. * * Tolerant to partial-stub `Session` instances (adapter-level unit * tests construct minimal fakes that may omit `setThinking`): when - * the method is missing we still update the adapter-side toggle + * the method is missing we still update the adapter-side effort * state and emit the snapshot, so the ACP wire stays consistent — * the test simply doesn't observe an SDK call. * + * After the SDK call lands, the recorded effort is reconciled with + * `Session.getStatus()` when that channel exists, so engine-side + * clamping (e.g. `always_thinking` rejecting `'off'`) is what the + * next snapshot renders. + * * Always emits a `config_option_update` notification afterwards so - * the client sees the toggle reflect the new value, even if it + * the client sees the picker reflect the new value, even if it * came in through the funnel and the response itself already * carries a fresh snapshot. */ - async setThinking(enabled: boolean): Promise { + async setThinking(effort: string): Promise { + const resolved = await this.resolveEffortForCurrentModel(effort); if (typeof this.session.setThinking === 'function') { - const effort = enabled ? await this.thinkingOnEffort() : THINKING_OFF_EFFORT; - await this.session.setThinking(effort); + await this.session.setThinking(resolved); } - this.currentThinkingEnabledInternal = enabled; + this.currentThinkingEffortInternal = + (await this.readEffectiveThinkingEffort()) ?? resolved; await this.emitConfigOptionUpdate(); } /** - * The effort to send when the ACP thinking toggle flips on: the current - * model's declared default effort (or middle `support_efforts`), falling - * back to `'on'` for boolean models or when the catalog is unavailable - * (harness-less unit tests). The `always_thinking` constraint is enforced - * downstream by agent-core's resolve, so this adapter no longer clamps an - * explicit off request here. + * Validate an ACP-supplied thinking value against the current model's + * catalog row and resolve the legacy `'on'` alias. Returns the effort + * string to forward to the SDK. See {@link setThinking} for the + * acceptance rules. + */ + private async resolveEffortForCurrentModel(effort: string): Promise { + if (!this.harness) return effort; + const models = await listModelsFromHarness(this.harness); + const entry = models.find((m) => m.id === this.currentModelIdInternal); + if (effort === 'on') return entry?.defaultThinkingEffort ?? 'on'; + if (effort === 'off') return 'off'; + if (entry !== undefined && !entry.supportEfforts.includes(effort)) { + throw RequestError.invalidParams( + { effort, modelId: entry.id }, + `Unknown thinking effort for model "${entry.id}": ${effort}`, + ); + } + return effort; + } + + /** + * The engine-normalized thinking effort reported by the SDK session's + * status channel, or `undefined` when the channel is missing + * (partial-stub unit tests), fails, or carries no usable value — the + * caller then keeps its own projected value. Reading status is the + * same swallow-and-fallback policy as + * {@link AcpServer.resolveCurrentThinkingEffort}. + */ + private async readEffectiveThinkingEffort(): Promise { + if (typeof this.session.getStatus !== 'function') return undefined; + try { + const effort = (await this.session.getStatus()).thinkingEffort; + return typeof effort === 'string' && effort.length > 0 ? effort : undefined; + } catch { + return undefined; + } + } + + /** + * The effort the legacy `'on'` value maps to: the current model's + * declared default effort (or middle `support_efforts`), falling back + * to `'on'` for boolean models or when the catalog is unavailable + * (harness-less unit tests). The `always_thinking` constraint is + * enforced downstream by agent-core's resolve, so this adapter no + * longer clamps an explicit off request here. */ private async thinkingOnEffort(): Promise { if (!this.harness) return 'on'; @@ -471,7 +538,7 @@ export class AcpSession { const snapshot = await buildSessionConfigOptions( this.harness, this.currentModelIdInternal, - this.currentThinkingEnabledInternal, + this.currentThinkingEffortInternal, this.currentModeIdInternal, ); await this.conn.sessionUpdate(configOptionUpdateNotification(this.id, snapshot)); @@ -1590,14 +1657,6 @@ function authRequiredFromUnknown(err: unknown): RequestError | undefined { return undefined; } -/** - * Effort string passed to {@link Session.setThinking} when the ACP `thinking` - * toggle flips off. The on-state effort is resolved per-model via - * {@link AcpSession.thinkingOnEffort} (declared default effort / middle - * `support_efforts` / `'on'`), so only the off sentinel is a constant here. - */ -const THINKING_OFF_EFFORT = 'off'; - /** * Identifier the agent-core session emits for the main (user-facing) * agent. Subagents are issued generated ids by `Session.spawnAgent`; diff --git a/packages/acp-adapter/test/_helpers/harness-stubs.ts b/packages/acp-adapter/test/_helpers/harness-stubs.ts index 0e34f04462..74bcabb37a 100644 --- a/packages/acp-adapter/test/_helpers/harness-stubs.ts +++ b/packages/acp-adapter/test/_helpers/harness-stubs.ts @@ -34,6 +34,10 @@ export function makeModelsMap( name?: string; thinkingSupported?: boolean; alwaysThinking?: boolean; + /** Declared `support_efforts` — presence turns the fixture into an effort-capable model. */ + efforts?: readonly string[]; + /** Declared `default_effort`; falls back to the middle `efforts` entry when omitted. */ + defaultEffort?: string; }>, ): Record { const out: Record = {}; @@ -51,6 +55,8 @@ export function makeModelsMap( model: entry.id, ...(entry.name !== undefined ? { displayName: entry.name } : {}), ...(capabilities !== undefined ? { capabilities } : {}), + ...(entry.efforts !== undefined ? { supportEfforts: [...entry.efforts] } : {}), + ...(entry.defaultEffort !== undefined ? { defaultEffort: entry.defaultEffort } : {}), } as ModelAlias; } return out; diff --git a/packages/acp-adapter/test/config-options.test.ts b/packages/acp-adapter/test/config-options.test.ts index 08475497f3..c67137b872 100644 --- a/packages/acp-adapter/test/config-options.test.ts +++ b/packages/acp-adapter/test/config-options.test.ts @@ -18,6 +18,8 @@ function makeHarnessWithModels( capabilities?: readonly string[]; protocol?: 'anthropic'; providerType?: 'anthropic' | 'kimi' | 'openai'; + supportEfforts?: readonly string[]; + defaultEffort?: string; }>, ): { harness: KimiHarness; getConfig: ReturnType } { // Mirror the `listAvailableModels` derivation: `id` is the config map @@ -32,6 +34,8 @@ function makeHarnessWithModels( displayName?: string; capabilities?: readonly string[]; protocol?: 'anthropic'; + supportEfforts?: readonly string[]; + defaultEffort?: string; }> = {}; const providers: Record = {}; for (const entry of entries) { @@ -42,6 +46,8 @@ function makeHarnessWithModels( ...(entry.displayName !== undefined ? { displayName: entry.displayName } : {}), ...(entry.capabilities !== undefined ? { capabilities: entry.capabilities } : {}), protocol: entry.protocol, + ...(entry.supportEfforts !== undefined ? { supportEfforts: entry.supportEfforts } : {}), + ...(entry.defaultEffort !== undefined ? { defaultEffort: entry.defaultEffort } : {}), }; if (entry.providerType !== undefined) { providers[providerName] = { type: entry.providerType }; @@ -54,8 +60,8 @@ function makeHarnessWithModels( describe('buildModelOption', () => { it('emits exactly one option per catalog row (Phase 15: no inlined `,thinking` variant rows)', () => { const models: readonly AcpModelEntry[] = [ - { id: 'alpha', name: 'Alpha', thinkingSupported: true, defaultThinkingEffort: 'on' }, - { id: 'beta', name: 'Beta', thinkingSupported: false, defaultThinkingEffort: 'on' }, + { id: 'alpha', name: 'Alpha', thinkingSupported: true, supportEfforts: [], defaultThinkingEffort: 'on' }, + { id: 'beta', name: 'Beta', thinkingSupported: false, supportEfforts: [], defaultThinkingEffort: 'on' }, ]; const option = buildModelOption(models, 'alpha'); @@ -79,7 +85,7 @@ describe('buildModelOption', () => { it('treats `currentValue` as the bare base model id — Phase 15 keeps the snapshot suffix-free', () => { const models: readonly AcpModelEntry[] = [ - { id: 'kimi-v2', name: 'Kimi v2', thinkingSupported: true, defaultThinkingEffort: 'on' }, + { id: 'kimi-v2', name: 'Kimi v2', thinkingSupported: true, supportEfforts: [], defaultThinkingEffort: 'on' }, ]; const option = buildModelOption(models, 'kimi-v2'); @@ -101,8 +107,8 @@ describe('buildModelOption', () => { }); describe('buildThinkingOption', () => { - it('produces a `type:"select"` `category:"thought_level"` option with `off`/`on` entries carrying the toggle value', () => { - const on = buildThinkingOption(true); + it('boolean models keep the legacy `off`/`on` select with the toggle value carried through', () => { + const on = buildThinkingOption('on', [], 'on'); expect(on.type).toBe('select'); expect(on.id).toBe('thinking'); expect(on.category).toBe('thought_level'); @@ -110,19 +116,72 @@ describe('buildThinkingOption', () => { if (on.type !== 'select') throw new Error('expected SessionConfigSelect'); expect(on.currentValue).toBe('on'); expect(on.options.map((o) => ('value' in o ? o.value : ''))).toEqual(['off', 'on']); - expect(on.options.map((o) => ('name' in o ? o.name : ''))).toEqual(['Thinking Off', 'Thinking On']); + expect(on.options.map((o) => ('name' in o ? o.name : ''))).toEqual(['Off', 'On']); - const off = buildThinkingOption(false); + const off = buildThinkingOption('off', [], 'on'); if (off.type !== 'select') throw new Error('expected SessionConfigSelect'); expect(off.currentValue).toBe('off'); + + // Boolean models render any non-'off' effort (e.g. a status-reported + // concrete level) as `on` — the engine speaks the binary pair here. + const high = buildThinkingOption('high', [], 'on'); + if (high.type !== 'select') throw new Error('expected SessionConfigSelect'); + expect(high.currentValue).toBe('on'); }); - it('collapses to a single locked "on" entry for always-thinking models', () => { - const locked = buildThinkingOption(true, true); + it('collapses to a single locked "on" entry for always-thinking boolean models', () => { + const locked = buildThinkingOption('on', [], 'on', true); if (locked.type !== 'select') throw new Error('expected SessionConfigSelect'); expect(locked.currentValue).toBe('on'); expect(locked.options.map((o) => ('value' in o ? o.value : ''))).toEqual(['on']); - expect(locked.options.map((o) => ('name' in o ? o.name : ''))).toEqual(['Thinking On']); + expect(locked.options.map((o) => ('name' in o ? o.name : ''))).toEqual(['On']); + }); + + it('emits one row per declared effort level, preceded by `off`', () => { + const option = buildThinkingOption('high', ['low', 'medium', 'high'], 'medium'); + if (option.type !== 'select') throw new Error('expected SessionConfigSelect'); + expect(option.currentValue).toBe('high'); + expect(option.options.map((o) => ('value' in o ? o.value : ''))).toEqual([ + 'off', + 'low', + 'medium', + 'high', + ]); + expect(option.options.map((o) => ('name' in o ? o.name : ''))).toEqual([ + 'Off', + 'Low', + 'Medium', + 'High', + ]); + + const off = buildThinkingOption('off', ['low', 'medium', 'high'], 'medium'); + if (off.type !== 'select') throw new Error('expected SessionConfigSelect'); + expect(off.currentValue).toBe('off'); + }); + + it('projects the legacy `on` alias and undeclared levels onto the model default effort', () => { + const legacyOn = buildThinkingOption('on', ['low', 'medium', 'high'], 'medium'); + if (legacyOn.type !== 'select') throw new Error('expected SessionConfigSelect'); + expect(legacyOn.currentValue).toBe('medium'); + + const stale = buildThinkingOption('xhigh', ['low', 'medium', 'high'], 'medium'); + if (stale.type !== 'select') throw new Error('expected SessionConfigSelect'); + expect(stale.currentValue).toBe('medium'); + }); + + it('drops the `off` row for always-thinking effort models and renders a recorded off as the default level', () => { + const locked = buildThinkingOption('off', ['low', 'medium', 'high'], 'medium', true); + if (locked.type !== 'select') throw new Error('expected SessionConfigSelect'); + expect(locked.options.map((o) => ('value' in o ? o.value : ''))).toEqual([ + 'low', + 'medium', + 'high', + ]); + expect(locked.currentValue).toBe('medium'); + + const on = buildThinkingOption('high', ['low', 'medium', 'high'], 'medium', true); + if (on.type !== 'select') throw new Error('expected SessionConfigSelect'); + expect(on.currentValue).toBe('high'); }); }); @@ -159,7 +218,7 @@ describe('buildSessionConfigOptions', () => { { id: 'kimi-coder', model: 'kimi-for-coding', displayName: 'Kimi Coder' }, ]); - const result = await buildSessionConfigOptions(harness, 'kimi-coder', false, 'default'); + const result = await buildSessionConfigOptions(harness, 'kimi-coder', 'off', 'default'); expect(getConfig).toHaveBeenCalledTimes(1); expect(result).toHaveLength(3); @@ -189,7 +248,7 @@ describe('buildSessionConfigOptions', () => { }, ]); - const result = await buildSessionConfigOptions(harness, 'custom', false, 'default'); + const result = await buildSessionConfigOptions(harness, 'custom', 'off', 'default'); expect(result.map((option) => option.id)).toEqual(['model', 'thinking', 'mode']); }); @@ -204,7 +263,7 @@ describe('buildSessionConfigOptions', () => { }, ]); - const result = await buildSessionConfigOptions(harness, 'custom', false, 'default'); + const result = await buildSessionConfigOptions(harness, 'custom', 'off', 'default'); expect(result.map((option) => option.id)).toEqual(['model', 'mode']); }); @@ -215,7 +274,7 @@ describe('buildSessionConfigOptions', () => { { id: 'kimi-plain', model: 'qwen-2.5-coder', displayName: 'Kimi Plain' }, ]); - const result = await buildSessionConfigOptions(harness, 'kimi-plain', false, 'default'); + const result = await buildSessionConfigOptions(harness, 'kimi-plain', 'off', 'default'); expect(result.map((o) => o.id)).toEqual(['model', 'mode']); }); @@ -225,12 +284,44 @@ describe('buildSessionConfigOptions', () => { { id: 'kimi-coder', model: 'kimi-for-coding', displayName: 'Kimi Coder' }, ]); - const result = await buildSessionConfigOptions(harness, 'kimi-coder', true, 'default'); + const result = await buildSessionConfigOptions(harness, 'kimi-coder', 'on', 'default'); const toggle = result.find((o) => o.id === 'thinking'); if (!toggle || toggle.type !== 'select') throw new Error('expected thinking select toggle'); expect(toggle.currentValue).toBe('on'); }); + it('advertises one row per declared effort level for effort-capable models', async () => { + const { harness } = makeHarnessWithModels([ + { + id: 'kimi-k2', + model: 'kimi-k2-thinking', + displayName: 'Kimi K2', + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'high'], + defaultEffort: 'medium', + }, + ]); + + const result = await buildSessionConfigOptions(harness, 'kimi-k2', 'high', 'default'); + const picker = result.find((o) => o.id === 'thinking'); + if (!picker || picker.type !== 'select') throw new Error('expected thinking select picker'); + expect(picker.currentValue).toBe('high'); + expect(picker.options.map((o) => ('value' in o ? o.value : ''))).toEqual([ + 'off', + 'low', + 'medium', + 'high', + ]); + + // The legacy `on` value projects onto the declared default level. + const defaulted = await buildSessionConfigOptions(harness, 'kimi-k2', 'on', 'default'); + const defaultedPicker = defaulted.find((o) => o.id === 'thinking'); + if (!defaultedPicker || defaultedPicker.type !== 'select') { + throw new Error('expected thinking select picker'); + } + expect(defaultedPicker.currentValue).toBe('medium'); + }); + it('locks the thinking toggle to on for always-thinking models even when the session state says off', async () => { const { harness } = makeHarnessWithModels([ { @@ -241,7 +332,7 @@ describe('buildSessionConfigOptions', () => { }, ]); - const result = await buildSessionConfigOptions(harness, 'kimi-deep', false, 'default'); + const result = await buildSessionConfigOptions(harness, 'kimi-deep', 'off', 'default'); const toggle = result.find((o) => o.id === 'thinking'); if (!toggle || toggle.type !== 'select') throw new Error('expected thinking select toggle'); @@ -254,14 +345,14 @@ describe('buildSessionConfigOptions', () => { { id: 'kimi-coder', model: 'kimi-for-coding', displayName: 'Kimi Coder' }, ]); - const result = await buildSessionConfigOptions(harness, 'unknown-model', true, 'default'); + const result = await buildSessionConfigOptions(harness, 'unknown-model', 'on', 'default'); expect(result.map((o) => o.id)).toEqual(['model', 'mode']); }); it('handles missing getConfig (partial-stub harness) by suppressing the toggle and shipping an empty model picker', async () => { const harness = {} as unknown as KimiHarness; - const result = await buildSessionConfigOptions(harness, '', false, 'default'); + const result = await buildSessionConfigOptions(harness, '', 'off', 'default'); expect(result.map((o) => o.id)).toEqual(['model', 'mode']); const modelOpt = result.find((o) => o.id === 'model'); diff --git a/packages/acp-adapter/test/model-catalog.test.ts b/packages/acp-adapter/test/model-catalog.test.ts index bfa852ac82..21bc87fe50 100644 --- a/packages/acp-adapter/test/model-catalog.test.ts +++ b/packages/acp-adapter/test/model-catalog.test.ts @@ -5,6 +5,7 @@ import type { KimiHarness, ModelAlias } from '@moonshot-ai/kimi-code-sdk'; import { deriveAlwaysThinking, deriveDefaultThinkingEffort, + deriveSupportEfforts, deriveThinkingSupported, listModelsFromHarness, } from '../src/model-catalog'; @@ -54,6 +55,25 @@ describe('deriveDefaultThinkingEffort', () => { }); }); +describe('deriveSupportEfforts', () => { + it('returns the declared efforts after override resolution', () => { + expect( + deriveSupportEfforts({ + ...alias('custom-model', ['thinking']), + supportEfforts: ['low', 'high', 'max'], + overrides: { supportEfforts: ['low', 'high'] }, + }), + ).toEqual(['low', 'high']); + }); + + it('drops blank entries and yields an empty list for boolean models', () => { + expect( + deriveSupportEfforts({ ...alias('custom-model', ['thinking']), supportEfforts: [''] }), + ).toEqual([]); + expect(deriveSupportEfforts(alias('custom-model', ['thinking']))).toEqual([]); + }); +}); + describe('listModelsFromHarness', () => { it('advertises thinking with a high default for an unknown model using the Anthropic protocol', async () => { const harness = { @@ -78,6 +98,7 @@ describe('listModelsFromHarness', () => { name: 'custom-anthropic-model', thinkingSupported: true, alwaysThinking: false, + supportEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], defaultThinkingEffort: 'high', }, ]); @@ -102,6 +123,7 @@ describe('listModelsFromHarness', () => { name: 'custom-anthropic-model', thinkingSupported: true, alwaysThinking: false, + supportEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], defaultThinkingEffort: 'high', }, ]); @@ -130,6 +152,7 @@ describe('listModelsFromHarness', () => { name: 'custom-anthropic-model', thinkingSupported: false, alwaysThinking: false, + supportEfforts: [], defaultThinkingEffort: 'on', }, ]); @@ -162,6 +185,7 @@ describe('listModelsFromHarness', () => { name: 'joint-model-0714-vibe', thinkingSupported: true, alwaysThinking: false, + supportEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], defaultThinkingEffort: 'high', }, ]); diff --git a/packages/acp-adapter/test/set-session-config-option.test.ts b/packages/acp-adapter/test/set-session-config-option.test.ts index d47831e2e0..faa5b33873 100644 --- a/packages/acp-adapter/test/set-session-config-option.test.ts +++ b/packages/acp-adapter/test/set-session-config-option.test.ts @@ -264,6 +264,171 @@ describe('AcpServer session/set_config_option', () => { expect(updates).toHaveLength(1); }); + it('configId="thinking" + a declared effort level → setThinking(level) + snapshot carries per-level rows', async () => { + const handle = makeFakeSession('sess-thinking-level'); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => handle.session, + getConfig: async () => ({ + providers: {}, + defaultModel: 'kimi-k2', + models: makeModelsMap([ + { + id: 'kimi-k2', + name: 'Kimi K2', + thinkingSupported: true, + efforts: ['low', 'medium', 'high'], + defaultEffort: 'medium', + }, + ]), + }), + } as unknown as KimiHarness; + const { client, capturing, sessionId } = await openSession(harness); + capturing.notifications.length = 0; + + const response = await client.setSessionConfigOption({ + sessionId, + configId: 'thinking', + value: 'high', + }); + + expect(handle.setThinkingCalls).toEqual(['high']); + const respPicker = response.configOptions.find((o) => o.id === 'thinking'); + if (!respPicker || respPicker.type !== 'select') throw new Error('expected select picker'); + expect(respPicker.currentValue).toBe('high'); + expect(respPicker.options.map((o) => ('value' in o ? o.value : ''))).toEqual([ + 'off', + 'low', + 'medium', + 'high', + ]); + + // The notification snapshot matches the response snapshot. + const updates = capturing.notifications.filter( + (n) => n.sessionId === sessionId && n.update.sessionUpdate === 'config_option_update', + ); + expect(updates).toHaveLength(1); + const update = updates[0]!.update; + if (update.sessionUpdate !== 'config_option_update') throw new Error('unreachable'); + const notifyPicker = update.configOptions.find((o) => o.id === 'thinking'); + if (!notifyPicker || notifyPicker.type !== 'select') throw new Error('expected select picker'); + expect(notifyPicker.currentValue).toBe('high'); + }); + + it('configId="thinking" + "on" maps to the model default effort for effort-capable models', async () => { + const handle = makeFakeSession('sess-thinking-default'); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => handle.session, + getConfig: async () => ({ + providers: {}, + defaultModel: 'kimi-k2', + models: makeModelsMap([ + { + id: 'kimi-k2', + name: 'Kimi K2', + thinkingSupported: true, + efforts: ['low', 'medium', 'high'], + defaultEffort: 'high', + }, + ]), + }), + } as unknown as KimiHarness; + const { client, capturing, sessionId } = await openSession(harness); + capturing.notifications.length = 0; + + const response = await client.setSessionConfigOption({ + sessionId, + configId: 'thinking', + value: 'on', + }); + + expect(handle.setThinkingCalls).toEqual(['high']); + const respPicker = response.configOptions.find((o) => o.id === 'thinking'); + if (!respPicker || respPicker.type !== 'select') throw new Error('expected select picker'); + expect(respPicker.currentValue).toBe('high'); + }); + + it('configId="thinking" + an undeclared level → invalid_params (-32602) BEFORE any SDK call', async () => { + const handle = makeFakeSession('sess-thinking-bogus'); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => handle.session, + getConfig: async () => ({ + providers: {}, + defaultModel: 'kimi-k2', + models: makeModelsMap([ + { + id: 'kimi-k2', + name: 'Kimi K2', + thinkingSupported: true, + efforts: ['low', 'medium', 'high'], + }, + ]), + }), + } as unknown as KimiHarness; + const { client, capturing, sessionId } = await openSession(harness); + capturing.notifications.length = 0; + + await expect( + client.setSessionConfigOption({ sessionId, configId: 'thinking', value: 'xhigh' }), + ).rejects.toMatchObject({ code: -32602 }); + + expect(handle.setThinkingCalls).toEqual([]); + const updates = capturing.notifications.filter( + (n) => n.update.sessionUpdate === 'config_option_update', + ); + expect(updates).toEqual([]); + }); + + it('snapshot reconciles with the engine-normalized effort read back from session status', async () => { + // Always-thinking effort model: the client asks for `off`, the engine + // clamps it back to the default level — the status channel is the + // source of truth for what the snapshot renders. + const handle = makeFakeSession('sess-thinking-clamped'); + const session = { + ...handle.session, + getStatus: async () => ({ thinkingEffort: 'high' }), + } as unknown as Session; + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + getConfig: async () => ({ + providers: {}, + defaultModel: 'kimi-deep', + models: makeModelsMap([ + { + id: 'kimi-deep', + name: 'Kimi Deep', + thinkingSupported: true, + alwaysThinking: true, + efforts: ['low', 'medium', 'high'], + defaultEffort: 'high', + }, + ]), + }), + } as unknown as KimiHarness; + const { client, capturing, sessionId } = await openSession(harness); + capturing.notifications.length = 0; + + const response = await client.setSessionConfigOption({ + sessionId, + configId: 'thinking', + value: 'off', + }); + + expect(handle.setThinkingCalls).toEqual(['off']); + const respPicker = response.configOptions.find((o) => o.id === 'thinking'); + if (!respPicker || respPicker.type !== 'select') throw new Error('expected select picker'); + // Engine clamped `off` → 'high'; no `off` row for always-thinking models. + expect(respPicker.currentValue).toBe('high'); + expect(respPicker.options.map((o) => ('value' in o ? o.value : ''))).toEqual([ + 'low', + 'medium', + 'high', + ]); + }); + const MODE_CASES: ReadonlyArray<{ modeId: 'default' | 'plan' | 'auto' | 'yolo'; expectedPlan: boolean; From 344f296228af8dc67703685991dd2d0883ed14f0 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Tue, 21 Jul 2026 12:32:15 +0800 Subject: [PATCH 2/2] test(acp): avoid spreading the Session stub in the reconciliation test --- .../test/set-session-config-option.test.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/acp-adapter/test/set-session-config-option.test.ts b/packages/acp-adapter/test/set-session-config-option.test.ts index faa5b33873..d674d6f94b 100644 --- a/packages/acp-adapter/test/set-session-config-option.test.ts +++ b/packages/acp-adapter/test/set-session-config-option.test.ts @@ -59,7 +59,7 @@ interface FakeSessionHandle { setThinkingCalls: string[]; } -function makeFakeSession(sessionId: string): FakeSessionHandle { +function makeFakeSession(sessionId: string, statusEffort?: string): FakeSessionHandle { const planModeCalls: boolean[] = []; const setPermissionCalls: PermissionMode[] = []; const setModelCalls: string[] = []; @@ -82,6 +82,12 @@ function makeFakeSession(sessionId: string): FakeSessionHandle { setThinking: async (effort: string) => { setThinkingCalls.push(effort); }, + // Present only when the test exercises the status-reconciliation path; + // `typeof === 'function'` guards elsewhere treat `undefined` as absent. + getStatus: + statusEffort === undefined + ? undefined + : async () => ({ thinkingEffort: statusEffort }), } as unknown as Session; return { session, planModeCalls, setPermissionCalls, setModelCalls, setThinkingCalls }; } @@ -385,14 +391,10 @@ describe('AcpServer session/set_config_option', () => { // Always-thinking effort model: the client asks for `off`, the engine // clamps it back to the default level — the status channel is the // source of truth for what the snapshot renders. - const handle = makeFakeSession('sess-thinking-clamped'); - const session = { - ...handle.session, - getStatus: async () => ({ thinkingEffort: 'high' }), - } as unknown as Session; + const handle = makeFakeSession('sess-thinking-clamped', 'high'); const harness = { auth: { status: async () => AUTHED_STATUS }, - createSession: async () => session, + createSession: async () => handle.session, getConfig: async () => ({ providers: {}, defaultModel: 'kimi-deep',