mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-26 00:54:36 +00:00
fix(kimi-code): persist picked thinking effort up to the model's default effort (#3205)
* fix(kimi-code): persist picked thinking effort up to the model's default effort The persistence gate kept the model's top declared effort session-only unconditionally, so users whose delivered default_effort is the top tier (e.g. max) could never save an explicit pick of it. Compare the pick against the model's default_effort instead, using support_efforts as the strength ordering: picks above the default stay session-only, picks at or below it persist. Models without a declared default keep the historical top-tier rule. The same change lands in the VS Code extension's mirrored logic. * docs(kimi-code): document the effective-default ceiling for effort persistence Clarify in both apps' comments, the changeset, and the config docs that the persistence ceiling is the model's effective default effort, whether declared via the catalog / overrides or synthesized by the protocol profile inference (Claude models resolve to high, so an xhigh pick is session-only there). Pin the inference path with tests in both apps. * fix(vscode): resolve the save-config model with its provider type Mirror the TUI's effectiveModelForHost: without the provider type the Anthropic fallback profile (e.g. claude-latest) never matches, so the inferred default effort that gates persistence was missed and an above-default pick could persist where the TUI keeps it session-only. * Delete .changeset/persist-effort-up-to-model-default.md Signed-off-by: 7Sageer <sag77r@hotmail.com> * fix(vscode): seed the persisted effort with the effective-default ceiling * fix(vscode): project webview models with the provider type * fix(kimi-code): apply a session-only effort pick to the runtime in the /provider flow * fix(vscode): update the persisted-effort seed on model-switch saves * docs(kimi-code): drop the default_effort persistence-ceiling note --------- Signed-off-by: 7Sageer <sag77r@hotmail.com>
This commit is contained in:
parent
4d5147ba5d
commit
8ddcb5ca13
10 changed files with 569 additions and 42 deletions
|
|
@ -597,7 +597,7 @@ async function persistModelSelection(
|
|||
const model = host.state.appState.availableModels[alias];
|
||||
const full = thinkingEffortToConfig(
|
||||
effort,
|
||||
model === undefined ? undefined : effectiveModelForHost(host, model).supportEfforts,
|
||||
model === undefined ? undefined : effectiveModelForHost(host, model),
|
||||
);
|
||||
// Re-confirming the effort shown when the picker opened is not an explicit
|
||||
// choice — persist the model but leave the stored effort preference alone.
|
||||
|
|
|
|||
|
|
@ -307,7 +307,7 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise<void> {
|
|||
host.mountEditorReplacement(selector);
|
||||
}
|
||||
|
||||
async function setDefaultModel(
|
||||
export async function setDefaultModel(
|
||||
host: SlashCommandHost,
|
||||
alias: string,
|
||||
effort: ThinkingEffort,
|
||||
|
|
@ -315,16 +315,23 @@ async function setDefaultModel(
|
|||
// Resolve efforts the same way the /model path does (effectiveModelForHost
|
||||
// applies overrides and the protocol-profile inference): catalog entries for
|
||||
// e.g. Anthropic models declare no support_efforts on the alias, and without
|
||||
// the inference a top-tier pick would slip through as a persisted effort.
|
||||
// the inference an above-default pick would slip through as a persisted effort.
|
||||
const model = host.state.appState.availableModels[alias];
|
||||
const thinking = thinkingEffortToConfig(
|
||||
effort,
|
||||
model === undefined ? undefined : effectiveModelForHost(host, model),
|
||||
);
|
||||
await host.harness.setConfig({
|
||||
defaultModel: alias,
|
||||
thinking: thinkingEffortToConfig(
|
||||
effort,
|
||||
model === undefined ? undefined : effectiveModelForHost(host, model).supportEfforts,
|
||||
),
|
||||
thinking,
|
||||
});
|
||||
await host.authFlow.refreshConfigAfterLogin();
|
||||
// refreshConfigAfterLogin reactivates from the persisted config, so a pick
|
||||
// the gate keeps session-only never reaches the runtime — apply it after
|
||||
// the refresh, or the persisted value would clobber it.
|
||||
if (thinking.effort === undefined && effort !== 'off' && effort !== 'on') {
|
||||
await host.authFlow.activateModelAfterLogin(alias, effort);
|
||||
}
|
||||
host.track('model_switch', { model: alias });
|
||||
host.showStatus(`Default model set to ${alias} with thinking ${effort}.`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ThinkingEffort } from '@moonshot-ai/kimi-code-sdk';
|
||||
import type { ModelAlias, ThinkingEffort } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
/** Whether a thinking effort represents "thinking enabled" (anything but 'off'). */
|
||||
export function isThinkingOn(effort: ThinkingEffort): boolean {
|
||||
|
|
@ -11,24 +11,37 @@ export function isThinkingOn(effort: ThinkingEffort): boolean {
|
|||
* on-signal rather than a declared effort, so it only persists `enabled` —
|
||||
* boolean models resolve back to `'on'` at runtime via
|
||||
* `defaultThinkingEffortFor`. A concrete effort persists as the global
|
||||
* default, EXCEPT the model's highest declared level — the last entry of
|
||||
* `support_efforts` (the list is ordered by strength, the same assumption
|
||||
* the `middleOf` default-effort resolution makes) — which is session-only
|
||||
* and records just `enabled`, so the most expensive tier never becomes the
|
||||
* global default for every new session. When the model's levels are unknown
|
||||
* the concrete effort is persisted as-is.
|
||||
* default, EXCEPT when it ranks above the model's effective default
|
||||
* effort: `support_efforts` is ordered by strength (the same assumption
|
||||
* the `middleOf` default-effort resolution makes), and a pick more
|
||||
* expensive than the default stays session-only and records just
|
||||
* `enabled`, so it never becomes the global default for every new
|
||||
* session. The default here is the effective model's, however it arose —
|
||||
* declared via the catalog or `[models.*.overrides]`, or synthesized by
|
||||
* the protocol-profile inference (`withAnthropicProfile` resolves Claude
|
||||
* models to 'high', so an 'xhigh' pick stays session-only there). When
|
||||
* the effective model carries no default effort at all, its highest
|
||||
* declared level stays session-only (the historical rule). Undeclared
|
||||
* values persist as-is — the configured provider validates them.
|
||||
*/
|
||||
export function thinkingEffortToConfig(
|
||||
effort: ThinkingEffort,
|
||||
supportEfforts?: readonly string[],
|
||||
model?: Pick<ModelAlias, 'supportEfforts' | 'defaultEffort'>,
|
||||
): {
|
||||
enabled: boolean;
|
||||
effort?: string;
|
||||
} {
|
||||
if (effort === 'off') return { enabled: false };
|
||||
if (effort === 'on') return { enabled: true };
|
||||
const top = supportEfforts?.at(-1);
|
||||
if (top !== undefined && effort === top) return { enabled: true };
|
||||
const efforts = model?.supportEfforts;
|
||||
if (efforts !== undefined && efforts.includes(effort)) {
|
||||
const declared = model?.defaultEffort;
|
||||
const ceiling =
|
||||
declared !== undefined && efforts.includes(declared)
|
||||
? efforts.indexOf(declared)
|
||||
: efforts.length - 2;
|
||||
if (efforts.indexOf(effort) > ceiling) return { enabled: true };
|
||||
}
|
||||
return { enabled: true, effort };
|
||||
}
|
||||
|
||||
|
|
|
|||
93
apps/kimi-code/test/tui/commands/provider.test.ts
Normal file
93
apps/kimi-code/test/tui/commands/provider.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* Scenario: /provider post-add default-model selection.
|
||||
* Responsibilities: the picked effort is gated for persistence by the model's
|
||||
* effective default, and a session-only pick is still applied to the runtime
|
||||
* after the config refresh (which only reactivates from persisted values).
|
||||
* Wiring: real setDefaultModel with the harness/authFlow boundaries stubbed by
|
||||
* a small host rig.
|
||||
* Run: pnpm -C apps/kimi-code exec vitest run test/tui/commands/provider.test.ts
|
||||
*/
|
||||
import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { SlashCommandHost } from '#/tui/commands';
|
||||
import { setDefaultModel } from '#/tui/commands/provider';
|
||||
|
||||
function makeHost() {
|
||||
const appState = {
|
||||
availableModels: {
|
||||
// Declares no efforts; the Anthropic profile inference supplies
|
||||
// [low, medium, high, xhigh, max] with the default resolved to 'high'.
|
||||
opus: {
|
||||
provider: 'compatible',
|
||||
model: 'claude-opus-4-7',
|
||||
maxContextSize: 200_000,
|
||||
} as unknown as ModelAlias,
|
||||
},
|
||||
availableProviders: {
|
||||
compatible: { type: 'anthropic' },
|
||||
},
|
||||
};
|
||||
const host = {
|
||||
state: { appState },
|
||||
harness: {
|
||||
setConfig: vi.fn(async () => ({})),
|
||||
},
|
||||
authFlow: {
|
||||
refreshConfigAfterLogin: vi.fn(async () => {}),
|
||||
activateModelAfterLogin: vi.fn(async () => {}),
|
||||
},
|
||||
track: vi.fn(),
|
||||
showStatus: vi.fn(),
|
||||
} as unknown as SlashCommandHost & {
|
||||
harness: { setConfig: ReturnType<typeof vi.fn> };
|
||||
authFlow: {
|
||||
refreshConfigAfterLogin: ReturnType<typeof vi.fn>;
|
||||
activateModelAfterLogin: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
return { host };
|
||||
}
|
||||
|
||||
describe('setDefaultModel', () => {
|
||||
it('applies an above-default pick to the runtime when the gate keeps it session-only', async () => {
|
||||
const { host } = makeHost();
|
||||
|
||||
await setDefaultModel(host, 'opus', 'xhigh');
|
||||
|
||||
expect(host.harness.setConfig).toHaveBeenCalledWith({
|
||||
defaultModel: 'opus',
|
||||
thinking: { enabled: true },
|
||||
});
|
||||
expect(host.authFlow.activateModelAfterLogin).toHaveBeenCalledWith('opus', 'xhigh');
|
||||
// The application must come after the refresh, or the persisted value
|
||||
// reactivated by refreshConfigAfterLogin would clobber the pick.
|
||||
expect(
|
||||
host.authFlow.activateModelAfterLogin.mock.invocationCallOrder[0]!,
|
||||
).toBeGreaterThan(host.authFlow.refreshConfigAfterLogin.mock.invocationCallOrder[0]!);
|
||||
});
|
||||
|
||||
it('does not re-apply the effort when the pick persists', async () => {
|
||||
const { host } = makeHost();
|
||||
|
||||
await setDefaultModel(host, 'opus', 'high');
|
||||
|
||||
expect(host.harness.setConfig).toHaveBeenCalledWith({
|
||||
defaultModel: 'opus',
|
||||
thinking: { enabled: true, effort: 'high' },
|
||||
});
|
||||
expect(host.authFlow.activateModelAfterLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not re-apply a boolean on pick', async () => {
|
||||
const { host } = makeHost();
|
||||
|
||||
await setDefaultModel(host, 'opus', 'on');
|
||||
|
||||
expect(host.harness.setConfig).toHaveBeenCalledWith({
|
||||
defaultModel: 'opus',
|
||||
thinking: { enabled: true },
|
||||
});
|
||||
expect(host.authFlow.activateModelAfterLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -7720,6 +7720,110 @@ command = "vim"
|
|||
});
|
||||
});
|
||||
|
||||
it('persists max when the model default effort is max', async () => {
|
||||
let switched = false;
|
||||
const session = makeSession({
|
||||
getStatus: vi.fn(async () => ({
|
||||
model: 'k2',
|
||||
thinkingEffort: switched ? 'max' : 'high',
|
||||
permission: 'manual',
|
||||
planMode: false,
|
||||
contextTokens: 0,
|
||||
maxContextTokens: 100,
|
||||
contextUsage: 0,
|
||||
})),
|
||||
setThinking: vi.fn(async () => {
|
||||
switched = true;
|
||||
}),
|
||||
});
|
||||
const setConfig = vi.fn(async () => ({ providers: {} }));
|
||||
const { driver } = await makeDriver(session, {
|
||||
getConfig: vi.fn(async () => ({
|
||||
models: {
|
||||
k2: {
|
||||
provider: 'managed:kimi-code',
|
||||
model: 'kimi-k2',
|
||||
maxContextSize: 100,
|
||||
displayName: 'Kimi K2',
|
||||
capabilities: ['thinking'],
|
||||
supportEfforts: ['low', 'high', 'max'],
|
||||
defaultEffort: 'max',
|
||||
},
|
||||
},
|
||||
defaultModel: 'k2',
|
||||
// A previously stored effort keeps the runtime below the delivered
|
||||
// max default, so picking max is an explicit change.
|
||||
thinking: { enabled: true, effort: 'high' },
|
||||
})),
|
||||
setConfig,
|
||||
});
|
||||
|
||||
driver.handleUserInput('/effort max');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session.setThinking).toHaveBeenCalledWith('max');
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(setConfig).toHaveBeenCalledWith({
|
||||
defaultModel: 'k2',
|
||||
thinking: { enabled: true, effort: 'max' },
|
||||
});
|
||||
});
|
||||
expect(driver.state.appState.thinkingEffort).toBe('max');
|
||||
});
|
||||
|
||||
it('keeps an xhigh pick session-only for a Claude model via the profile inference', async () => {
|
||||
// claude-opus-4-7 declares no efforts; the Anthropic profile inference
|
||||
// supplies [low, medium, high, xhigh, max] and resolves the default to
|
||||
// 'high', so an xhigh pick ranks above the persistence ceiling.
|
||||
let switched = false;
|
||||
const session = makeSession({
|
||||
getStatus: vi.fn(async () => ({
|
||||
model: 'opus',
|
||||
thinkingEffort: switched ? 'xhigh' : 'high',
|
||||
permission: 'manual',
|
||||
planMode: false,
|
||||
contextTokens: 0,
|
||||
maxContextTokens: 100,
|
||||
contextUsage: 0,
|
||||
})),
|
||||
setThinking: vi.fn(async () => {
|
||||
switched = true;
|
||||
}),
|
||||
});
|
||||
const setConfig = vi.fn(async () => ({ providers: {} }));
|
||||
const { driver } = await makeDriver(session, {
|
||||
getConfig: vi.fn(async () => ({
|
||||
providers: {
|
||||
compatible: { type: 'anthropic', apiKey: 'test-key' },
|
||||
},
|
||||
models: {
|
||||
opus: {
|
||||
provider: 'compatible',
|
||||
model: 'claude-opus-4-7',
|
||||
maxContextSize: 100,
|
||||
},
|
||||
},
|
||||
defaultModel: 'opus',
|
||||
thinking: { enabled: true, effort: 'high' },
|
||||
})),
|
||||
setConfig,
|
||||
});
|
||||
|
||||
driver.handleUserInput('/effort xhigh');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session.setThinking).toHaveBeenCalledWith('xhigh');
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(setConfig).toHaveBeenCalledWith({
|
||||
defaultModel: 'opus',
|
||||
thinking: { enabled: true },
|
||||
});
|
||||
});
|
||||
expect(driver.state.appState.thinkingEffort).toBe('xhigh');
|
||||
});
|
||||
|
||||
it('refreshes only OAuth provider models before opening /model picker', async () => {
|
||||
const { driver } = await makeDriver(makeSession(), {
|
||||
getConfig: vi.fn(async () => ({
|
||||
|
|
|
|||
|
|
@ -21,20 +21,85 @@ describe('thinkingEffortToConfig', () => {
|
|||
});
|
||||
|
||||
it.each([
|
||||
// The model's highest declared level (last support_efforts entry) is
|
||||
// With no declared default effort, the historical rule applies: the
|
||||
// model's highest declared level (last support_efforts entry) is
|
||||
// session-only; anything below it persists as the global default.
|
||||
['low', { enabled: true, effort: 'low' }],
|
||||
['high', { enabled: true, effort: 'high' }],
|
||||
['max', { enabled: true }],
|
||||
// Undeclared values persist as-is (the provider validates them).
|
||||
['ultra', { enabled: true, effort: 'ultra' }],
|
||||
] as const)('maps %s → %o for [low, high, max]', (effort, expected) => {
|
||||
expect(thinkingEffortToConfig(effort, ['low', 'high', 'max'])).toEqual(expected);
|
||||
] as const)('maps %s → %o for [low, high, max] without a default', (effort, expected) => {
|
||||
expect(thinkingEffortToConfig(effort, { supportEfforts: ['low', 'high', 'max'] })).toEqual(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a single declared level as the top tier', () => {
|
||||
expect(thinkingEffortToConfig('max', ['max'])).toEqual({ enabled: true });
|
||||
expect(thinkingEffortToConfig('max', { supportEfforts: ['max'] })).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['low', { enabled: true, effort: 'low' }],
|
||||
['high', { enabled: true, effort: 'high' }],
|
||||
// Above the delivered default: session-only.
|
||||
['max', { enabled: true }],
|
||||
] as const)('maps %s → %o for [low, high, max] with default high', (effort, expected) => {
|
||||
expect(
|
||||
thinkingEffortToConfig(effort, {
|
||||
supportEfforts: ['low', 'high', 'max'],
|
||||
defaultEffort: 'high',
|
||||
}),
|
||||
).toEqual(expected);
|
||||
});
|
||||
|
||||
it('persists the top tier when the delivered default is the top tier', () => {
|
||||
expect(
|
||||
thinkingEffortToConfig('max', {
|
||||
supportEfforts: ['low', 'high', 'max'],
|
||||
defaultEffort: 'max',
|
||||
}),
|
||||
).toEqual({ enabled: true, effort: 'max' });
|
||||
});
|
||||
|
||||
it('keeps a non-top pick above the delivered default session-only', () => {
|
||||
expect(
|
||||
thinkingEffortToConfig('high', {
|
||||
supportEfforts: ['low', 'high', 'max'],
|
||||
defaultEffort: 'low',
|
||||
}),
|
||||
).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('falls back to the top-tier rule when the declared default is not a listed level', () => {
|
||||
expect(
|
||||
thinkingEffortToConfig('max', {
|
||||
supportEfforts: ['low', 'high', 'max'],
|
||||
defaultEffort: 'ultra',
|
||||
}),
|
||||
).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['low', { enabled: true, effort: 'low' }],
|
||||
['medium', { enabled: true, effort: 'medium' }],
|
||||
['high', { enabled: true, effort: 'high' }],
|
||||
// Above the effective default: session-only.
|
||||
['xhigh', { enabled: true }],
|
||||
['max', { enabled: true }],
|
||||
] as const)(
|
||||
// The shape the Anthropic profile inference hands the gate for the
|
||||
// latest Claude models: five tiers with the default resolved to 'high'.
|
||||
'maps %s → %o for [low, medium, high, xhigh, max] with default high',
|
||||
(effort, expected) => {
|
||||
expect(
|
||||
thinkingEffortToConfig(effort, {
|
||||
supportEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
defaultEffort: 'high',
|
||||
}),
|
||||
).toEqual(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('isThinkingOn', () => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
effectiveModelAlias,
|
||||
type KimiConfig as SdkKimiConfig,
|
||||
type ModelAlias,
|
||||
type ProviderType,
|
||||
type ThinkingEffort,
|
||||
} from "@moonshot-ai/kimi-code-sdk";
|
||||
|
||||
|
|
@ -41,9 +42,14 @@ const saveConfig: Handler<SessionConfig, { ok: boolean }> = async (params, ctx)
|
|||
const effortChanged = params.effortChanged !== false;
|
||||
const config = await ctx.harness.getConfig({ reload: true });
|
||||
const model = config.models?.[params.model];
|
||||
// Resolve with the provider type the way the TUI's effectiveModelForHost
|
||||
// does: without it the Anthropic fallback profile (e.g. `claude-latest`)
|
||||
// never matches, so the inferred default that gates persistence is missed.
|
||||
const providerType =
|
||||
model === undefined ? undefined : (config.providers?.[model.provider]?.type ?? model.protocol);
|
||||
const full = thinkingConfig(
|
||||
effort,
|
||||
model === undefined ? undefined : effectiveModelAlias(model).supportEfforts,
|
||||
model === undefined ? undefined : effectiveModelAlias(model, providerType),
|
||||
);
|
||||
// Re-confirming the effort already shown is not an explicit choice —
|
||||
// persist the model but leave the stored effort preference alone (the TUI's
|
||||
|
|
@ -126,7 +132,12 @@ export const configHandlers = {
|
|||
|
||||
export function toWebviewConfig(config: SdkKimiConfig): WebviewKimiConfig {
|
||||
const models: ModelConfig[] = Object.entries(config.models ?? {})
|
||||
.map(([id, model]) => toWebviewModel(id, model))
|
||||
// Resolve with the provider type the way saveConfig does: without it the
|
||||
// Anthropic fallback profile never matches, and the webview's effort
|
||||
// persistence seed would gate on a different effective model.
|
||||
.map(([id, model]) =>
|
||||
toWebviewModel(id, model, config.providers?.[model.provider]?.type ?? model.protocol),
|
||||
)
|
||||
.toSorted((left, right) => left.name.localeCompare(right.name));
|
||||
return {
|
||||
defaultModel: config.defaultModel ?? models[0]?.id ?? null,
|
||||
|
|
@ -136,8 +147,8 @@ export function toWebviewConfig(config: SdkKimiConfig): WebviewKimiConfig {
|
|||
};
|
||||
}
|
||||
|
||||
function toWebviewModel(id: string, model: ModelAlias): ModelConfig {
|
||||
const effective = effectiveModelAlias(model);
|
||||
function toWebviewModel(id: string, model: ModelAlias, providerType?: ProviderType): ModelConfig {
|
||||
const effective = effectiveModelAlias(model, providerType);
|
||||
return {
|
||||
id,
|
||||
name: effective.displayName ?? effective.model ?? id,
|
||||
|
|
@ -159,20 +170,33 @@ function sessionConfigEffort(config: SessionConfig): ThinkingEffort {
|
|||
* Project a thinking effort to the `[thinking]` config patch persisted to
|
||||
* config.toml — mirrors the TUI's thinkingEffortToConfig. "off" disables
|
||||
* thinking; "on" is the boolean-model on-signal, so it only persists
|
||||
* `enabled`. A concrete effort persists as the global default, EXCEPT the
|
||||
* model's highest declared level — the last entry of `support_efforts` —
|
||||
* which is session-only and records just `enabled`, so the most expensive
|
||||
* tier never becomes the global default for every new session. When the
|
||||
* model's levels are unknown the concrete effort is persisted as-is.
|
||||
* `enabled`. A concrete effort persists as the global default, EXCEPT when it
|
||||
* ranks above the model's effective default effort: `support_efforts` is
|
||||
* ordered by strength, and a pick more expensive than the default stays
|
||||
* session-only and records just `enabled`, so it never becomes the global
|
||||
* default for every new session. The default here is the effective model's,
|
||||
* however it arose — declared via the catalog or overrides, or synthesized
|
||||
* by the protocol-profile inference (`withAnthropicProfile` resolves Claude
|
||||
* models to "high", so an "xhigh" pick stays session-only there). When the
|
||||
* effective model carries no default effort at all, its highest declared
|
||||
* level stays session-only (the historical rule). When the model's levels
|
||||
* are unknown the concrete effort is persisted as-is.
|
||||
*/
|
||||
function thinkingConfig(
|
||||
effort: ThinkingEffort,
|
||||
supportEfforts?: readonly string[],
|
||||
model?: Pick<ModelAlias, "supportEfforts" | "defaultEffort">,
|
||||
): { enabled: boolean; effort?: string } {
|
||||
if (effort === "off") return { enabled: false };
|
||||
if (effort === "on") return { enabled: true };
|
||||
const top = supportEfforts?.at(-1);
|
||||
if (top !== undefined && effort === top) return { enabled: true };
|
||||
const efforts = model?.supportEfforts;
|
||||
if (efforts !== undefined && efforts.includes(effort)) {
|
||||
const declared = model?.defaultEffort;
|
||||
const ceiling =
|
||||
declared !== undefined && efforts.includes(declared)
|
||||
? efforts.indexOf(declared)
|
||||
: efforts.length - 2;
|
||||
if (efforts.indexOf(effort) > ceiling) return { enabled: true };
|
||||
}
|
||||
return { enabled: true, effort };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -293,6 +293,36 @@ describe("Webview RPC boundary (validates requests before host dispatch)", () =>
|
|||
});
|
||||
});
|
||||
|
||||
it("resolves the fallback-profile default effort with the provider type", async () => {
|
||||
// claude-latest declares efforts but no default; the Anthropic fallback
|
||||
// profile only matches when the provider type joins the resolution.
|
||||
host.harness.getConfig.mockResolvedValueOnce({
|
||||
defaultModel: "custom/claude",
|
||||
providers: {
|
||||
custom: { type: "anthropic", apiKey: "test-key" },
|
||||
},
|
||||
models: {
|
||||
"custom/claude": {
|
||||
provider: "custom",
|
||||
model: "claude-latest",
|
||||
supportEfforts: ["low", "medium", "high", "xhigh", "max"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await bridge.handle({ id: "rpc-models", method: Methods.GetModels }, "view-1");
|
||||
|
||||
expect(result).toMatchObject({
|
||||
result: {
|
||||
models: [{
|
||||
id: "custom/claude",
|
||||
support_efforts: ["low", "medium", "high", "xhigh", "max"],
|
||||
default_effort: "high",
|
||||
}],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not expose the session storage path when listing sessions", async () => {
|
||||
host.harness.listSessions.mockResolvedValueOnce([
|
||||
{
|
||||
|
|
@ -502,7 +532,7 @@ describe("Webview config saves (thinking effort persistence parity with the TUI)
|
|||
});
|
||||
});
|
||||
|
||||
it("keeps the model's top declared tier session-only", async () => {
|
||||
it("keeps a pick above the model's delivered default session-only", async () => {
|
||||
mockConfig();
|
||||
|
||||
await bridge.handle(
|
||||
|
|
@ -516,6 +546,42 @@ describe("Webview config saves (thinking effort persistence parity with the TUI)
|
|||
});
|
||||
});
|
||||
|
||||
it("persists the top tier when the model's delivered default is the top tier", async () => {
|
||||
host.harness.getConfig.mockResolvedValue({
|
||||
defaultModel: "kimi/reasoning",
|
||||
models: { "kimi/reasoning": { ...effortModel, defaultEffort: "max" } },
|
||||
} as never);
|
||||
|
||||
await bridge.handle(
|
||||
{ id: "rpc-1", method: Methods.SaveConfig, params: { model: "kimi/reasoning", thinking: true, effort: "max" } },
|
||||
"view-1",
|
||||
);
|
||||
|
||||
expect(host.harness.setConfig).toHaveBeenCalledWith({
|
||||
defaultModel: "kimi/reasoning",
|
||||
thinking: { enabled: true, effort: "max" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an xhigh pick session-only when the default comes from the Anthropic profile inference", async () => {
|
||||
// claude-opus-4-7 declares no efforts; the profile inference supplies
|
||||
// [low, medium, high, xhigh, max] and resolves the default to "high".
|
||||
host.harness.getConfig.mockResolvedValue({
|
||||
defaultModel: "custom/claude",
|
||||
models: { "custom/claude": { provider: "custom", model: "claude-opus-4-7" } },
|
||||
} as never);
|
||||
|
||||
await bridge.handle(
|
||||
{ id: "rpc-1", method: Methods.SaveConfig, params: { model: "custom/claude", thinking: true, effort: "xhigh" } },
|
||||
"view-1",
|
||||
);
|
||||
|
||||
expect(host.harness.setConfig).toHaveBeenCalledWith({
|
||||
defaultModel: "custom/claude",
|
||||
thinking: { enabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("persists the concrete effort when the model's levels are unknown", async () => {
|
||||
host.harness.getConfig.mockResolvedValue({ defaultModel: "other/model", models: {} });
|
||||
|
||||
|
|
|
|||
|
|
@ -384,7 +384,7 @@ describe("Webview thinking effort parity with the TUI", () => {
|
|||
expect(boundary.saveConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not seed future sessions with the model's top declared tier", () => {
|
||||
it("seeds the top tier when it is the model's delivered default", () => {
|
||||
boundary.saveConfig.mockResolvedValue({ ok: true });
|
||||
useSettingsStore.getState().initModels(MODELS, "reasoning", false);
|
||||
|
||||
|
|
@ -392,6 +392,128 @@ describe("Webview thinking effort parity with the TUI", () => {
|
|||
|
||||
expect(useSettingsStore.getState().thinkingEffort).toBe("high");
|
||||
expect(boundary.saveConfig).toHaveBeenCalledWith({ model: "reasoning", thinking: true, effort: "high" });
|
||||
expect(useSettingsStore.getState().defaultThinkingEffort).toBe("high");
|
||||
});
|
||||
|
||||
it("does not seed a pick above the model's delivered default", () => {
|
||||
boundary.saveConfig.mockResolvedValue({ ok: true });
|
||||
useSettingsStore.getState().initModels([
|
||||
{
|
||||
id: "reasoning",
|
||||
name: "Reasoning",
|
||||
provider: "managed:kimi-code",
|
||||
capabilities: ["thinking"],
|
||||
support_efforts: ["low", "high", "max"],
|
||||
default_effort: "low",
|
||||
},
|
||||
], "reasoning", false);
|
||||
|
||||
useSettingsStore.getState().selectThinkingEffort("high");
|
||||
|
||||
expect(useSettingsStore.getState().thinkingEffort).toBe("high");
|
||||
expect(boundary.saveConfig).toHaveBeenCalledWith({ model: "reasoning", thinking: true, effort: "high" });
|
||||
expect(useSettingsStore.getState().defaultThinkingEffort).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not seed the top tier when the model declares no default", () => {
|
||||
boundary.saveConfig.mockResolvedValue({ ok: true });
|
||||
useSettingsStore.getState().initModels([
|
||||
{
|
||||
id: "reasoning",
|
||||
name: "Reasoning",
|
||||
provider: "managed:kimi-code",
|
||||
capabilities: ["thinking"],
|
||||
support_efforts: ["low", "high"],
|
||||
},
|
||||
], "reasoning", false);
|
||||
|
||||
useSettingsStore.getState().selectThinkingEffort("high");
|
||||
|
||||
expect(useSettingsStore.getState().thinkingEffort).toBe("high");
|
||||
expect(boundary.saveConfig).toHaveBeenCalledWith({ model: "reasoning", thinking: true, effort: "high" });
|
||||
expect(useSettingsStore.getState().defaultThinkingEffort).toBeUndefined();
|
||||
});
|
||||
|
||||
const SWITCH_MODELS = [
|
||||
{
|
||||
id: "seeded",
|
||||
name: "Seeded",
|
||||
provider: "managed:kimi-code",
|
||||
capabilities: ["thinking"],
|
||||
support_efforts: ["low", "medium"],
|
||||
default_effort: "medium",
|
||||
},
|
||||
{
|
||||
id: "max-default",
|
||||
name: "Max Default",
|
||||
provider: "managed:kimi-code",
|
||||
capabilities: ["thinking"],
|
||||
support_efforts: ["low", "max"],
|
||||
default_effort: "max",
|
||||
},
|
||||
];
|
||||
|
||||
it("updates the seed when a model switch persists the derived effort", () => {
|
||||
boundary.saveConfig.mockResolvedValue({ ok: true });
|
||||
useSettingsStore.getState().initModels(SWITCH_MODELS, "seeded", true, "medium");
|
||||
|
||||
// "medium" is unsupported here, so the switch derives the model default
|
||||
// "max"; with the delivered default at the top tier the host persists it.
|
||||
useSettingsStore.getState().updateModel("max-default");
|
||||
|
||||
expect(useSettingsStore.getState().thinkingEffort).toBe("max");
|
||||
expect(boundary.saveConfig).toHaveBeenCalledWith({
|
||||
model: "max-default",
|
||||
thinking: true,
|
||||
effort: "max",
|
||||
effortChanged: true,
|
||||
});
|
||||
expect(useSettingsStore.getState().defaultThinkingEffort).toBe("max");
|
||||
});
|
||||
|
||||
it("rolls the seed back when the model-switch save fails", async () => {
|
||||
let rejectSave!: (error: Error) => void;
|
||||
boundary.saveConfig.mockReturnValue(new Promise((_resolve, reject) => {
|
||||
rejectSave = reject;
|
||||
}));
|
||||
useSettingsStore.getState().initModels(SWITCH_MODELS, "seeded", true, "medium");
|
||||
|
||||
useSettingsStore.getState().updateModel("max-default");
|
||||
expect(useSettingsStore.getState().defaultThinkingEffort).toBe("max");
|
||||
|
||||
rejectSave(new Error("config.toml is read-only"));
|
||||
await vi.waitFor(() => {
|
||||
expect(useSettingsStore.getState().defaultThinkingEffort).toBe("medium");
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the seed alone when the switch re-confirms the active effort", () => {
|
||||
boundary.saveConfig.mockResolvedValue({ ok: true });
|
||||
// No persisted effort: the seed starts undefined and the session derives
|
||||
// "max" from the model default.
|
||||
useSettingsStore.getState().initModels([
|
||||
...SWITCH_MODELS,
|
||||
{
|
||||
id: "max-default-b",
|
||||
name: "Max Default B",
|
||||
provider: "managed:kimi-code",
|
||||
capabilities: ["thinking"],
|
||||
support_efforts: ["low", "max"],
|
||||
default_effort: "max",
|
||||
},
|
||||
], "max-default", true);
|
||||
|
||||
// The derived effort equals the active one, so the host leaves the stored
|
||||
// preference untouched — the seed must not invent one either.
|
||||
useSettingsStore.getState().updateModel("max-default-b");
|
||||
|
||||
expect(useSettingsStore.getState().thinkingEffort).toBe("max");
|
||||
expect(boundary.saveConfig).toHaveBeenCalledWith({
|
||||
model: "max-default-b",
|
||||
thinking: true,
|
||||
effort: "max",
|
||||
effortChanged: false,
|
||||
});
|
||||
expect(useSettingsStore.getState().defaultThinkingEffort).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -102,6 +102,23 @@ function defaultEffortForModel(model: ModelConfig, defaultThinking: boolean, con
|
|||
return defaultThinking ? "on" : "off";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether picking `effort` persists it as the global default — mirrors the
|
||||
* extension host's thinkingConfig gate: a pick above the model's effective
|
||||
* default effort stays session-only, with the ceiling falling back to the
|
||||
* tier below the top when the model carries no listed default. Only listed
|
||||
* efforts reach this helper (selectThinkingEffort rejects the rest).
|
||||
*/
|
||||
function persistsAsDefaultEffort(model: ModelConfig, effort: string): boolean {
|
||||
const efforts = model.support_efforts ?? [];
|
||||
const declared = model.default_effort;
|
||||
const ceiling =
|
||||
declared !== undefined && efforts.includes(declared)
|
||||
? efforts.indexOf(declared)
|
||||
: efforts.length - 2;
|
||||
return efforts.indexOf(effort) <= ceiling;
|
||||
}
|
||||
|
||||
export function isImageModel(model: ModelConfig): boolean {
|
||||
return model.capabilities.includes("image_in");
|
||||
}
|
||||
|
|
@ -203,15 +220,29 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
|||
}
|
||||
|
||||
const thinkingEffort = defaultEffortForModel(model, defaultThinking, defaultThinkingEffort);
|
||||
set({ currentModel: modelId, thinkingEffort });
|
||||
const effortChanged = thinkingEffort !== previousEffort;
|
||||
set({
|
||||
currentModel: modelId,
|
||||
thinkingEffort,
|
||||
// The save below persists the derived effort when it changed and
|
||||
// clears the gate — keep the seed in sync, or the next switch derives
|
||||
// from a stale value and saves it back over the persisted one.
|
||||
defaultThinkingEffort:
|
||||
effortChanged &&
|
||||
thinkingEffort !== "off" &&
|
||||
thinkingEffort !== "on" &&
|
||||
persistsAsDefaultEffort(model, thinkingEffort)
|
||||
? thinkingEffort
|
||||
: defaultThinkingEffort,
|
||||
});
|
||||
saveConfigWithRollback(
|
||||
{
|
||||
model: modelId,
|
||||
thinking: thinkingEffort !== "off",
|
||||
effort: thinkingEffort,
|
||||
effortChanged: thinkingEffort !== previousEffort,
|
||||
effortChanged,
|
||||
},
|
||||
{ currentModel, thinkingEffort: previousEffort },
|
||||
{ currentModel, thinkingEffort: previousEffort, defaultThinkingEffort },
|
||||
set,
|
||||
);
|
||||
},
|
||||
|
|
@ -258,11 +289,13 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
|||
set({
|
||||
thinkingEffort,
|
||||
defaultThinking: thinkingEffort !== "off",
|
||||
// The model's top declared tier is session-only (only the boolean
|
||||
// toggle is persisted), so it must not become the configured-effort
|
||||
// seed for future sessions.
|
||||
// A pick above the model's effective default effort is session-only
|
||||
// (only the boolean toggle is persisted), so it must not become the
|
||||
// configured-effort seed for future sessions.
|
||||
defaultThinkingEffort:
|
||||
thinkingEffort !== "off" && thinkingEffort !== "on" && thinkingEffort !== allowed.at(-1)
|
||||
thinkingEffort !== "off" &&
|
||||
thinkingEffort !== "on" &&
|
||||
persistsAsDefaultEffort(model, thinkingEffort)
|
||||
? thinkingEffort
|
||||
: defaultThinkingEffort,
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue