Skip to content

Commit 596cadd

Browse files
authored
feat: support always-thinking models via supports_thinking_type (#662)
Map the /models three-state supports_thinking_type field ('only' / 'no' / 'both', taking precedence over the legacy supports_reasoning boolean) onto the existing always_thinking capability: - oauth: parse the field in both /models parsers; 'only' emits always_thinking alongside thinking, 'no' suppresses thinking even when supports_reasoning is set, absent falls back to the legacy boolean. Default thinking selection is forced on for 'only' (and off for 'no') models during login and provider refresh - TUI: render the thinking control with a fixed On/Off layout — locked models show a greyed-out "Off (Unsupported)" segment, and non-thinking models mirror the style with "On (Unsupported)" - agent-core: clamp thinkingLevel at the getter so a stale thinking-off config can never reach the request builder, status events, or subagent inheritance - acp-adapter: derive alwaysThinking from capabilities, collapse the thinking select to a single locked "on" entry, and ignore off requests for locked models while re-emitting the snapshot
1 parent 1e2e679 commit 596cadd

18 files changed

Lines changed: 735 additions & 14 deletions

File tree

apps/kimi-code/src/tui/components/dialogs/model-selector.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,13 +259,19 @@ export class ModelSelectorComponent extends Container implements Focusable {
259259
active
260260
? currentTheme.boldFg('primary', `[ ${label} ]`)
261261
: currentTheme.fg('text', ` ${label} `);
262+
// The whole segment is muted, suffix included, so the disabled side reads
263+
// as a single greyed-out control rather than a selectable option.
264+
const unavailable = (label: string): string =>
265+
currentTheme.fg('textMuted', ` ${label} (Unsupported) `);
262266

267+
// On stays left and Off right in all three states so the control never
268+
// shifts while the cursor moves across models.
263269
const availability = thinkingAvailability(choice.model);
264270
if (availability === 'always-on') {
265-
return ` ${segment('Always on', true)}`;
271+
return ` ${segment('On', true)} ${unavailable('Off')}`;
266272
}
267273
if (availability === 'unsupported') {
268-
return ` ${segment('Off', true)} ${currentTheme.fg('textMuted', 'unsupported')}`;
274+
return ` ${unavailable('On')} ${segment('Off', true)}`;
269275
}
270276
const draft = this.draftFor(choice);
271277
return ` ${segment('On', draft)} ${segment('Off', !draft)}`;

apps/kimi-code/src/tui/utils/refresh-providers.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,10 @@ function restoreDefaultSelection(
182182
): void {
183183
if (defaultModel === undefined || config.models?.[defaultModel] === undefined) return;
184184
config.defaultModel = defaultModel;
185-
config.defaultThinking = defaultThinking;
185+
// A refresh may have just learned that the default model cannot disable
186+
// thinking — never restore a stale thinking-off selection onto it.
187+
const capabilities = config.models[defaultModel]?.capabilities ?? [];
188+
config.defaultThinking = capabilities.includes('always_thinking') ? true : defaultThinking;
186189
}
187190

188191
// `apply*` may leave `defaultModel` pointing at an alias that no longer exists

apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { visibleWidth } from '@earendil-works/pi-tui';
33
import { describe, expect, it, vi } from 'vitest';
44

55
import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector';
6+
import { currentTheme } from '#/tui/theme';
67
import { darkColors } from '#/tui/theme/colors';
78

89
const ANSI = /\[[0-9;]*m/g;
@@ -94,16 +95,60 @@ describe('ModelSelectorComponent', () => {
9495
onCancel: vi.fn(),
9596
});
9697

97-
expect(text(picker)).toContain('[ Always on ]');
98+
// Always-on: On selected, Off greyed out with an explanation.
99+
const alwaysOut = text(picker);
100+
expect(alwaysOut).toContain('[ On ]');
101+
expect(alwaysOut).toContain('Off (Unsupported)');
102+
expect(alwaysOut).not.toContain('Always on');
98103
picker.handleInput('\r');
99104
expect(onSelect).toHaveBeenLastCalledWith({ alias: 'always', thinking: true });
100105

106+
// Unsupported: Off selected, On greyed out — same style, mirrored.
101107
picker.handleInput(DOWN);
102-
expect(text(picker)).toContain('[ Off ] unsupported');
108+
const plainOut = text(picker);
109+
expect(plainOut).toContain('On (Unsupported)');
110+
expect(plainOut).toContain('[ Off ]');
111+
expect(plainOut).not.toContain('] unsupported');
103112
picker.handleInput('\r');
104113
expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: false });
105114
});
106115

