Skip to content
Closed
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": patch
---

Expose per-model thinking effort levels in the ACP config options surface for editor integrations.
117 changes: 87 additions & 30 deletions packages/acp-adapter/src/config-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,13 @@
* — 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`).
* converted this from `SessionConfigBoolean` to a `type: 'select'`
* control so Zed renders it — Zed's chip strip currently only knows
* how to draw `select` options, and the spec's `boolean` arm shows
* up as "Unknown". Models that advertise `supportEfforts` render an
* effort dropdown (`off`, `low`, `medium`, …) matching the VS Code
* `ThinkingButton`; models without effort lists keep the legacy
* binary `off`/`on` select.
* - `id: 'mode'` (`type: 'select'`, `category: 'mode'`) — the
* locked 4-mode taxonomy from PLAN D9 ({@link ACP_MODES}).
*
Expand Down Expand Up @@ -79,55 +78,88 @@ export function buildModelOption(
}

/**
* Build the `thinking` toggle.
* Human-readable label for a thinking effort value, matching the VS Code
* `ThinkingButton` component: capitalise the first letter (`off` → `Off`).
*/
function label(effort: string): string {
return effort.length === 0 ? effort : effort.charAt(0).toUpperCase() + effort.slice(1);
}

/**
* Build the `thinking` config option.
*
* 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
* 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 control shape now mirrors the VS Code `ThinkingButton` component:
* - `alwaysThinking` models collapse to a single locked-on entry.
* - Models that advertise `supportEfforts` render an effort dropdown
* (`off`, `low`, `medium`, …) with the current effort selected.
* - All other thinking-capable models keep the legacy binary
* `off`/`on` select for backwards compatibility.
*
* 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.
*
* `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.
*/
export function buildThinkingOption(
enabled: boolean,
currentEffort: string,
supportEfforts: readonly string[] | undefined,
alwaysThinking = false,
): SessionConfigOption {
const efforts = supportEfforts ?? [];

// Always-thinking models cannot be turned off. If they advertise effort
// levels we still show the effort selector (omitting `off`), matching the
// VS Code `ThinkingButton` when `alwaysOn` is set.
if (alwaysThinking) {
if (efforts.length > 0) {
const normalizedEffort =
currentEffort !== 'off' && efforts.includes(currentEffort) ? currentEffort : efforts[0]!;
return {
type: 'select',
id: 'thinking',
name: 'Thinking',
category: 'thought_level',
currentValue: normalizedEffort,
options: efforts.map((effort) => ({ value: effort, name: label(effort) })),
};
}
return {
type: 'select',
id: 'thinking',
name: 'Thinking',
category: 'thought_level',
currentValue: 'on',
options: [{ value: 'on', name: 'Thinking On' }],
options: [{ value: 'on', name: 'On' }],
Comment on lines 134 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 Show effort choices for always-on effort models

When a catalog row has both alwaysThinking and supportEfforts, this early return runs before the effort-dropdown branch, so ACP exposes only a single on choice. Such rows are real: effectiveModelAlias assigns always_thinking for profiles with canDisableThinking === false while still copying profile.efforts (packages/agent-core/src/config/model.ts:41-56), and managed/open-platform models can also be supports_thinking_type='only' with efforts. In those environments the new ACP surface still hides the low/medium/high/... selector for effort-capable models; the always-on case should omit only off, not collapse all efforts to on.

Useful? React with 👍 / 👎.

};
}

if (efforts.length > 0) {
const normalizedEffort =
currentEffort !== 'off' && efforts.includes(currentEffort) ? currentEffort : 'off';
Comment on lines +142 to +143

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 Don't render carried thinking effort as off

When currentEffort is any non-off value that is not declared by the currently selected model, this reports currentValue: 'off' without actually disabling thinking. For example, after selecting max on one model and switching to a Kimi model whose supportEfforts are only ['low','high'], AcpSession.setModel preserves the non-off adapter state while agent-core normalizes unsupported Kimi efforts to the target model default (packages/agent-core/src/agent/config/thinking.ts:79-81); the ACP snapshot then tells the client thinking is off even though the session will run with thinking on. Prefer the target/default effort for non-off carried values, or refresh the adapter state from the session after the model switch.

Useful? React with 👍 / 👎.

return {
type: 'select',
id: 'thinking',
name: 'Thinking',
category: 'thought_level',
currentValue: normalizedEffort,
options: [{ value: 'off', name: 'Off' }, ...efforts.map((effort) => ({ value: effort, name: label(effort) }))],
};
}

return {
type: 'select',
id: 'thinking',
name: 'Thinking',
category: 'thought_level',
currentValue: enabled ? 'on' : 'off',
currentValue: currentEffort === 'off' ? 'off' : 'on',
options: [
{ value: 'off', name: 'Thinking Off' },
{ value: 'on', name: 'Thinking On' },
{ value: 'off', name: 'Off' },
{ value: 'on', name: 'On' },
],
};
}
Expand Down Expand Up @@ -186,7 +218,7 @@ 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);
Expand All @@ -196,8 +228,33 @@ export async function buildSessionConfigOptions(
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));
// recorded effort — agent-core clamps the runtime the same way.
// The legacy `'on'` sentinel, or any effort not declared by the current
// model, maps to the model's default effort so the snapshot matches the
// effective runtime state after agent-core normalizes unsupported values.
const efforts = currentModelEntry?.supportEfforts;
let normalizedEffort = currentThinkingEffort;
if (
normalizedEffort === 'off' ||
(efforts !== undefined &&
efforts.length > 0 &&
!efforts.includes(normalizedEffort))
) {
normalizedEffort =
normalizedEffort === 'off'
? 'off'
: (currentModelEntry?.defaultThinkingEffort ?? 'on');
}
if (alwaysThinking && normalizedEffort === 'off') {
normalizedEffort = currentModelEntry?.defaultThinkingEffort ?? 'on';
}
out.push(
buildThinkingOption(
normalizedEffort,
currentModelEntry?.supportEfforts,
alwaysThinking,
),
);
}
out.push(buildModeOption(currentModeId));
return out;
Expand Down
8 changes: 8 additions & 0 deletions packages/acp-adapter/src/model-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ export interface AcpModelEntry {
* `defaultThinkingEffortFor` so the ACP on-state matches the TUI.
*/
readonly defaultThinkingEffort: string;
/**
* The effort levels the model advertises. When absent or empty the ACP
* thinking control falls back to a binary `off`/`on` select; otherwise
* it renders an effort dropdown (`off`, `low`, `medium`, …) like the
* VS Code `ThinkingButton` component.
*/
readonly supportEfforts?: readonly string[] | undefined;
}

