diff --git a/.changeset/acp-thinking-effort-levels.md b/.changeset/acp-thinking-effort-levels.md new file mode 100644 index 0000000000..7c7bc78211 --- /dev/null +++ b/.changeset/acp-thinking-effort-levels.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Expose per-model thinking effort levels in the ACP config options surface for editor integrations. diff --git a/packages/acp-adapter/src/config-options.ts b/packages/acp-adapter/src/config-options.ts index 7f6088d836..64417e30d7 100644 --- a/packages/acp-adapter/src/config-options.ts +++ b/packages/acp-adapter/src/config-options.ts @@ -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}). * @@ -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' }], }; } + + if (efforts.length > 0) { + const normalizedEffort = + currentEffort !== 'off' && efforts.includes(currentEffort) ? currentEffort : 'off'; + 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' }, ], }; } @@ -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 { const models = await listModelsFromHarness(harness); @@ -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; diff --git a/packages/acp-adapter/src/model-catalog.ts b/packages/acp-adapter/src/model-catalog.ts index 178ce0cf97..17e37af4eb 100644 --- a/packages/acp-adapter/src/model-catalog.ts +++ b/packages/acp-adapter/src/model-catalog.ts @@ -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; } /** @@ -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; diff --git a/packages/acp-adapter/src/server.ts b/packages/acp-adapter/src/server.ts index 558901b5c3..25b1d28160 100644 --- a/packages/acp-adapter/src/server.ts +++ b/packages/acp-adapter/src/server.ts @@ -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 @@ -386,7 +380,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 +388,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 @@ -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); @@ -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, ); @@ -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 }; @@ -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: @@ -792,7 +785,7 @@ export class AcpServer implements Agent { configOptions: await buildSessionConfigOptions( this.harness, acpSession.currentModelId, - acpSession.currentThinkingEnabled, + acpSession.currentThinkingEffort, acpSession.currentModeId, ), }; @@ -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 { - const resumed = thinkingEnabledFromEffort(resumedThinkingEffort); - if (resumed !== undefined) return resumed; + ): Promise { + 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'; } } diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index 0121bde4e2..f33c88c702 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -101,30 +101,28 @@ export class AcpSession { * `,thinking` suffix) for the `configOptions` model picker (PLAN D11). * Updated by {@link setModel} after the SDK call lands. Phase 15 * decoupled thinking from the model id — see - * {@link currentThinkingEnabledInternal} — so this field never carries + * {@link currentThinkingEffortInternal} — so this field never carries * a `,thinking` suffix even when the client originally sent one * through `unstable_setSessionModel`. */ private currentModelIdInternal: string; /** - * The adapter-side authoritative current thinking-toggle state. + * The adapter-side authoritative current thinking effort. * 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). + * 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`). + * Holds the raw effort string (`'off'`, `'low'`, `'medium'`, …) that + * is forwarded to `Session.setThinking`. The `configOptions` snapshot + * builds the visible option set from the current model's + * `supportEfforts`: models with effort lists render a dropdown; models + * without them keep the legacy binary `off`/`on` select. */ - private currentThinkingEnabledInternal = false; + private currentThinkingEffortInternal = 'off'; /** * The adapter-side authoritative current mode id. Updated by @@ -190,7 +188,7 @@ export class AcpSession { * `configOptions.model.currentValue`. Defaults to empty string when * absent (adapter-level unit tests). Phase 15: must be the bare model * key (no `,thinking` suffix); thinking is carried separately by - * {@link initialThinkingEnabled}. + * {@link initialThinkingEffort}. */ initialModelId?: string, /** @@ -204,16 +202,16 @@ 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. Phase 15 introduces + * this so resumed sessions whose persisted `thinkingEffort` was + * non-`'off'` start with the toggle on. 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 +250,12 @@ export class AcpSession { } /** - * Adapter-side authoritative thinking-toggle state, used by + * Adapter-side authoritative 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,11 +315,10 @@ 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 — 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 effort + * (forwarded to `Session.setThinking`). * * Wire semantics: * - `'kimi-v2'` → setModel('kimi-v2'); thinking state unchanged. @@ -330,16 +327,14 @@ export class AcpSession { * * 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 - * 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). + * preserve thinking state. To explicitly disable thinking, the client + * must call `setSessionConfigOption({ configId: 'thinking', value: 'off' })`. * * `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 - * {@link buildSessionConfigOptions}'s `thinkingSupported` gate. + * `currentValue`. Thinking visibility in the snapshot is governed by + * `currentThinkingEffortInternal` and {@link buildSessionConfigOptions}'s + * `thinkingSupported` gate. * * Unknown model errors bubble up from the SDK as-is; the caller in * `AcpServer.unstable_setSessionModel` decides how to translate them. @@ -350,39 +345,39 @@ export class AcpSession { const baseKey = hasSuffix ? modelId.slice(0, -suffix.length) : modelId; await this.session.setModel(baseKey); if (hasSuffix && typeof this.session.setThinking === 'function') { - await this.session.setThinking(await this.thinkingOnEffort()); - this.currentThinkingEnabledInternal = true; + const effort = await this.thinkingOnEffort(); + await this.session.setThinking(effort); + this.currentThinkingEffortInternal = effort; } 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')`. + * The value is the effort string (`'off'`, `'low'`, `'medium'`, …) + * selected by the client. `'on'` is accepted as a legacy alias for + * the current model's default effort (see {@link thinkingOnEffort}); + * any other value is passed through to `Session.setThinking` as-is. * * 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 - * state and emit the snapshot, so the ACP wire stays consistent — - * the test simply doesn't observe an SDK call. + * 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. * * Always emits a `config_option_update` notification afterwards so - * the client sees the toggle reflect the new value, even if it - * came in through the funnel and the response itself already - * carries a fresh snapshot. + * the client sees the toggle 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 resolvedEffort = effort === 'on' ? await this.thinkingOnEffort() : 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(resolvedEffort); } - this.currentThinkingEnabledInternal = enabled; + this.currentThinkingEffortInternal = resolvedEffort; await this.emitConfigOptionUpdate(); } @@ -471,7 +466,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)); diff --git a/packages/acp-adapter/test/config-options.test.ts b/packages/acp-adapter/test/config-options.test.ts index 08475497f3..edf2d8bcb0 100644 --- a/packages/acp-adapter/test/config-options.test.ts +++ b/packages/acp-adapter/test/config-options.test.ts @@ -101,8 +101,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('produces a `type:"select"` `category:"thought_level"` option with `off`/`on` entries for binary models', () => { + const on = buildThinkingOption('on', undefined); expect(on.type).toBe('select'); expect(on.id).toBe('thinking'); expect(on.category).toBe('thought_level'); @@ -110,19 +110,65 @@ 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', undefined); if (off.type !== 'select') throw new Error('expected SessionConfigSelect'); expect(off.currentValue).toBe('off'); }); - it('collapses to a single locked "on" entry for always-thinking models', () => { - const locked = buildThinkingOption(true, true); + it('exposes effort levels when the model advertises supportEfforts', () => { + const option = buildThinkingOption('medium', ['low', 'medium', 'high']); + if (option.type !== 'select') throw new Error('expected SessionConfigSelect'); + expect(option.currentValue).toBe('medium'); + 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', + ]); + }); + + it('falls back to `off` when the current effort is not in supportEfforts', () => { + const option = buildThinkingOption('xhigh', ['low', 'medium', 'high']); + if (option.type !== 'select') throw new Error('expected SessionConfigSelect'); + expect(option.currentValue).toBe('off'); + }); + + it('collapses to a single locked "on" entry for always-thinking models without effort lists', () => { + const locked = buildThinkingOption('on', undefined, 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('shows effort choices without an off entry for always-thinking models with supportEfforts', () => { + const locked = buildThinkingOption('high', ['low', 'medium', 'high'], true); + if (locked.type !== 'select') throw new Error('expected SessionConfigSelect'); + expect(locked.currentValue).toBe('high'); + expect(locked.options.map((o) => ('value' in o ? o.value : ''))).toEqual([ + 'low', + 'medium', + 'high', + ]); + expect(locked.options.map((o) => ('name' in o ? o.name : ''))).toEqual([ + 'Low', + 'Medium', + 'High', + ]); + }); + + it('falls back to the first effort for always-thinking models when the current effort is unsupported', () => { + const locked = buildThinkingOption('xhigh', ['low', 'medium', 'high'], true); + if (locked.type !== 'select') throw new Error('expected SessionConfigSelect'); + expect(locked.currentValue).toBe('low'); }); }); @@ -159,7 +205,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 +235,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 +250,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 +261,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 +271,58 @@ 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('renders an effort dropdown for models that advertise supportEfforts', async () => { + const { harness } = makeHarnessWithModels([ + { + id: 'claude', + model: 'claude-sonnet-4-20250514', + displayName: 'Claude Sonnet 4', + protocol: 'anthropic', + providerType: 'anthropic', + }, + ]); + + const result = await buildSessionConfigOptions(harness, 'claude', 'low', '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('low'); + expect(toggle.options.map((o) => ('value' in o ? o.value : ''))).toEqual([ + 'off', + 'low', + 'medium', + 'high', + ]); + }); + + it('maps a carried unsupported effort to the target model default effort', async () => { + // Switching from a model with `max` to a model whose catalog only + // declares `['low', 'high']` should show `high` (the default), not `off`. + const harness = { + getConfig: async () => ({ + providers: {}, + models: { + 'custom-reasoner': { + model: 'custom-reasoner', + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + defaultEffort: 'high', + }, + }, + }), + } as unknown as KimiHarness; + + const result = await buildSessionConfigOptions(harness, 'custom-reasoner', 'max', '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('high'); + }); + it('locks the thinking toggle to on for always-thinking models even when the session state says off', async () => { const { harness } = makeHarnessWithModels([ { @@ -241,7 +333,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 +346,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..2d008a31cc 100644 --- a/packages/acp-adapter/test/model-catalog.test.ts +++ b/packages/acp-adapter/test/model-catalog.test.ts @@ -79,6 +79,7 @@ describe('listModelsFromHarness', () => { thinkingSupported: true, alwaysThinking: false, defaultThinkingEffort: 'high', + supportEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], }, ]); }); @@ -103,6 +104,7 @@ describe('listModelsFromHarness', () => { thinkingSupported: true, alwaysThinking: false, defaultThinkingEffort: 'high', + supportEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], }, ]); }); @@ -163,6 +165,7 @@ describe('listModelsFromHarness', () => { thinkingSupported: true, alwaysThinking: false, defaultThinkingEffort: 'high', + supportEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], }, ]); }); diff --git a/packages/acp-adapter/test/session-resume.test.ts b/packages/acp-adapter/test/session-resume.test.ts index 2b4640ece4..2e15c28c03 100644 --- a/packages/acp-adapter/test/session-resume.test.ts +++ b/packages/acp-adapter/test/session-resume.test.ts @@ -62,7 +62,7 @@ function makeInMemoryStreamPair(): { /** * Build a fake {@link Session} whose `getResumeState` reports the given * main-agent config so the server's resume-state projection (modelAlias - * → currentModelId, thinkingEffort → currentThinkingEnabled) gets a + * → currentModelId, thinkingEffort → currentThinkingEffort) gets a * deterministic input. History is empty because `resumeSession` does * not replay anyway — the field is kept for API parity with the * matching session-load helper.