116+
it('ignores Left/Right on always-on and unsupported models', () => {
117+
const onSelect = vi.fn();
118+
const picker = new ModelSelectorComponent({
119+
models: {
120+
always: model('Kimi Thinking', ['always_thinking']),
121+
plain: model('Kimi Plain', ['tool_use']),
122+
},
123+
currentValue: 'always',
124+
currentThinking: true,
125+
onSelect,
126+
onCancel: vi.fn(),
127+
});
128+
129+
picker.handleInput(RIGHT);
130+
picker.handleInput('\r');
131+
expect(onSelect).toHaveBeenLastCalledWith({ alias: 'always', thinking: true });
132+
133+
picker.handleInput(DOWN);
134+
picker.handleInput(LEFT);
135+
picker.handleInput('\r');
136+
expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: false });
137+
});
138+
139+
it('renders the unavailable thinking segment muted', () => {
140+
const picker = new ModelSelectorComponent({
141+
models: { always: model('Kimi Thinking', ['always_thinking']) },
142+
currentValue: 'always',
143+
currentThinking: true,
144+
onSelect: vi.fn(),
145+
onCancel: vi.fn(),
146+
});
147+
148+
const raw = picker.render(120).join('\n');
149+
expect(raw).toContain(currentTheme.fg('textMuted', ' Off (Unsupported) '));
150+
});
151+
107152
it('keeps the thinking draft when moving across models', () => {
108153
const onSelect = vi.fn();
109154
const picker = new ModelSelectorComponent({

apps/kimi-code/test/tui/utils/refresh-providers.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,4 +342,61 @@ describe('refreshAllProviderModels', () => {
342342
expect(host.current().defaultModel).toBe(userAlias);
343343
expect(host.current().defaultThinking).toBe(false);
344344
});
345+
346+
it('forces default thinking on when the refreshed default model cannot disable thinking', async () => {
347+
const host = makeRefreshHost({
348+
providers: {
349+
[KIMI_CODE_PROVIDER_NAME]: {
350+
type: 'kimi',
351+
apiKey: '',
352+
oauth: { storage: 'file', key: 'oauth/kimi-code' },
353+
},
354+
},
355+
models: {
356+
'kimi-code/kimi-deep-coder': {
357+
provider: KIMI_CODE_PROVIDER_NAME,
358+
model: 'kimi-deep-coder',
359+
maxContextSize: 262144,
360+
capabilities: ['thinking', 'tool_use'],
361+
},
362+
},
363+
defaultModel: 'kimi-code/kimi-deep-coder',
364+
defaultThinking: false,
365+
telemetry: true,
366+
} as unknown as KimiConfig);
367+
368+
const fetchMock = vi.fn<FetchMock>(
369+
async () =>
370+
new Response(
371+
JSON.stringify({
372+
data: [
373+
{
374+
id: 'kimi-deep-coder',
375+
context_length: 262144,
376+
supports_reasoning: true,
377+
supports_thinking_type: 'only',
378+
},
379+
],
380+
}),
381+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
382+
),
383+
);
384+
vi.stubGlobal('fetch', fetchMock);
385+
386+
const result = await refreshAllProviderModels({
387+
getConfig: async () => host.current(),
388+
removeProvider: host.removeProvider,
389+
setConfig: host.setConfig,
390+
resolveOAuthToken: vi.fn(async () => 'oauth-access-token'),
391+
});
392+
393+
expect(result.failed).toEqual([]);
394+
expect(host.current().models?.['kimi-code/kimi-deep-coder']?.capabilities).toEqual([
395+
'thinking',
396+
'always_thinking',
397+
'tool_use',
398+
]);
399+
expect(host.current().defaultModel).toBe('kimi-code/kimi-deep-coder');
400+
expect(host.current().defaultThinking).toBe(true);
401+
});
345402
});

packages/acp-adapter/src/config-options.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,28 @@ export function buildModelOption(
9696
* currently-selected model has `thinkingSupported === false`, the
9797
* snapshot omits it entirely (dynamic visibility), so the client never
9898
* shows a toggle that wouldn't do anything.
99+
*
100+
* `alwaysThinking` models (declared `always_thinking` capability — the
101+
* runtime cannot disable thinking) collapse the select to a single
102+
* locked `on` entry: the state stays visible to the client, but there
103+
* is no off option to pick. ACP has no "disabled entry" concept, so
104+
* omitting `off` is the wire-level equivalent of the TUI's greyed-out
105+
* `Off (Unsupported)` segment.
99106
*/
100-
export function buildThinkingOption(enabled: boolean): SessionConfigOption {
107+
export function buildThinkingOption(
108+
enabled: boolean,
109+
alwaysThinking = false,
110+
): SessionConfigOption {
111+
if (alwaysThinking) {
112+
return {
113+
type: 'select',
114+
id: 'thinking',
115+
name: 'Thinking',
116+
category: 'thought_level',
117+
currentValue: 'on',
118+
options: [{ value: 'on', name: 'Thinking On' }],
119+
};
120+
}
101121
return {
102122
type: 'select',
103123
id: 'thinking',
@@ -171,9 +191,12 @@ export async function buildSessionConfigOptions(
171191
const models = await listModelsFromHarness(harness);
172192
const currentModelEntry = models.find((m) => m.id === currentBaseModelId);
173193
const showThinking = currentModelEntry?.thinkingSupported === true;
194+
const alwaysThinking = currentModelEntry?.alwaysThinking === true;
174195
const out: SessionConfigOption[] = [buildModelOption(models, currentBaseModelId)];
175196
if (showThinking) {
176-
out.push(buildThinkingOption(currentThinkingEnabled));
197+
// Always-thinking models render locked-on regardless of the session's
198+
// recorded toggle state — agent-core clamps the runtime the same way.
199+
out.push(buildThinkingOption(alwaysThinking || currentThinkingEnabled, alwaysThinking));
177200
}
178201
out.push(buildModeOption(currentModeId));
179202
return out;

packages/acp-adapter/src/model-catalog.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export interface AcpModelEntry {
3535
readonly name: string;
3636
readonly description?: string | undefined;
3737
readonly thinkingSupported: boolean;
38+
/** Declared 'always_thinking' capability — thinking cannot be turned off. */
39+
readonly alwaysThinking?: boolean;
3840
}
3941

4042
/**
@@ -47,13 +49,24 @@ const TOGGLEABLE_THINKING_MODELS = new Set(['kimi-for-coding', 'kimi-code']);
4749

4850
export function deriveThinkingSupported(alias: ModelAlias): boolean {
4951
const declared = alias.capabilities ?? [];
50-
if (declared.includes('thinking')) return true;
52+
if (declared.includes('thinking') || declared.includes('always_thinking')) return true;
5153
const lower = alias.model.toLowerCase();
5254
if (lower.includes('thinking') || lower.includes('reason')) return true;
5355
if (TOGGLEABLE_THINKING_MODELS.has(alias.model)) return true;
5456
return false;
5557
}
5658

59+
/**
60+
* Whether the alias declares the 'always_thinking' capability — the model
61+
* cannot run with thinking disabled, so the ACP toggle must lock to on.
62+
* Deliberately capability-only: the name heuristics above keep feeding
63+
* `thinkingSupported`, but only an explicit (server-derived) declaration
64+
* may remove the off option from the client.
65+
*/
66+
export function deriveAlwaysThinking(alias: ModelAlias): boolean {
67+
return (alias.capabilities ?? []).includes('always_thinking');
68+
}
69+
5770
/**
5871
* Project `harness.getConfig().models` into a flat catalog. Returns an
5972
* empty array when the harness has no models configured, when
@@ -80,6 +93,7 @@ export async function listModelsFromHarness(
8093
id,
8194
name: alias.displayName ?? alias.model ?? id,
8295
thinkingSupported: deriveThinkingSupported(alias),
96+
alwaysThinking: deriveAlwaysThinking(alias),
8397
});
8498
}
8599
return out;

packages/acp-adapter/src/session.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
type AcpBuiltinSlashCommandName,
3838
} from './builtin-commands';
3939
import { buildSessionConfigOptions } from './config-options';
40+
import { listModelsFromHarness } from './model-catalog';
4041
import { acpBlocksToPromptParts } from './convert';
4142
import {
4243
acpToolCallId,
@@ -365,13 +366,33 @@ export class AcpSession {
365366
* carries a fresh snapshot.
366367
*/
367368
async setThinking(enabled: boolean): Promise<void> {
369+
if (!enabled && (await this.currentModelAlwaysThinking())) {
370+
// The current model cannot disable thinking (declared
371+
// 'always_thinking'); silently ignore the off request — agent-core
372+
// clamps the runtime the same way — but still refresh the snapshot
373+
// so a stale client toggle snaps back to on.
374+
this.currentThinkingEnabledInternal = true;
375+
await this.emitConfigOptionUpdate();
376+
return;
377+
}
368378
if (typeof this.session.setThinking === 'function') {
369379
await this.session.setThinking(enabled ? THINKING_ON_LEVEL : THINKING_OFF_LEVEL);
370380
}
371381
this.currentThinkingEnabledInternal = enabled;
372382
await this.emitConfigOptionUpdate();
373383
}
374384

385+
/**
386+
* Whether the currently-selected model declares 'always_thinking'.
387+
* Harness-less adapter unit tests resolve to false — the agent-core
388+
* runtime clamp still protects the actual request in that case.
389+
*/
390+
private async currentModelAlwaysThinking(): Promise<boolean> {
391+
if (!this.harness) return false;
392+
const models = await listModelsFromHarness(this.harness);
393+
return models.find((m) => m.id === this.currentModelIdInternal)?.alwaysThinking === true;
394+
}
395+
375396
/**
376397
* Forward an ACP `session/set_mode` request to the underlying SDK
377398
* session.

packages/acp-adapter/test/_helpers/harness-stubs.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,28 @@ export const UNAUTHED_STATUS = {
2929
* mirrors what a real config file would carry.
3030
*/
3131
export function makeModelsMap(
32-
entries: ReadonlyArray<{ id: string; name?: string; thinkingSupported?: boolean }>,
32+
entries: ReadonlyArray<{
33+
id: string;
34+
name?: string;
35+
thinkingSupported?: boolean;
36+
alwaysThinking?: boolean;
37+
}>,
3338
): Record<string, ModelAlias> {
3439
const out: Record<string, ModelAlias> = {};
3540
for (const entry of entries) {
41+
const capabilities = entry.alwaysThinking === true
42+
? ['thinking', 'always_thinking']
43+
: entry.thinkingSupported === true
44+
? ['thinking']
45+
: undefined;
3646
out[entry.id] = {
3747
// The fields below are the minimum shape the adapter reads off
3848
// each alias — `provider`/`max_context_size` are required by the
3949
// schema but unused by the model catalog, so they're skipped
4050
// here and the partial-record cast keeps the test stub honest.
4151
model: entry.id,
4252
...(entry.name !== undefined ? { displayName: entry.name } : {}),
43-
...(entry.thinkingSupported === true ? { capabilities: ['thinking'] } : {}),
53+
...(capabilities !== undefined ? { capabilities } : {}),
4454
} as ModelAlias;
4555
}
4656
return out;

packages/acp-adapter/test/config-options.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,14 @@ describe('buildThinkingOption', () => {
9494
if (off.type !== 'select') throw new Error('expected SessionConfigSelect');
9595
expect(off.currentValue).toBe('off');
9696
});
97+
98+
it('collapses to a single locked "on" entry for always-thinking models', () => {
99+
const locked = buildThinkingOption(true, true);
100+
if (locked.type !== 'select') throw new Error('expected SessionConfigSelect');
101+
expect(locked.currentValue).toBe('on');
102+
expect(locked.options.map((o) => ('value' in o ? o.value : ''))).toEqual(['on']);
103+
expect(locked.options.map((o) => ('name' in o ? o.name : ''))).toEqual(['Thinking On']);
104+
});
97105
});
98106

99107
describe('buildModeOption', () => {
@@ -171,6 +179,24 @@ describe('buildSessionConfigOptions', () => {
171179
expect(toggle.currentValue).toBe('on');
172180
});
173181

182+
it('locks the thinking toggle to on for always-thinking models even when the session state says off', async () => {
183+
const { harness } = makeHarnessWithModels([
184+
{
185+
id: 'kimi-deep',
186+
model: 'kimi-deep-coder',
187+
displayName: 'Kimi Deep',
188+
capabilities: ['thinking', 'always_thinking'],
189+
},
190+
]);
191+
192+
const result = await buildSessionConfigOptions(harness, 'kimi-deep', false, 'default');
193+
194+
const toggle = result.find((o) => o.id === 'thinking');
195+
if (!toggle || toggle.type !== 'select') throw new Error('expected thinking select toggle');
196+
expect(toggle.currentValue).toBe('on');
197+
expect(toggle.options.map((o) => ('value' in o ? o.value : ''))).toEqual(['on']);
198+
});
199+
174200
it('omits the thinking toggle when the current base model id is not in the catalog (defensive)', async () => {
175201
const { harness } = makeHarnessWithModels([
176202
{ id: 'kimi-coder', model: 'kimi-for-coding', displayName: 'Kimi Coder' },

0 commit comments

Comments
 (0)