/**
Expand Down Expand Up @@ -134,6 +141,7 @@ export async function listModelsFromHarness(
thinkingSupported: deriveThinkingSupported(alias, providerType),
alwaysThinking: deriveAlwaysThinking(alias, providerType),
defaultThinkingEffort: deriveDefaultThinkingEffort(alias, providerType),
supportEfforts: effective.supportEfforts,
});
}
return out;
Expand Down
79 changes: 41 additions & 38 deletions packages/acp-adapter/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,12 +200,6 @@ function nonEmptyString(value: string | undefined): string | undefined {
return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed;
}

function thinkingEnabledFromEffort(effort: unknown): boolean | undefined {
if (typeof effort !== 'string') return undefined;
const normalized = effort.trim().toLowerCase();
return normalized.length > 0 && normalized !== 'off';
}

/**
* Agent-side ACP handler. Routes `initialize` + `session/new` + `session/cancel`
* into {@link KimiHarness}; refuses methods that are not yet wired with a
Expand Down Expand Up @@ -386,15 +380,15 @@ 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,
this.clientCapabilities,
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
Expand All @@ -412,7 +406,7 @@ export class AcpServer implements Agent {
const configOptions = await buildSessionConfigOptions(
this.harness,
currentModelId,
currentThinkingEnabled,
currentThinkingEffort,
DEFAULT_MODE_ID,
);
this.scheduleAvailableCommandsUpdate(session.id);
Expand Down Expand Up @@ -586,7 +580,7 @@ export class AcpServer implements Agent {
// exposes the boolean axis. 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,
);
Expand All @@ -597,13 +591,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 };
Expand Down Expand Up @@ -773,13 +767,12 @@ 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 thinking option now carries effort strings (`'off'`,
// `'low'`, `'medium'`, …) for models that advertise
// `supportEfforts`, while legacy/binary clients still send `'on'`
// / `'off'`. Pass the value through as a string; `AcpSession`
// normalises `'on'` to the current model's default effort.
await acpSession.setThinking(String(value));
break;
}
default:
Expand All @@ -792,7 +785,7 @@ export class AcpServer implements Agent {
configOptions: await buildSessionConfigOptions(
this.harness,
acpSession.currentModelId,
acpSession.currentThinkingEnabled,
acpSession.currentThinkingEffort,
acpSession.currentModeId,
),
};
Expand Down Expand Up @@ -914,48 +907,58 @@ 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 thinking effort for the `thinking` config option
* 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.
*
* Returns the raw effort string (`'off'`, `'low'`, `'medium'`, …) or
* the legacy `'on'` sentinel when only a boolean enabled flag is
* available. `'on'` is normalised to the current model's default effort
* by {@link buildSessionConfigOptions} before it reaches the wire.
*
* 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<boolean> {
const resumed = thinkingEnabledFromEffort(resumedThinkingEffort);
if (resumed !== undefined) return resumed;
): Promise<string> {
if (typeof resumedThinkingEffort === 'string' && resumedThinkingEffort.length > 0) {
return resumedThinkingEffort;
}

if (typeof session.getStatus === 'function') {
try {
const current = thinkingEnabledFromEffort((await session.getStatus()).thinkingEffort);
if (current !== undefined) return current;
const status = await session.getStatus();
if (typeof status.thinkingEffort === 'string' && status.thinkingEffort.length > 0) {
return status.thinkingEffort;
}
} 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 (configured !== undefined) return configured;
return thinking?.enabled === true;
if (thinking?.enabled === false) return 'off';
if (typeof thinking?.effort === 'string' && thinking.effort.length > 0) {
return thinking.effort;
}
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';
}
}

Expand Down
Loading