diff --git a/.changeset/dual-model-routing.md b/.changeset/dual-model-routing.md new file mode 100644 index 0000000000..ff5a1c1e41 --- /dev/null +++ b/.changeset/dual-model-routing.md @@ -0,0 +1,10 @@ +--- +"@moonshot-ai/kimi-code": minor +"@moonshot-ai/kimi-code-sdk": minor +--- + +Add the `dual-model-routing` experimental feature: route the main agent and its subagents to different models. + +When the feature is enabled (via `/experiments`, `KIMI_CODE_EXPERIMENTAL_DUAL_MODEL_ROUTING`, or the master `KIMI_CODE_EXPERIMENTAL_FLAG` switch), subagents use a dedicated subagent model instead of inheriting the main agent's model. The subagent model defaults to the new `default_subagent_model` config field and can be switched live via `/model`, which now opens a scope picker (Main agent / Subagents) when the feature is active. The footer status bar shows both the main and subagent models while the feature is on. Subagent thinking effort is also separable: it defaults to the new `default_subagent_thinking_effort` config field and can be switched live via the same `/model` scope picker (the chosen effort is persisted alongside the model). Note that the dedicated model and effort apply to delegated subagents only — the side-question (`/btw`) agent always inherits the main agent's model and effort, because it replays the main agent's prompt prefix and shares its prefix cache, so routing it elsewhere would re-process the whole conversation on a cold cache. + +When the feature is disabled, all UI and runtime behavior is unchanged — subagents inherit the parent model as before, the footer shows only the main model, and `/model` keeps its singular behavior. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 58f1c27acb..8b3df3a970 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -14,6 +14,7 @@ import { type ExperimentalFeatureDraftChange, } from '../components/dialogs/experiments-selector'; import { modelDisplayName, segmentsFor } from '../components/dialogs/model-selector'; +import { ModelScopeSelectorComponent, type ModelScope } from '../components/dialogs/model-scope-selector'; import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector'; import { PermissionSelectorComponent } from '../components/dialogs/permission-selector'; import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector'; @@ -26,7 +27,7 @@ import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; import { formatErrorMessage } from '../utils/event-payload'; import { thinkingEffortToConfig } from '../utils/thinking-config'; import { showUsage } from './info'; -import { setExperimentalFeatures } from './experimental-flags'; +import { isExperimentalFlagEnabled, setExperimentalFeatures } from './experimental-flags'; import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- @@ -239,6 +240,12 @@ export async function handleThemeCommand(host: SlashCommandHost, args: string): export async function handleModelCommand(host: SlashCommandHost, args: string): Promise { const alias = args.trim(); await refreshModelsForPicker(host); + // When dual-model routing is active and no explicit alias is given, let the + // user choose which scope (main agent / subagents) to configure first. + if (alias.length === 0 && isExperimentalFlagEnabled('dual-model-routing')) { + showModelScopePicker(host); + return; + } if (alias.length === 0) { showModelPicker(host); return; @@ -314,6 +321,155 @@ function showEffortPicker( // Pickers & config apply // --------------------------------------------------------------------------- +/** + * Show the model-scope picker (Main agent / Subagents) used when the + * `dual-model-routing` experimental feature is active. Selecting a scope + * opens the matching model picker. + */ +function showModelScopePicker(host: SlashCommandHost): void { + host.mountEditorReplacement( + new ModelScopeSelectorComponent({ + currentModel: host.state.appState.model, + currentSubagentModel: host.state.appState.subagentModel, + currentSubagentThinkingEffort: host.state.appState.subagentThinkingEffort, + availableModels: host.state.appState.availableModels, + onSelect: (scope: ModelScope) => { + host.restoreEditor(); + if (scope === 'subagent') { + showSubagentModelPicker(host); + } else { + showModelPicker(host); + } + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +/** + * Show the model picker for the subagent scope (`dual-model-routing` + * experimental feature). Reuses the tabbed model selector; the selected + * alias and thinking effort are applied via `performSubagentModelSwitch`. + */ +function showSubagentModelPicker(host: SlashCommandHost): void { + const models = Object.fromEntries( + Object.entries(host.state.appState.availableModels).map(([alias, model]) => [ + alias, + effectiveModelForHost(host, model), + ]), + ); + const entries = Object.entries(models); + if (entries.length === 0) { + host.showNotice( + 'No models configured', + 'Run /login to sign in to Kimi, or /provider to add another provider from a model catalog.', + ); + return; + } + host.mountEditorReplacement( + new TabbedModelSelectorComponent({ + models, + currentValue: host.state.appState.subagentModel ?? host.state.appState.model, + currentThinkingEffort: host.state.appState.subagentThinkingEffort ?? host.state.appState.thinkingEffort, + // A subagent-model switch does not invalidate the MAIN conversation's + // prompt cache (only caches of subagents spawned after the change), so + // the standard cache warning does not apply here. + warning: undefined, + onSelect: ({ alias, thinking }) => { + host.restoreEditor(); + void performSubagentModelSwitch(host, alias, thinking, true); + }, + onSessionOnlySelect: ({ alias, thinking }) => { + host.restoreEditor(); + void performSubagentModelSwitch(host, alias, thinking, false); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +/** + * Apply a subagent model selection. Pushes the live session override (both + * model and thinking effort) and, when persisting, writes `defaultSubagentModel` + * and `defaultSubagentThinkingEffort` to config.toml. + */ +async function performSubagentModelSwitch( + host: SlashCommandHost, + alias: string, + effort: string, + persist: boolean, +): Promise { + if (host.state.appState.streamingPhase !== 'idle') { + host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.'); + return; + } + + // No-op guard: skip the RPC round-trips and status flash when neither the + // alias nor the effort actually changed. + if ( + alias === host.state.appState.subagentModel && + effort === host.state.appState.subagentThinkingEffort + ) { + const displayName = modelDisplayName(alias, host.state.appState.availableModels[alias]); + const effortSuffix = effort.length > 0 ? ` (effort: ${effort})` : ''; + host.showStatus(`Already using ${displayName}${effortSuffix} for subagents.`, 'success'); + return; + } + + const displayName = modelDisplayName(alias, host.state.appState.availableModels[alias]); + const session = host.session; + try { + if (session !== undefined) { + await session.setSubagentModel(alias); + await session.setSubagentThinkingEffort(effort); + } + } catch (error) { + host.showError(`Failed to switch subagent model: ${formatErrorMessage(error)}`); + return; + } + + host.setAppState({ subagentModel: alias, subagentThinkingEffort: effort }); + + let persisted = false; + if (persist) { + try { + persisted = await persistSubagentModelSelection(host, alias, effort); + } catch (error) { + host.showError(`Switched subagents to ${displayName}, but failed to save default: ${formatErrorMessage(error)}`); + return; + } + } + + const effortSuffix = effort.length > 0 ? ` (effort: ${effort})` : ''; + const status = persist + ? persisted + ? `Saved ${displayName}${effortSuffix} as the default subagent model.` + : `Subagent model set to ${displayName}${effortSuffix}.` + : `Subagent model set to ${displayName}${effortSuffix} for this session only.`; + host.showStatus(status, 'success'); + host.track('subagent_model_switch', { model: alias, effort, persist }); +} + +async function persistSubagentModelSelection( + host: SlashCommandHost, + alias: string, + effort: string, +): Promise { + const config = await host.harness.getConfig({ reload: true }); + const modelChanged = config.defaultSubagentModel !== alias; + const effortChanged = config.defaultSubagentThinkingEffort !== effort; + if (!modelChanged && !effortChanged) return false; + await host.harness.setConfig({ + ...(modelChanged ? { defaultSubagentModel: alias } : {}), + ...(effortChanged ? { defaultSubagentThinkingEffort: effort } : {}), + }); + return true; +} + function showEditorPicker(host: SlashCommandHost): void { const currentValue = host.state.appState.editorCommand ?? ''; host.mountEditorReplacement( @@ -674,12 +830,25 @@ export async function applyExperimentalFeatureChanges( host.refreshSlashCommandAutocomplete(); host.restoreEditor(); if (host.session !== undefined) { + // A live session re-syncs appState (including subagent model/effort) + // via reloadSession + reloadCurrentSessionView, so no manual sync here. await host.session.reloadSession(); await host.reloadCurrentSessionView( host.session, 'Experimental features updated. Session reloaded.', ); } else { + // No session to reload — manually sync the subagent-model app state + // from config so the footer and /model scope picker reflect the flag. + const config = await host.harness.getConfig({ reload: true }); + host.setAppState({ + subagentModel: isExperimentalFlagEnabled('dual-model-routing') + ? (config.defaultSubagentModel ?? undefined) + : undefined, + subagentThinkingEffort: isExperimentalFlagEnabled('dual-model-routing') + ? (config.defaultSubagentThinkingEffort ?? undefined) + : undefined, + }); host.showStatus('Experimental features updated.', 'success'); } host.track('experimental_features_apply', { changed: changes.length }); diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index a2e8d7adee..a205cc5d6f 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -13,6 +13,7 @@ import { effectiveModelAlias } from '@moonshot-ai/kimi-code-sdk'; import { ALL_TIPS, type ToolbarTip } from '#/tui/constant/tips'; import { isRainbowDancing, renderDanceFooterModel } from '#/tui/easter-eggs/dance'; +import { isExperimentalFlagEnabled } from '#/tui/commands/experimental-flags'; import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; @@ -141,6 +142,38 @@ function modelDisplayName(state: AppState): string { return effective?.displayName ?? effective?.model ?? state.model; } +/** + * Display name for the subagent model (`dual-model-routing` experimental + * feature). Returns the empty string when no subagent model is set, so the + * caller can skip rendering it. + * + * When the subagent alias matches the main model but the provider differs + * (e.g. the same model served by a different provider), the provider id is + * prepended so the user can tell the two apart at a glance. + */ +function subagentModelDisplayName(state: AppState): string { + const alias = state.subagentModel; + if (alias === undefined || alias.length === 0) return ''; + const model = state.availableModels[alias]; + const effective = model === undefined ? undefined : effectiveModelAlias(model); + const base = effective?.displayName ?? effective?.model ?? alias; + // Prepend the provider when the subagent alias matches the main model but + // the underlying provider differs (same model, different route). + const mainModel = state.availableModels[state.model]; + const mainProvider = mainModel === undefined ? undefined : effectiveModelAlias(mainModel).provider; + const subProvider = effective?.provider; + // Prepend the provider when it differs from the main model's provider, + // so the user can distinguish two routes that serve the same model name + // (e.g. the same kimi-k3 via managed:kimi-code vs opencode-go). + const providerPrefix = + subProvider !== undefined && subProvider !== mainProvider ? `${subProvider}/` : ''; + // Append the thinking-effort suffix when a separate effort is set for + // subagents (e.g. "GLM-5.2 · high"). + const effort = state.subagentThinkingEffort; + if (effort !== undefined && effort.length > 0) return `${providerPrefix}${base} · ${effort}`; + return `${providerPrefix}${base}`; +} + function shortenCwd(path: string): string { if (!path) return path; const home = process.env['HOME'] ?? ''; @@ -284,6 +317,39 @@ export class FooterComponent implements Component { left.push(renderedModelLabel); } + // Subagent model badge — shown when the `dual-model-routing` experimental + // feature is active and the subagent's effective settings differ from the + // main agent's. Three dimensions of distinctness, any one of which makes + // the badge relevant: (1) a different model alias, (2) the same alias on a + // different provider (e.g. the same model served elsewhere), or (3) a + // different thinking effort. When all three match the main agent there is + // nothing to surface. The flag check is defense-in-depth: + // getSubagentModel() / getStatus also gate on the flag, so + // appState.subagentModel should already be undefined when the feature is off. + const subagentAlias = state.subagentModel; + const subagentEffort = state.subagentThinkingEffort; + const subagentEntry = + subagentAlias !== undefined ? state.availableModels[subagentAlias] : undefined; + const subagentProvider = subagentEntry + ? effectiveModelAlias(subagentEntry).provider + : undefined; + const mainEntry = state.availableModels[state.model]; + const mainProvider = mainEntry ? effectiveModelAlias(mainEntry).provider : undefined; + const isDistinct = + subagentAlias !== undefined && + subagentAlias !== state.model + ? true // different alias → always distinct + : subagentAlias !== undefined && + (subagentProvider !== mainProvider || // same alias, different provider + (subagentEffort !== undefined && subagentEffort !== state.thinkingEffort)); // same alias, different effort + const subagentLabel = + isExperimentalFlagEnabled('dual-model-routing') && isDistinct + ? subagentModelDisplayName(state) + : ''; + if (subagentLabel.length > 0) { + left.push(chalk.hex(colors.textDim)('subagents:') + ' ' + chalk.hex(colors.text)(subagentLabel)); + } + // Background-task badges sit immediately before cwd. `bash-*` tasks // (shell processes) and `agent-*` tasks (background subagents) get // separate badges so the user can distinguish them at a glance. diff --git a/apps/kimi-code/src/tui/components/dialogs/model-scope-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-scope-selector.ts new file mode 100644 index 0000000000..0a0cbe99e7 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/model-scope-selector.ts @@ -0,0 +1,78 @@ +/** + * ModelScopeSelectorComponent — a small picker that lets the user choose + * which model scope to configure when the `dual-model-routing` experimental + * feature is active: + * + * - "main" → configure the main agent's model + * - "subagent" → configure the subagent model (used by spawned subagents) + * + * It is a thin wrapper around ChoicePickerComponent, mirroring + * PermissionSelectorComponent. Mounted via `mountEditorReplacement`. + */ + +import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; + +import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; +import { modelDisplayName } from './model-selector'; + +export type ModelScope = 'main' | 'subagent'; + +export interface ModelScopeSelectorOptions { + /** Current main-agent model alias. */ + readonly currentModel: string; + /** Current subagent model alias, or undefined when unset. */ + readonly currentSubagentModel: string | undefined; + /** Current subagent thinking effort, or undefined when unset. */ + readonly currentSubagentThinkingEffort: string | undefined; + /** Catalog of available models (alias → definition) for display names. */ + readonly availableModels: Record; + readonly onSelect: (scope: ModelScope) => void; + readonly onCancel: () => void; +} + +function isModelScope(value: string): value is ModelScope { + return value === 'main' || value === 'subagent'; +} + +export class ModelScopeSelectorComponent extends ChoicePickerComponent { + constructor(opts: ModelScopeSelectorOptions) { + const mainName = modelDisplayName(opts.currentModel, opts.availableModels[opts.currentModel]); + const effort = opts.currentSubagentThinkingEffort; + const hasEffort = effort !== undefined && effort.length > 0; + let subagentName: string; + if (opts.currentSubagentModel !== undefined && opts.currentSubagentModel.length > 0) { + const model = modelDisplayName( + opts.currentSubagentModel, + opts.availableModels[opts.currentSubagentModel], + ); + subagentName = hasEffort ? `${model} (effort: ${effort})` : model; + } else { + // The model is inherited, but a configured subagent effort still + // applies — show it so the label reflects the effective settings. + subagentName = hasEffort ? `(inherits main model, effort: ${effort})` : '(inherits main model)'; + } + + const options: readonly ChoiceOption[] = [ + { + value: 'main', + label: `Main agent — ${mainName}`, + description: 'The model that runs your conversation and owns the main turn.', + }, + { + value: 'subagent', + label: `Subagents — ${subagentName}`, + description: + 'The model used by delegated subagents (task, explore, swarm). When set, subagents run on this model instead of the main model.', + }, + ]; + + super({ + title: 'Select model scope', + options, + onSelect: (value) => { + if (isModelScope(value)) opts.onSelect(value); + }, + onCancel: opts.onCancel, + }); + } +} diff --git a/apps/kimi-code/src/tui/components/index.ts b/apps/kimi-code/src/tui/components/index.ts index 52f6c052e7..b4a5de44c3 100644 --- a/apps/kimi-code/src/tui/components/index.ts +++ b/apps/kimi-code/src/tui/components/index.ts @@ -9,6 +9,7 @@ export * from './dialogs/compaction'; export * from './dialogs/editor-selector'; export * from './dialogs/experiments-selector'; export * from './dialogs/help-panel'; +export * from './dialogs/model-scope-selector'; export * from './dialogs/model-selector'; export * from './dialogs/permission-selector'; export * from './dialogs/question-dialog'; diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index a7acc77ce8..e8730fdcb4 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -11,6 +11,7 @@ import { type RefreshResult, } from '../utils/refresh-providers'; import { thinkingEffortFromConfig } from '../utils/thinking-config'; +import { isExperimentalFlagEnabled } from '../commands/experimental-flags'; import type { SessionEventHandler } from './session-event-handler'; import type { AppState, KimiTUIOptions } from '../types'; import type { TUIState } from '../tui-state'; @@ -110,6 +111,8 @@ export class AuthFlowController { sessionId: '', model: '', sessionTitle: null, + subagentModel: undefined, + subagentThinkingEffort: undefined, }); await this.host.refreshSkillCommands(); await this.host.refreshPluginCommands(); @@ -134,6 +137,12 @@ export class AuthFlowController { availableProviders, model: defaultModel, maxContextTokens: selected.maxContextSize, + subagentModel: isExperimentalFlagEnabled('dual-model-routing') + ? (config.defaultSubagentModel ?? undefined) + : undefined, + subagentThinkingEffort: isExperimentalFlagEnabled('dual-model-routing') + ? (config.defaultSubagentThinkingEffort ?? undefined) + : undefined, }; host.setAppState(appStatePatch); } @@ -148,6 +157,8 @@ export class AuthFlowController { maxContextTokens: 0, contextUsage: 0, contextTokens: 0, + subagentModel: undefined, + subagentThinkingEffort: undefined, }); } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 464c7237aa..664ecb31c7 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -209,6 +209,8 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { : 'manual'; return { model: '', + subagentModel: undefined, + subagentThinkingEffort: undefined, workDir: input.workDir, additionalDirs: [...(input.additionalDirs ?? [])], sessionId: '', @@ -1544,6 +1546,8 @@ export class KimiTUI { this.setAppState({ sessionId: session.id, model: status.model ?? '', + subagentModel: status.subagentModel, + subagentThinkingEffort: status.subagentThinkingEffort, thinkingEffort: status.thinkingEffort, permissionMode: status.permission, planMode: status.planMode, diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index d895b11e12..5d25757511 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -26,6 +26,18 @@ export interface BannerState { export interface AppState { model: string; + /** + * Live subagent model alias (`dual-model-routing` experimental feature). + * Undefined when the feature is off or no subagent model is set (subagents + * then inherit the main model). Keyed into `availableModels`. + */ + subagentModel?: string; + /** + * Live subagent thinking effort (`dual-model-routing` experimental feature). + * Undefined when the feature is off or no subagent effort is set (subagents + * then inherit the main agent's thinking effort). + */ + subagentThinkingEffort?: string; workDir: string; additionalDirs: readonly string[]; sessionId: string; diff --git a/apps/kimi-code/test/tui/commands/experiments.test.ts b/apps/kimi-code/test/tui/commands/experiments.test.ts index 89bf62da6c..f724ac981b 100644 --- a/apps/kimi-code/test/tui/commands/experiments.test.ts +++ b/apps/kimi-code/test/tui/commands/experiments.test.ts @@ -27,6 +27,22 @@ function feature( }; } +function dualModelRoutingFeature( + overrides: Partial = {}, +): ExperimentalFeatureState { + return feature({ + id: 'dual-model-routing', + title: 'Dual-model routing', + description: 'Run subagents on a dedicated model.', + surface: 'core', + env: 'KIMI_CODE_EXPERIMENTAL_DUAL_MODEL_ROUTING', + defaultEnabled: false, + enabled: false, + source: 'default', + ...overrides, + }); +} + function makeHost() { const session = { id: 'ses-experiments', @@ -39,6 +55,7 @@ function makeHost() { }, harness: { setConfig: vi.fn(async () => ({ providers: {} })), + getConfig: vi.fn(async () => ({ providers: {} })), getExperimentalFeatures: vi.fn(async () => [ feature({ enabled: false, source: 'config', configValue: false }), ]), @@ -50,10 +67,12 @@ function makeHost() { restoreEditor: vi.fn(), showStatus: vi.fn(), showError: vi.fn(), + setAppState: vi.fn(), track: vi.fn(), } as unknown as SlashCommandHost & { harness: { setConfig: ReturnType; + getConfig: ReturnType; getExperimentalFeatures: ReturnType; }; refreshSlashCommandAutocomplete: ReturnType; @@ -62,12 +81,20 @@ function makeHost() { restoreEditor: ReturnType; showStatus: ReturnType; showError: ReturnType; + setAppState: ReturnType; track: ReturnType; session: typeof session; }; return host; } +/** Make a host whose session is undefined (the no-session path exercises the + * manual subagent-model sync in applyExperimentalFeatureChanges). */ +function makeNoSessionHost() { + const host = makeHost(); + return { ...host, session: undefined }; +} + describe('experimental feature command handlers', () => { afterEach(() => { setExperimentalFeatures([]); @@ -114,3 +141,71 @@ describe('experimental feature command handlers', () => { ); }); }); + +describe('applyExperimentalFeatureChanges dual-model-routing sync', () => { + afterEach(() => { + setExperimentalFeatures([]); + }); + + it('populates subagentModel/subagentThinkingEffort from config when enabling (no session)', async () => { + const host = makeNoSessionHost(); + host.harness.getExperimentalFeatures.mockResolvedValueOnce([ + dualModelRoutingFeature({ enabled: true, source: 'config', configValue: true }), + ]); + host.harness.getConfig.mockResolvedValueOnce({ + defaultSubagentModel: 'glm-5.2', + defaultSubagentThinkingEffort: 'high', + }); + + await applyExperimentalFeatureChanges(host, [ + { id: 'dual-model-routing', enabled: true }, + ]); + + expect(host.setAppState).toHaveBeenCalledWith({ + subagentModel: 'glm-5.2', + subagentThinkingEffort: 'high', + }); + }); + + it('clears subagentModel/subagentThinkingEffort when disabling (no session)', async () => { + const host = makeNoSessionHost(); + // Seed the flag as enabled so the disabling change is observed. + setExperimentalFeatures([{ id: 'dual-model-routing', enabled: true }]); + host.harness.getExperimentalFeatures.mockResolvedValueOnce([ + dualModelRoutingFeature({ enabled: false, source: 'config', configValue: false }), + ]); + + await applyExperimentalFeatureChanges(host, [ + { id: 'dual-model-routing', enabled: false }, + ]); + + expect(host.setAppState).toHaveBeenCalledWith({ + subagentModel: undefined, + subagentThinkingEffort: undefined, + }); + }); + + it('skips the manual setAppState sync when a session is present (reload covers it)', async () => { + const host = makeHost(); + host.harness.getExperimentalFeatures.mockResolvedValueOnce([ + dualModelRoutingFeature({ enabled: true, source: 'config', configValue: true }), + ]); + + await applyExperimentalFeatureChanges(host, [ + { id: 'dual-model-routing', enabled: true }, + ]); + + // setAppState is never called with a subagentModel patch in the session + // branch — reloadSession + reloadCurrentSessionView own that sync. + const calls = host.setAppState.mock.calls as ReadonlyArray< + ReadonlyArray> + >; + expect( + calls.some( + (call) => + call[0]?.['subagentModel'] !== undefined || + call[0]?.['subagentThinkingEffort'] !== undefined, + ), + ).toBe(false); + }); +}); diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index 2fe6f3e52e..471b1d7542 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -2,6 +2,7 @@ import chalk from 'chalk'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; +import { setExperimentalFeatures } from '#/tui/commands/experimental-flags'; import { setRainbowDance, type RainbowDanceController } from '#/tui/easter-eggs/dance'; import { currentTheme, darkColors, lightColors } from '#/tui/theme'; import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; @@ -187,3 +188,196 @@ describe('FooterComponent displayName override', () => { expect(footer.render(120).join('\n')).not.toContain('Remote Name'); }); }); + +describe('FooterComponent subagent model badge', () => { + afterEach(() => { + setExperimentalFeatures([]); + }); + + it('hides the subagent badge when the dual-model-routing flag is off', () => { + setExperimentalFeatures([]); + const state: AppState = { + ...appState, + model: 'kimi-k3', + subagentModel: 'glm-5.2', + availableModels: { + 'kimi-k3': { provider: 'managed:kimi-code', model: 'kimi-k3', maxContextSize: 1_048_576 }, + 'glm-5.2': { provider: 'zai', model: 'glm-5.2', maxContextSize: 1_000_000, displayName: 'GLM-5.2' }, + }, + }; + const footer = new FooterComponent(state); + const rendered = footer.render(140).join('\n'); + + // Even with a subagentModel set, the badge is hidden when the flag is off. + expect(rendered).not.toContain('subagents:'); + }); + + it('hides the subagent badge when no subagent model is set', () => { + setExperimentalFeatures([{ id: 'dual-model-routing', enabled: true }]); + const footer = new FooterComponent(appState); + const rendered = footer.render(120).join('\n'); + + expect(rendered).not.toContain('subagents:'); + }); + + it('shows the subagent model when the flag is on and a distinct model is set', () => { + setExperimentalFeatures([{ id: 'dual-model-routing', enabled: true }]); + const state: AppState = { + ...appState, + model: 'kimi-k3', + subagentModel: 'glm-5.2', + availableModels: { + 'kimi-k3': { + provider: 'managed:kimi-code', + model: 'kimi-k3', + maxContextSize: 1_048_576, + }, + 'glm-5.2': { + provider: 'zai', + model: 'glm-5.2', + maxContextSize: 1_000_000, + displayName: 'GLM-5.2', + }, + }, + }; + const footer = new FooterComponent(state); + const rendered = footer.render(140).join('\n'); + + expect(rendered).toContain('subagents:'); + expect(rendered).toContain('GLM-5.2'); + }); + + it('hides the subagent badge when subagentModel equals the main model', () => { + setExperimentalFeatures([{ id: 'dual-model-routing', enabled: true }]); + const state: AppState = { + ...appState, + model: 'kimi-k3', + subagentModel: 'kimi-k3', + availableModels: { + 'kimi-k3': { + provider: 'managed:kimi-code', + model: 'kimi-k3', + maxContextSize: 1_048_576, + displayName: 'Kimi K3', + }, + }, + }; + const footer = new FooterComponent(state); + const rendered = footer.render(140).join('\n'); + + // Even with the flag on and a subagentModel set, the badge is hidden + // when the alias is identical to the main model (nothing distinct to show). + expect(rendered).not.toContain('subagents:'); + }); + + it('shows the badge when the model is the same but the thinking effort differs', () => { + setExperimentalFeatures([{ id: 'dual-model-routing', enabled: true }]); + const state: AppState = { + ...appState, + model: 'kimi-k3', + thinkingEffort: 'low', + subagentModel: 'kimi-k3', + subagentThinkingEffort: 'high', + availableModels: { + 'kimi-k3': { + provider: 'managed:kimi-code', + model: 'kimi-k3', + maxContextSize: 1_048_576, + displayName: 'Kimi K3', + }, + }, + }; + const footer = new FooterComponent(state); + const rendered = footer.render(140).join('\n'); + + expect(rendered).toContain('subagents:'); + expect(rendered).toContain('Kimi K3 · high'); + }); + + it('shows the badge with provider prefix when the model name is the same but the provider differs', () => { + setExperimentalFeatures([{ id: 'dual-model-routing', enabled: true }]); + // Two aliases serve the same underlying model (kimi-k3) via different + // providers: the main agent uses managed:kimi-code, the subagent uses + // opencode-go. The badge shows the provider prefix on the subagent side + // so the user can tell the routes apart. + const state: AppState = { + ...appState, + model: 'kimi-k3', + subagentModel: 'kimi-k3-opencode', + availableModels: { + 'kimi-k3': { + provider: 'managed:kimi-code', + model: 'kimi-k3', + maxContextSize: 1_048_576, + displayName: 'Kimi K3', + }, + 'kimi-k3-opencode': { + provider: 'opencode-go', + model: 'kimi-k3', + maxContextSize: 1_048_576, + displayName: 'Kimi K3', + }, + }, + }; + const footer = new FooterComponent(state); + const rendered = footer.render(160).join('\n'); + + expect(rendered).toContain('subagents:'); + // The provider prefix is shown so the user can tell the routes apart. + expect(rendered).toContain('opencode-go/Kimi K3'); + }); + + it('appends the thinking-effort suffix to the subagent badge when set', () => { + setExperimentalFeatures([{ id: 'dual-model-routing', enabled: true }]); + const state: AppState = { + ...appState, + model: 'kimi-k3', + subagentModel: 'glm-5.2', + subagentThinkingEffort: 'high', + availableModels: { + 'kimi-k3': { + provider: 'managed:kimi-code', + model: 'kimi-k3', + maxContextSize: 1_048_576, + }, + 'glm-5.2': { + provider: 'zai', + model: 'glm-5.2', + maxContextSize: 1_000_000, + displayName: 'GLM-5.2', + }, + }, + }; + const footer = new FooterComponent(state); + const rendered = footer.render(140).join('\n'); + + expect(rendered).toContain('GLM-5.2 · high'); + }); + + it('shows no effort suffix when subagentThinkingEffort is unset', () => { + setExperimentalFeatures([{ id: 'dual-model-routing', enabled: true }]); + const state: AppState = { + ...appState, + model: 'kimi-k3', + subagentModel: 'glm-5.2', + availableModels: { + 'kimi-k3': { + provider: 'managed:kimi-code', + model: 'kimi-k3', + maxContextSize: 1_048_576, + }, + 'glm-5.2': { + provider: 'zai', + model: 'glm-5.2', + maxContextSize: 1_000_000, + displayName: 'GLM-5.2', + }, + }, + }; + const footer = new FooterComponent(state); + const rendered = footer.render(140).join('\n'); + + expect(rendered).toContain('GLM-5.2'); + expect(rendered).not.toContain('GLM-5.2 ·'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/model-scope-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-scope-selector.test.ts new file mode 100644 index 0000000000..366a72b699 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/model-scope-selector.test.ts @@ -0,0 +1,113 @@ +import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { ModelScopeSelectorComponent } from '#/tui/components/dialogs/model-scope-selector'; + +const ANSI_SGR = /\[[0-9;]*m/g; + +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +const availableModels: Record = { + 'kimi-k3': { + provider: 'managed:kimi-code', + model: 'kimi-k3', + maxContextSize: 1_048_576, + displayName: 'Kimi K3', + }, + 'glm-5.2': { + provider: 'zai', + model: 'glm-5.2', + maxContextSize: 1_000_000, + displayName: 'GLM-5.2', + }, +}; + +describe('ModelScopeSelectorComponent', () => { + it('shows resolved display names for both scopes', () => { + const onSelect = vi.fn(); + const onCancel = vi.fn(); + const picker = new ModelScopeSelectorComponent({ + currentModel: 'kimi-k3', + currentSubagentModel: 'glm-5.2', + currentSubagentThinkingEffort: undefined, + availableModels, + onSelect, + onCancel, + }); + + const out = picker.render(120).map(strip); + + expect(out.some((l) => l.includes('Main agent — Kimi K3'))).toBe(true); + expect(out.some((l) => l.includes('Subagents — GLM-5.2'))).toBe(true); + }); + + it('shows "(inherits main model)" when currentSubagentModel is undefined', () => { + const picker = new ModelScopeSelectorComponent({ + currentModel: 'kimi-k3', + currentSubagentModel: undefined, + currentSubagentThinkingEffort: undefined, + availableModels, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = picker.render(120).map(strip); + + expect(out.some((l) => l.includes('Subagents — (inherits main model)'))).toBe(true); + }); + + it('appends the effort suffix when currentSubagentThinkingEffort is set', () => { + const picker = new ModelScopeSelectorComponent({ + currentModel: 'kimi-k3', + currentSubagentModel: 'glm-5.2', + currentSubagentThinkingEffort: 'high', + availableModels, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = picker.render(120).map(strip); + + expect(out.some((l) => l.includes('Subagents — GLM-5.2 (effort: high)'))).toBe(true); + }); + + it('shows the effort when the model is inherited but an effort is configured', () => { + const picker = new ModelScopeSelectorComponent({ + currentModel: 'kimi-k3', + currentSubagentModel: undefined, + currentSubagentThinkingEffort: 'high', + availableModels, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = picker.render(120).map(strip); + + expect(out.some((l) => l.includes('Subagents — (inherits main model, effort: high)'))).toBe( + true, + ); + }); + + it('fires onSelect with "main" for the main option and "subagent" for the subagent option', () => { + const onSelect = vi.fn(); + const picker = new ModelScopeSelectorComponent({ + currentModel: 'kimi-k3', + currentSubagentModel: 'glm-5.2', + currentSubagentThinkingEffort: undefined, + availableModels, + onSelect, + onCancel: vi.fn(), + }); + + // First option is "main" and starts selected. + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith('main'); + + // Move down to the subagent option and select it. + picker.handleInput('\u001B[B'); // ↓ + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith('subagent'); + }); +}); diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 5708bcc9bf..16d4213629 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -313,6 +313,9 @@ const conversationPaneRef = ref | null>(nu // Dialog visibility refs const showModelPicker = ref(false); +/** Model picker opened in "subagent" mode (dual-model-routing). Selection + * routes to setSubagentModel instead of setModel. */ +const subagentPickerOpen = ref(false); const showProviders = ref(false); const showLogin = ref(false); @@ -338,6 +341,7 @@ const anyOverlayOpen = computed( () => openDialogCount.value > 0 || showModelPicker.value || + subagentPickerOpen.value || showProviders.value || showLogin.value || showAddWorkspace.value || @@ -413,6 +417,32 @@ async function handleComposerSelectModel(modelId: string): Promise { } } +/** Open the model picker overlay in subagent mode (dual-model-routing). + * Same ModelPicker component; selection is routed to setSubagentModel. */ +async function openSubagentModelPicker(): Promise { + modelsLoading.value = true; + modelsUnavailable.value = false; + subagentPickerOpen.value = true; + try { + await client.refreshAllProviders(); + } catch { + modelsUnavailable.value = true; + } finally { + modelsLoading.value = false; + } +} + +/** Clear the active session's subagent model so subagents fall back to main. */ +function handleClearSubagentModel(): void { + void client.setSubagentModel(undefined); +} + +/** Handle a selection from the model picker when it is in subagent mode. */ +async function handleSelectSubagentModel(modelId: string): Promise { + subagentPickerOpen.value = false; + await client.setSubagentModel(modelId); +} + async function handleAddProvider(input: { type: string; apiKey?: string; baseUrl?: string; defaultModel?: string }): Promise { await client.addProvider(input); } @@ -823,6 +853,7 @@ function openPr(url: string): void { :session-title="activeSessionTitle" :pr="client.activePullRequest.value" :conversation-toc="client.conversationToc.value" + :dual-model-routing="client.dualModelRoutingEnabled.value" @open-changes="openDiffDetail()" @select-workspace="handleCreateSessionInWorkspace($event)" @add-workspace="showAddWorkspace = true" @@ -853,6 +884,8 @@ function openPr(url: string): void { @compact="client.compact()" @pick-model="openModelPicker()" @select-model="handleComposerSelectModel($event)" + @pick-subagent-model="openSubagentModelPicker()" + @clear-subagent-model="handleClearSubagentModel()" @open-file="openFilePreview($event)" @open-media="openMediaPreview($event)" @open-thinking="openThinkingPanel($event)" @@ -984,6 +1017,20 @@ function openPr(url: string): void { @close="showModelPicker = false" /> + + + { const body: Record = {}; @@ -432,6 +434,9 @@ export class DaemonKimiWebApi implements KimiWebApi { if (input.goalObjective !== undefined) agentConfig['goal_objective'] = input.goalObjective; if (input.goalControl !== undefined) agentConfig['goal_control'] = input.goalControl; if (input.thinking !== undefined) agentConfig['thinking'] = input.thinking; + if (input.subagentModel !== undefined) agentConfig['subagent_model'] = input.subagentModel; + if (input.subagentThinkingEffort !== undefined) + agentConfig['subagent_thinking_effort'] = input.subagentThinkingEffort; if (Object.keys(agentConfig).length > 0) body['agent_config'] = agentConfig; const data = await this.http.post( `/sessions/${encodeURIComponent(sessionId)}/profile`, @@ -459,6 +464,14 @@ export class DaemonKimiWebApi implements KimiWebApi { contextTokens: data.context_tokens ?? 0, maxContextTokens: data.max_context_tokens ?? 0, contextUsage: data.context_usage ?? 0, + subagentModel: + typeof data.subagent_model === 'string' && data.subagent_model.length > 0 + ? data.subagent_model + : undefined, + subagentThinkingEffort: + typeof data.subagent_thinking_effort === 'string' && data.subagent_thinking_effort.length > 0 + ? data.subagent_thinking_effort + : undefined, }; } diff --git a/apps/kimi-web/src/api/daemon/wire.ts b/apps/kimi-web/src/api/daemon/wire.ts index 900d98bcf0..bef381869c 100644 --- a/apps/kimi-web/src/api/daemon/wire.ts +++ b/apps/kimi-web/src/api/daemon/wire.ts @@ -94,6 +94,11 @@ export interface WireSession { swarm_mode?: boolean; goal_objective?: string; goal_control?: 'pause' | 'resume' | 'cancel'; + /** Separate model for subagents (dual-model-routing experimental flag). + * Empty string clears it so subagents use the main model. */ + subagent_model?: string; + /** Separate thinking effort for subagents (dual-model-routing). */ + subagent_thinking_effort?: string; }; usage: WireSessionUsage; permission_rules: WirePermissionRule[]; @@ -111,6 +116,11 @@ export interface WireSessionRuntimeStatus { context_tokens: number; max_context_tokens: number; context_usage: number; + /** Separate model for subagents (dual-model-routing experimental flag). + * Empty/absent means subagents use the main model. */ + subagent_model?: string; + /** Separate thinking effort for subagents (dual-model-routing). */ + subagent_thinking_effort?: string; } // GET /sessions/{id}/goal — camelCase, same shape as the `goal.updated` event diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 5c34d26ccb..b8e27e8ffd 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -106,6 +106,11 @@ export interface AppSessionRuntimeStatus { contextTokens: number; maxContextTokens: number; contextUsage: number; + /** Separate model used for subagents when dual-model-routing is enabled. + * Empty/undefined means subagents use the main model. */ + subagentModel?: string; + /** Separate thinking effort for subagents (dual-model-routing). */ + subagentThinkingEffort?: string; } // --------------------------------------------------------------------------- @@ -704,7 +709,7 @@ export interface KimiWebApi { createSession(input: { title?: string; cwd?: string; model?: string; workspaceId?: string }): Promise; /** Fetch one session by id (deep links beyond the first listSessions page). */ getSession(sessionId: string): Promise; - updateSession(sessionId: string, input: { title?: string; cwd?: string; model?: string; permissionMode?: string; planMode?: boolean; swarmMode?: boolean; goalObjective?: string; goalControl?: 'pause' | 'resume' | 'cancel'; thinking?: string }): Promise; + updateSession(sessionId: string, input: { title?: string; cwd?: string; model?: string; permissionMode?: string; planMode?: boolean; swarmMode?: boolean; goalObjective?: string; goalControl?: 'pause' | 'resume' | 'cancel'; thinking?: string; subagentModel?: string; subagentThinkingEffort?: string }): Promise; getSessionStatus(sessionId: string): Promise; /** Current goal snapshot, or null when the session has no active goal. */ getSessionGoal(sessionId: string): Promise; diff --git a/apps/kimi-web/src/components/chat/ChatDock.vue b/apps/kimi-web/src/components/chat/ChatDock.vue index 160c77a242..a924542702 100644 --- a/apps/kimi-web/src/components/chat/ChatDock.vue +++ b/apps/kimi-web/src/components/chat/ChatDock.vue @@ -54,6 +54,8 @@ const props = defineProps<{ /** True while the visible approval has a respond in flight. */ approvalBusy?: boolean; mobile?: boolean; + /** True when the dual-model-routing experimental flag is on. */ + dualModelRouting?: boolean; }>(); const emit = defineEmits<{ @@ -74,6 +76,8 @@ const emit = defineEmits<{ compact: []; pickModel: []; selectModel: [modelId: string]; + pickSubagentModel: []; + clearSubagentModel: []; answer: [questionId: string, response: QuestionResponse]; dismiss: [questionId: string]; approval: [approvalId: string, response: { decision: 'approved' | 'rejected' | 'cancelled'; scope?: 'session'; feedback?: string; selectedLabel?: string }]; @@ -282,6 +286,8 @@ defineExpose({ loadForEdit, loadAttachmentsForEdit, focus }); :starred-ids="starredIds" :skills="skills" :starting="starting" + :dual-model-routing="dualModelRouting" + :subagent-model-id="status.subagentModelId" @submit="emit('submit', $event)" @steer="emit('steer', $event)" @command="emit('command', $event)" @@ -299,6 +305,8 @@ defineExpose({ loadForEdit, loadAttachmentsForEdit, focus }); @compact="emit('compact')" @pick-model="emit('pickModel')" @select-model="emit('selectModel', $event)" + @pick-subagent-model="emit('pickSubagentModel')" + @clear-subagent-model="emit('clearSubagentModel')" /> diff --git a/apps/kimi-web/src/components/chat/Composer.vue b/apps/kimi-web/src/components/chat/Composer.vue index 5d2e16f6d9..151a8d2831 100644 --- a/apps/kimi-web/src/components/chat/Composer.vue +++ b/apps/kimi-web/src/components/chat/Composer.vue @@ -64,6 +64,11 @@ const props = withDefaults(defineProps<{ skills?: AppSkill[]; /** Hide the context-usage indicator (used on the empty-session landing page). */ hideContext?: boolean; + /** True when the dual-model-routing experimental flag is enabled on the + * server. Gates the subagent model pill in the toolbar. */ + dualModelRouting?: boolean; + /** Subagent model id for the active session (undefined = same as main). */ + subagentModelId?: string; }>(), { running: false, starting: false, @@ -73,6 +78,8 @@ const props = withDefaults(defineProps<{ models: () => [], starredIds: () => [], skills: () => [], + dualModelRouting: false, + subagentModelId: undefined, }); const placeholder = computed(() => @@ -105,6 +112,10 @@ const emit = defineEmits<{ compact: []; pickModel: []; selectModel: [modelId: string]; + /** Open the full model picker for the subagent model. */ + pickSubagentModel: []; + /** Clear the subagent model (revert to "same as main"). */ + clearSubagentModel: []; }>(); const { t, locale } = useI18n(); @@ -825,6 +836,16 @@ const starredSet = computed(() => new Set(props.starredIds ?? [])); function isStarred(modelId: string): boolean { return starredSet.value.has(modelId); } + +/** Display name for the subagent model pill. Resolved against the catalog; + * falls back to the raw id. Empty when no subagent model is set. */ +const subagentDisplay = computed(() => { + const id = props.subagentModelId; + if (!id) return ''; + const matched = + props.models.find((m) => m.id === id) ?? props.models.find((m) => m.model === id); + return matched?.displayName ?? matched?.model ?? (id.includes('/') ? id.split('/').pop()! : id); +}); const starredOtherModels = computed(() => { if (!props.models?.length) return []; return props.models.filter( @@ -1116,6 +1137,36 @@ function selectModel(modelId: string): void { {{ thinkingSuffix }} + + + + {{ t('status.subagentLabel') }} + {{ subagentDisplay }} + {{ t('status.subagentSameAsMain') }} + + +