Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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