Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/acp-thinking-effort-levels.md
Original file line number Diff line number Diff line change
@@ -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.
125 changes: 76 additions & 49 deletions packages/acp-adapter/src/config-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}).
*
Expand All @@ -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
Expand Down Expand Up @@ -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;
Comment on lines +132 to +137

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep thinking currentValue within advertised options

When a model declares default_effort that is not present in support_efforts (the schema allows this, and agent-core's defaultThinkingEffortFor falls back to a declared effort), this fallback makes the ACP select's currentValue become that invalid default even though options only contains off plus supportEfforts. In that configuration, a fresh snapshot with current effort on/unknown can render a value the client cannot select; validate defaultEffort against efforts and fall back to the middle declared effort instead.

Useful? React with 👍 / 👎.

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 →
Expand All @@ -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
Expand All @@ -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<SessionConfigOption[]> {
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;
Expand Down
33 changes: 29 additions & 4 deletions packages/acp-adapter/src/model-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
});
}
Expand Down
Loading
Loading