From 54fa7830cc887d9174cd30b4a1da011d12decd5d Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 4 Jun 2026 17:07:29 +0800 Subject: [PATCH 1/4] feat: Add persistent experimental feature toggles and a TUI panel --- .../persistent-experimental-features.md | 7 + apps/kimi-code/src/cli/run-prompt.ts | 3 +- apps/kimi-code/src/tui/commands/config.ts | 80 +++++- apps/kimi-code/src/tui/commands/dispatch.ts | 13 + apps/kimi-code/src/tui/commands/index.ts | 1 + apps/kimi-code/src/tui/commands/registry.ts | 9 +- apps/kimi-code/src/tui/commands/reload.ts | 3 + .../dialogs/experiments-selector.ts | 232 ++++++++++++++++++ .../components/dialogs/settings-selector.ts | 7 + apps/kimi-code/src/tui/components/index.ts | 1 + apps/kimi-code/src/tui/kimi-tui.ts | 6 +- apps/kimi-code/test/cli/goal-prompt.test.ts | 4 +- .../test/tui/commands/experiments.test.ts | 119 +++++++++ apps/kimi-code/test/tui/commands/goal.test.ts | 2 +- .../test/tui/commands/registry.test.ts | 4 +- .../test/tui/commands/reload.test.ts | 13 + .../test/tui/commands/resolve.test.ts | 20 +- .../dialogs/experiments-selector.test.ts | 155 ++++++++++++ .../test/tui/kimi-tui-startup.test.ts | 4 +- docs/en/configuration/config-files.md | 20 +- docs/en/configuration/env-vars.md | 4 + docs/en/reference/slash-commands.md | 11 +- docs/zh/configuration/config-files.md | 20 +- docs/zh/configuration/env-vars.md | 4 + docs/zh/reference/slash-commands.md | 11 +- .../agent-core/src/agent/compaction/micro.ts | 5 +- packages/agent-core/src/agent/index.ts | 4 + .../agent-core/src/agent/injection/manager.ts | 14 +- packages/agent-core/src/agent/tool/index.ts | 21 +- packages/agent-core/src/agent/turn/index.ts | 3 +- packages/agent-core/src/config/schema.ts | 21 ++ packages/agent-core/src/config/toml.ts | 15 ++ packages/agent-core/src/flags/registry.ts | 19 +- packages/agent-core/src/flags/resolver.ts | 61 ++++- packages/agent-core/src/flags/types.ts | 28 +++ packages/agent-core/src/rpc/core-api.ts | 3 +- packages/agent-core/src/rpc/core-impl.ts | 38 ++- packages/agent-core/src/session/index.ts | 5 + packages/agent-core/src/session/rpc.ts | 3 +- .../tools/builtin/collaboration/ask-user.ts | 30 ++- packages/agent-core/test/agent/basic.test.ts | 9 + .../test/agent/compaction/full.test.ts | 4 +- .../test/agent/compaction/micro.test.ts | 8 +- .../agent-core/test/agent/harness/agent.ts | 2 + .../agent-core/test/config/configs.test.ts | 60 +++++ .../agent-core/test/flags/resolver.test.ts | 74 ++++++ .../agent-core/test/harness/runtime.test.ts | 128 +++++++++- .../agent-core/test/tools/ask-user.test.ts | 32 +++ packages/node-sdk/src/index.ts | 2 + packages/node-sdk/src/kimi-harness.ts | 5 + packages/node-sdk/src/rpc.ts | 6 + packages/node-sdk/src/types.ts | 3 + packages/node-sdk/test/config.test.ts | 37 ++- 53 files changed, 1299 insertions(+), 94 deletions(-) create mode 100644 .changeset/persistent-experimental-features.md create mode 100644 apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts create mode 100644 apps/kimi-code/test/tui/commands/experiments.test.ts create mode 100644 apps/kimi-code/test/tui/components/dialogs/experiments-selector.test.ts diff --git a/.changeset/persistent-experimental-features.md b/.changeset/persistent-experimental-features.md new file mode 100644 index 0000000000..36f58493a3 --- /dev/null +++ b/.changeset/persistent-experimental-features.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code-sdk": minor +"@moonshot-ai/kimi-code": minor +--- + +Add persistent experimental feature toggles and a TUI panel that applies confirmed changes by reloading the current session. diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index c01b0015b2..37706fda74 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -147,7 +147,7 @@ export async function runPrompt( // waiter blocks until the goal is terminal; we then emit a summary and set a // distinct exit code. const flagMap = await harness.getExperimentalFlags(); - const goalCreate = parseHeadlessGoalCreate(opts.prompt!, flagMap['goal-command'] === true); + const goalCreate = parseHeadlessGoalCreate(opts.prompt!, flagMap['goal_command'] === true); if (goalCreate !== undefined) { await runHeadlessGoal(session, goalCreate, goalModel, outputFormat, stdout, stderr); } else { @@ -466,6 +466,7 @@ function runPromptTurn( case 'compaction.completed': case 'compaction.started': case 'cron.fired': + case 'goal.updated': case 'mcp.server.status': case 'session.meta.updated': case 'skill.activated': diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 5259f75db7..b8d86c8eb4 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -1,6 +1,15 @@ -import type { PermissionMode, Session } from '@moonshot-ai/kimi-code-sdk'; +import type { + ExperimentalFeatureState, + FlagId, + PermissionMode, + Session, +} from '@moonshot-ai/kimi-code-sdk'; import { EditorSelectorComponent } from '../components/dialogs/editor-selector'; +import { + ExperimentsSelectorComponent, + type ExperimentalFeatureDraftChange, +} from '../components/dialogs/experiments-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'; @@ -12,6 +21,7 @@ import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; import { isTheme } from '../theme/index'; import { formatErrorMessage } from '../utils/event-payload'; import { showUsage } from './info'; +import { setExperimentalFlags } from './experimental-flags'; import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- @@ -421,6 +431,73 @@ export function showUpdatePreferencePicker(host: SlashCommandHost): void { ); } +export async function showExperimentsPanel(host: SlashCommandHost): Promise { + let features: readonly ExperimentalFeatureState[]; + try { + features = await host.harness.getExperimentalFeatures(); + } catch (error) { + host.showError(`Failed to load experimental features: ${formatErrorMessage(error)}`); + return; + } + mountExperimentsPanel(host, features); +} + +export async function applyExperimentalFeatureChanges( + host: SlashCommandHost, + changes: readonly ExperimentalFeatureDraftChange[], +): Promise { + if (changes.length === 0) { + host.showStatus( + 'No experimental feature changes to apply.', + host.state.theme.colors.textMuted, + ); + return; + } + + const experimental: Partial> = {}; + for (const change of changes) { + experimental[change.id] = change.enabled; + } + + try { + await host.harness.setConfig({ experimental }); + const flags = await host.harness.getExperimentalFlags(); + setExperimentalFlags(flags); + host.refreshSlashCommandAutocomplete(); + host.restoreEditor(); + if (host.session !== undefined) { + await host.session.reloadSession(); + await host.reloadCurrentSessionView( + host.session, + 'Experimental features updated. Session reloaded.', + ); + } else { + host.showStatus('Experimental features updated.', host.state.theme.colors.success); + } + host.track('experimental_features_apply', { changed: changes.length }); + } catch (error) { + host.showError(`Failed to update experimental features: ${formatErrorMessage(error)}`); + } +} + +function mountExperimentsPanel( + host: SlashCommandHost, + features: readonly ExperimentalFeatureState[], +): void { + host.mountEditorReplacement( + new ExperimentsSelectorComponent({ + features, + colors: host.state.theme.colors, + onApply: (changes) => { + void applyExperimentalFeatureChanges(host, changes); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + type UpdatePreferenceHost = { readonly state: { readonly appState: Pick< @@ -503,6 +580,7 @@ function handleSettingsSelection(host: SlashCommandHost, value: SettingsSelectio case 'permission': showPermissionPicker(host); return; case 'theme': showThemePicker(host); return; case 'editor': showEditorPicker(host); return; + case 'experiments': void showExperimentsPanel(host); return; case 'upgrade': showUpdatePreferencePicker(host); return; case 'usage': void showUsage(host); return; } diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index e5dbd47f10..fff595d165 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -32,6 +32,7 @@ import { handlePlanCommand, handleThemeCommand, handleYoloCommand, + showExperimentsPanel, showModelPicker, showPermissionPicker, showSettingsSelector, @@ -68,6 +69,7 @@ export { handleThemeCommand, handleYoloCommand, showModelPicker, + showExperimentsPanel, showPermissionPicker, showSettingsSelector, } from './config'; @@ -109,6 +111,7 @@ export interface SlashCommandHost { mountEditorReplacement(panel: Component & Focusable): void; restoreEditor(): void; restoreInputText(text: string): void; + refreshSlashCommandAutocomplete(): void; // Session requireSession(): Session; @@ -171,6 +174,13 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi host.track('input_command_invalid', { reason: 'blocked', command: intent.commandName }); host.showError(slashBusyMessage(intent.commandName, intent.reason)); return; + case 'invalid': + host.track('input_command_invalid', { + reason: intent.reason, + command: intent.commandName, + }); + host.showError(`Invalid slash command: /${intent.commandName}`); + return; case 'skill': { const session = host.session; if (host.state.appState.model.trim().length === 0 || session === undefined) { @@ -238,6 +248,9 @@ async function handleBuiltInSlashCommand( case 'plugins': void handlePluginsCommand(host, args); return; + case 'experiments': + await showExperimentsPanel(host); + return; case 'reload': await handleReloadCommand(host); return; diff --git a/apps/kimi-code/src/tui/commands/index.ts b/apps/kimi-code/src/tui/commands/index.ts index 45cf1c220f..8dd5dde955 100644 --- a/apps/kimi-code/src/tui/commands/index.ts +++ b/apps/kimi-code/src/tui/commands/index.ts @@ -18,6 +18,7 @@ export { handlePlanCommand, handleThemeCommand, handleYoloCommand, + showExperimentsPanel, showModelPicker, showPermissionPicker, showSettingsSelector, diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 80ebb73798..b334ecfe9b 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -114,6 +114,13 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 60, availability: 'always', }, + { + name: 'experiments', + aliases: ['experimental'], + description: 'Manage experimental features', + priority: 60, + availability: 'always', + }, { name: 'reload', aliases: [], @@ -139,7 +146,7 @@ export const BUILTIN_SLASH_COMMANDS = [ aliases: [], description: 'Start or manage an autonomous goal', priority: 80, - experimentalFlag: 'goal-command', + experimentalFlag: 'goal_command', // No argumentHint: the menu description stays as short as every other // command's. The subcommands (status/pause/resume/cancel/replace) surface in // the argument autocomplete list once the user types `/goal ` (see diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index 402c07765e..17100f5500 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -2,6 +2,7 @@ import type { KimiConfig } from '@moonshot-ai/kimi-code-sdk'; import { loadTuiConfig, type TuiConfig } from '../config'; import type { SlashCommandHost } from './dispatch'; +import { setExperimentalFlags } from './experimental-flags'; export async function handleReloadTuiCommand(host: SlashCommandHost): Promise { const tuiConfig = await loadTuiConfig(); @@ -19,6 +20,8 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise } const config = await host.harness.getConfig({ reload: true }); + setExperimentalFlags(await host.harness.getExperimentalFlags()); + host.refreshSlashCommandAutocomplete(); applyRuntimeConfig(host, config); applyReloadedTuiConfig(host, tuiConfig); diff --git a/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts b/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts new file mode 100644 index 0000000000..c7dcb8da6a --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts @@ -0,0 +1,232 @@ +import { + Container, + Key, + matchesKey, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@earendil-works/pi-tui'; +import type { ExperimentalFeatureState } from '@moonshot-ai/kimi-code-sdk'; +import chalk from 'chalk'; + +import { SELECT_POINTER } from '#/tui/constant/symbols'; +import type { ColorPalette } from '#/tui/theme/colors'; +import { printableChar } from '#/tui/utils/printable-key'; +import { SearchableList } from '#/tui/utils/searchable-list'; + +const ELLIPSIS = '…'; + +export interface ExperimentalFeatureDraftChange { + readonly id: ExperimentalFeatureState['id']; + readonly enabled: boolean; +} + +export interface ExperimentsSelectorOptions { + readonly features: readonly ExperimentalFeatureState[]; + readonly colors: ColorPalette; + readonly onApply: (changes: readonly ExperimentalFeatureDraftChange[]) => void; + readonly onCancel: () => void; +} + +export class ExperimentsSelectorComponent extends Container implements Focusable { + focused = false; + + private readonly opts: ExperimentsSelectorOptions; + private readonly list: SearchableList; + private readonly draft = new Map(); + + constructor(opts: ExperimentsSelectorOptions) { + super(); + this.opts = opts; + this.list = new SearchableList({ + items: opts.features, + toSearchText: (feature) => `${feature.title} ${feature.id} ${feature.description}`, + searchable: true, + }); + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + if (this.list.clearQuery()) return; + this.opts.onCancel(); + return; + } + if (matchesKey(data, Key.enter)) { + const changes = this.draftChanges(); + if (changes.length > 0) this.opts.onApply(changes); + return; + } + const decoded = printableChar(data); + if (matchesKey(data, Key.space) || decoded === ' ') { + const selected = this.list.selected(); + if (selected !== undefined) this.toggleDraft(selected); + return; + } + this.list.handleKey(data); + } + + override render(width: number): string[] { + const { colors } = this.opts; + const view = this.list.view(); + const titleSuffix = + view.query.length === 0 ? chalk.hex(colors.textMuted)(' (type to search)') : ''; + const hintParts = ['↑↓ navigate']; + if (view.page.pageCount > 1) hintParts.push('PgUp/PgDn page'); + hintParts.push('Space toggle', 'Enter apply', 'Esc cancel'); + if (view.query.length > 0) hintParts.push('Backspace clear'); + + const lines: string[] = [ + chalk.hex(colors.primary)('─'.repeat(width)), + chalk.hex(colors.primary).bold(' Experimental features') + titleSuffix, + chalk.hex(colors.textMuted)(` ${hintParts.join(' · ')}`), + '', + ]; + + if (view.query.length > 0) { + lines.push(chalk.hex(colors.primary)(` Search: `) + chalk.hex(colors.text)(view.query)); + } + + if (view.items.length === 0) { + lines.push(chalk.hex(colors.textMuted)(' No matches')); + } + + for (let i = view.page.start; i < view.page.end; i++) { + const feature = view.items[i]!; + const selected = i === view.selectedIndex; + lines.push(...this.renderFeature(feature, selected, width)); + } + + lines.push(''); + if (view.query.length > 0) { + lines.push( + chalk.hex(colors.textMuted)( + ` ${String(view.items.length)} / ${String(this.opts.features.length)}`, + ), + ); + } else if (view.page.end < view.items.length) { + lines.push( + chalk.hex(colors.textMuted)( + ` ▼ ${String(view.items.length - view.page.end)} more`, + ), + ); + } + lines.push(this.renderApplyButton()); + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); + } + + private toggleDraft(feature: ExperimentalFeatureState): void { + if (isLocked(feature)) return; + + const enabled = !this.effectiveEnabled(feature); + if (enabled === feature.enabled) { + this.draft.delete(feature.id); + return; + } + this.draft.set(feature.id, enabled); + } + + private effectiveEnabled(feature: ExperimentalFeatureState): boolean { + return this.draft.get(feature.id) ?? feature.enabled; + } + + private isDraftChanged(feature: ExperimentalFeatureState): boolean { + return this.effectiveEnabled(feature) !== feature.enabled; + } + + private draftChanges(): ExperimentalFeatureDraftChange[] { + const changes: ExperimentalFeatureDraftChange[] = []; + for (const feature of this.opts.features) { + if (this.isDraftChanged(feature)) { + changes.push({ id: feature.id, enabled: this.effectiveEnabled(feature) }); + } + } + return changes; + } + + private renderApplyButton(): string { + const { colors } = this.opts; + const changes = this.draftChanges(); + const count = changes.length; + const label = '[ Apply changes and reload ]'; + const summary = + count === 0 ? 'no changes' : `${String(count)} ${count === 1 ? 'change' : 'changes'}`; + const buttonStyle = count === 0 ? chalk.hex(colors.textDim) : chalk.hex(colors.primary).bold; + const summaryStyle = count === 0 ? chalk.hex(colors.textMuted) : chalk.hex(colors.success); + return ` ${buttonStyle(label)} ${summaryStyle(summary)}`; + } + + private renderFeature( + feature: ExperimentalFeatureState, + selected: boolean, + width: number, + ): string[] { + const { colors } = this.opts; + const pointer = selected ? SELECT_POINTER : ' '; + const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); + const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + const enabled = this.effectiveEnabled(feature); + const status = enabled ? 'enabled' : 'disabled'; + const statusStyle = enabled ? chalk.hex(colors.success) : chalk.hex(colors.textDim); + const detail = this.isDraftChanged(feature) + ? `${featureDetail(feature)} · modified` + : featureDetail(feature); + const lines = [ + `${prefix}${labelStyle(feature.title)} ${statusStyle(status)}`, + chalk.hex(colors.textMuted)(` ${detail}`), + ]; + const descriptionWidth = Math.max(1, width - 4); + for (const line of wrapText(feature.description, descriptionWidth)) { + lines.push(chalk.hex(colors.textMuted)(` ${line}`)); + } + return lines; + } +} + +function isLocked(feature: ExperimentalFeatureState): boolean { + return feature.source === 'env' || feature.source === 'master-env'; +} + +function featureDetail(feature: ExperimentalFeatureState): string { + const source = sourceLabel(feature); + if (feature.source === 'env' || feature.source === 'master-env') { + return `id ${feature.id} · ${source}`; + } + return `id ${feature.id} · ${source} · ${feature.env}`; +} + +function sourceLabel(feature: ExperimentalFeatureState): string { + switch (feature.source) { + case 'master-env': + return 'locked by KIMI_CODE_EXPERIMENTAL_FLAG'; + case 'env': + return `locked by ${feature.env}`; + case 'config': + return 'config'; + case 'default': + return 'default'; + } +} + +function wrapText(text: string, width: number): string[] { + const maxWidth = Math.max(1, width); + const words = text + .trim() + .split(/\s+/) + .filter((word) => word.length > 0); + const lines: string[] = []; + let current = ''; + + for (const word of words) { + const candidate = current.length === 0 ? word : `${current} ${word}`; + if (visibleWidth(candidate) <= maxWidth) { + current = candidate; + continue; + } + if (current.length > 0) lines.push(current); + current = visibleWidth(word) <= maxWidth ? word : truncateToWidth(word, maxWidth, ELLIPSIS); + } + + if (current.length > 0) lines.push(current); + return lines; +} diff --git a/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts b/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts index 339cbc90c7..3e6e426919 100644 --- a/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts @@ -7,6 +7,7 @@ export type SettingsSelection = | 'theme' | 'editor' | 'permission' + | 'experiments' | 'upgrade' | 'usage'; @@ -31,6 +32,11 @@ const SETTINGS_OPTIONS: readonly ChoiceOption[] = [ label: 'Editor', description: 'Set the external editor command.', }, + { + value: 'experiments', + label: 'Experiments', + description: 'Turn experimental features on or off.', + }, { value: 'upgrade', label: 'Automatic updates', @@ -49,6 +55,7 @@ function isSettingsSelection(value: string): value is SettingsSelection { value === 'theme' || value === 'editor' || value === 'permission' || + value === 'experiments' || value === 'upgrade' || value === 'usage' ); diff --git a/apps/kimi-code/src/tui/components/index.ts b/apps/kimi-code/src/tui/components/index.ts index bcff5b2ac2..c8035dfb8d 100644 --- a/apps/kimi-code/src/tui/components/index.ts +++ b/apps/kimi-code/src/tui/components/index.ts @@ -7,6 +7,7 @@ export * from './dialogs/approval-panel'; export * from './dialogs/choice-picker'; export * from './dialogs/compaction'; export * from './dialogs/editor-selector'; +export * from './dialogs/experiments-selector'; export * from './dialogs/help-panel'; export * from './dialogs/model-selector'; export * from './dialogs/permission-selector'; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index df7b5db073..ffd45a23e8 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -331,6 +331,10 @@ export class KimiTUI { this.state.editor.setAutocompleteProvider(provider); } + refreshSlashCommandAutocomplete(): void { + this.setupAutocomplete(); + } + async refreshSkillCommands(session?: SkillListSession): Promise { if (session === undefined) { this.skillCommands = []; @@ -1015,7 +1019,7 @@ export class KimiTUI { async syncRuntimeState(session: Session = this.requireSession()): Promise { const [status, goalResult] = await Promise.all([ session.getStatus(), - isExperimentalFlagEnabled('goal-command') + isExperimentalFlagEnabled('goal_command') ? session.getGoal() : Promise.resolve({ goal: null }), ]); diff --git a/apps/kimi-code/test/cli/goal-prompt.test.ts b/apps/kimi-code/test/cli/goal-prompt.test.ts index 3a5bcf9a4c..53535bdd1c 100644 --- a/apps/kimi-code/test/cli/goal-prompt.test.ts +++ b/apps/kimi-code/test/cli/goal-prompt.test.ts @@ -110,7 +110,7 @@ const mocks = vi.hoisted(() => { session, eventHandlers, mainEvent, - experimentalFlags: { 'goal-command': true } as Record, + experimentalFlags: { 'goal_command': true } as Record, sessions: [] as Array<{ readonly id: string; readonly workDir: string }>, }; }); @@ -168,7 +168,7 @@ describe('runPrompt headless goal mode', () => { beforeEach(() => { savedExitCode = process.exitCode; - mocks.experimentalFlags = { 'goal-command': true }; + mocks.experimentalFlags = { 'goal_command': true }; mocks.sessions = []; mocks.session.createGoal.mockClear(); mocks.session.getStatus.mockResolvedValue({ permission: 'auto', model: 'k2' } as never); diff --git a/apps/kimi-code/test/tui/commands/experiments.test.ts b/apps/kimi-code/test/tui/commands/experiments.test.ts new file mode 100644 index 0000000000..307b3988ce --- /dev/null +++ b/apps/kimi-code/test/tui/commands/experiments.test.ts @@ -0,0 +1,119 @@ +import type { ExperimentalFeatureState } from '@moonshot-ai/kimi-code-sdk'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { SlashCommandHost } from '#/tui/commands'; +import { + applyExperimentalFeatureChanges, +} from '#/tui/commands/config'; +import { + isExperimentalFlagEnabled, + setExperimentalFlags, +} from '#/tui/commands/experimental-flags'; +import { darkColors } from '#/tui/theme/colors'; + +function feature( + overrides: Partial = {}, +): ExperimentalFeatureState { + return { + id: 'goal_command', + title: 'Goal command', + description: 'Enable goal mode.', + surface: 'both', + env: 'KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND', + defaultEnabled: false, + enabled: false, + source: 'default', + ...overrides, + }; +} + +function makeHost() { + const session = { + id: 'ses-experiments', + reloadSession: vi.fn(async () => ({})), + }; + const host = { + state: { + theme: { colors: darkColors }, + ui: { requestRender: vi.fn() }, + }, + harness: { + setConfig: vi.fn(async () => ({ providers: {} })), + getExperimentalFlags: vi.fn(async () => ({ 'goal_command': true })), + getExperimentalFeatures: vi.fn(async () => [ + feature({ enabled: true, source: 'config', configValue: true }), + ]), + }, + session, + refreshSlashCommandAutocomplete: vi.fn(), + reloadCurrentSessionView: vi.fn(async () => {}), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + showStatus: vi.fn(), + showError: vi.fn(), + track: vi.fn(), + } as unknown as SlashCommandHost & { + harness: { + setConfig: ReturnType; + getExperimentalFlags: ReturnType; + getExperimentalFeatures: ReturnType; + }; + refreshSlashCommandAutocomplete: ReturnType; + reloadCurrentSessionView: ReturnType; + mountEditorReplacement: ReturnType; + restoreEditor: ReturnType; + showStatus: ReturnType; + showError: ReturnType; + track: ReturnType; + session: typeof session; + }; + return host; +} + +describe('experimental feature command handlers', () => { + afterEach(() => { + setExperimentalFlags({}); + }); + + it('persists config overrides, refreshes command flags, closes the panel, and reloads', async () => { + const host = makeHost(); + + await applyExperimentalFeatureChanges(host, [ + { id: 'goal_command', enabled: true }, + ]); + + expect(host.harness.setConfig).toHaveBeenCalledWith({ + experimental: { 'goal_command': true }, + }); + expect(host.harness.getExperimentalFlags).toHaveBeenCalled(); + expect(host.harness.getExperimentalFeatures).not.toHaveBeenCalled(); + expect(isExperimentalFlagEnabled('goal_command')).toBe(true); + expect(host.refreshSlashCommandAutocomplete).toHaveBeenCalled(); + expect(host.restoreEditor).toHaveBeenCalled(); + expect(host.session.reloadSession).toHaveBeenCalledOnce(); + expect(host.reloadCurrentSessionView).toHaveBeenCalledWith( + host.session, + 'Experimental features updated. Session reloaded.', + ); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + expect(host.track).toHaveBeenCalledWith('experimental_features_apply', { + changed: 1, + }); + expect(host.showStatus).not.toHaveBeenCalledWith( + 'Experimental features updated.', + darkColors.success, + ); + }); + + it('does not write config when there are no drafted changes', async () => { + const host = makeHost(); + + await applyExperimentalFeatureChanges(host, []); + + expect(host.harness.setConfig).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith( + 'No experimental feature changes to apply.', + darkColors.textMuted, + ); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/goal.test.ts b/apps/kimi-code/test/tui/commands/goal.test.ts index 2ab78e0427..aa95c446a4 100644 --- a/apps/kimi-code/test/tui/commands/goal.test.ts +++ b/apps/kimi-code/test/tui/commands/goal.test.ts @@ -407,7 +407,7 @@ describe('dispatchInput /goal integration', () => { }); it('routes /goal through the real resolver, creates the goal, and sends the objective', async () => { - setExperimentalFlags({ 'goal-command': true }); + setExperimentalFlags({ 'goal_command': true }); const { host, session } = makeHost(); dispatchInput(host, '/goal Ship feature X'); diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index ae9eeb8f62..39ab645c17 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -73,10 +73,10 @@ describe('built-in slash command registry', () => { ]); }); - it('registers goal behind the goal-command flag with subcommand-aware availability', () => { + it('registers goal behind the goal_command flag with subcommand-aware availability', () => { const goal = findBuiltInSlashCommand('goal'); expect(goal).toBeDefined(); - expect((goal as KimiSlashCommand).experimentalFlag).toBe('goal-command'); + expect((goal as KimiSlashCommand).experimentalFlag).toBe('goal_command'); expect(resolveSlashCommandAvailability(goal!, '')).toBe('always'); expect(resolveSlashCommandAvailability(goal!, 'status')).toBe('always'); expect(resolveSlashCommandAvailability(goal!, 'pause')).toBe('always'); diff --git a/apps/kimi-code/test/tui/commands/reload.test.ts b/apps/kimi-code/test/tui/commands/reload.test.ts index 717297df10..dea06f0968 100644 --- a/apps/kimi-code/test/tui/commands/reload.test.ts +++ b/apps/kimi-code/test/tui/commands/reload.test.ts @@ -9,11 +9,16 @@ import { handleReloadTuiCommand, } from '#/tui/commands/reload'; import type { SlashCommandHost } from '#/tui/commands'; +import { + isExperimentalFlagEnabled, + setExperimentalFlags, +} from '#/tui/commands/experimental-flags'; const tempDirs: string[] = []; const originalKimiCodeHome = process.env['KIMI_CODE_HOME']; afterEach(async () => { + setExperimentalFlags({}); for (const dir of tempDirs.splice(0)) { await rm(dir, { recursive: true, force: true }); } @@ -45,6 +50,7 @@ auto_install = false await handleReloadTuiCommand(host); expect(host.harness.getConfig).not.toHaveBeenCalled(); + expect(host.harness.getExperimentalFlags).not.toHaveBeenCalled(); expect(session.reloadSession).not.toHaveBeenCalled(); expect(host.state.appState).toMatchObject({ theme: 'light', @@ -71,6 +77,9 @@ auto_install = false 'Session reloaded.', ); expect(host.harness.getConfig).toHaveBeenCalledWith({ reload: true }); + expect(host.harness.getExperimentalFlags).toHaveBeenCalledOnce(); + expect(host.refreshSlashCommandAutocomplete).toHaveBeenCalledOnce(); + expect(isExperimentalFlagEnabled('goal_command')).toBe(true); expect(host.state.appState.theme).toBe('light'); expect(host.state.appState.availableModels).toEqual({ fresh: { provider: 'test', model: 'fresh-model', maxContextSize: 1000 }, @@ -119,6 +128,7 @@ function makeHost({ test: { type: 'kimi', apiKey: 'test-key' }, }, })), + getExperimentalFlags: vi.fn(async () => ({ goal_command: true })), }, setAppState: vi.fn((patch: Record) => { Object.assign(state.appState, patch); @@ -127,12 +137,15 @@ function makeHost({ state.appState.theme = theme; }), refreshTerminalThemeTracking: vi.fn(), + refreshSlashCommandAutocomplete: vi.fn(), reloadCurrentSessionView: vi.fn(async () => {}), showStatus: vi.fn(), } as unknown as SlashCommandHost & { readonly harness: { readonly getConfig: ReturnType; + readonly getExperimentalFlags: ReturnType; }; + readonly refreshSlashCommandAutocomplete: ReturnType; readonly reloadCurrentSessionView: ReturnType; readonly showStatus: ReturnType; }; diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts index 15bdbbeab7..07344bad9e 100644 --- a/apps/kimi-code/test/tui/commands/resolve.test.ts +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -46,6 +46,11 @@ describe('resolveSlashCommandInput', () => { name: 'btw', args: 'what are you doing?', }); + expect(resolve('/experiments')).toMatchObject({ + kind: 'builtin', + name: 'experiments', + args: '', + }); }); it('blocks idle-only built-ins while streaming', () => { @@ -125,6 +130,11 @@ describe('resolveSlashCommandInput', () => { name: 'reload-tui', args: '', }); + expect(resolve('/experiments', { isStreaming: true })).toMatchObject({ + kind: 'builtin', + name: 'experiments', + args: '', + }); expect(resolve('/btw side question', { isStreaming: true })).toMatchObject({ kind: 'builtin', name: 'btw', @@ -170,8 +180,8 @@ describe('goal command resolution', () => { setExperimentalFlags({}); }); - it('resolves /goal to the builtin command when goal-command is enabled', () => { - setExperimentalFlags({ 'goal-command': true }); + it('resolves /goal to the builtin command when goal_command is enabled', () => { + setExperimentalFlags({ 'goal_command': true }); expect(resolve('/goal Ship feature X')).toMatchObject({ kind: 'builtin', name: 'goal', @@ -179,7 +189,7 @@ describe('goal command resolution', () => { }); }); - it('treats /goal as a normal message when goal-command is disabled', () => { + it('treats /goal as a normal message when goal_command is disabled', () => { setExperimentalFlags({}); expect(resolve('/goal Ship feature X')).toEqual({ kind: 'message', @@ -188,7 +198,7 @@ describe('goal command resolution', () => { }); it('blocks goal creation while streaming', () => { - setExperimentalFlags({ 'goal-command': true }); + setExperimentalFlags({ 'goal_command': true }); expect(resolve('/goal Ship feature X', { isStreaming: true })).toEqual({ kind: 'blocked', commandName: 'goal', @@ -197,7 +207,7 @@ describe('goal command resolution', () => { }); it('does not block status/pause/cancel/bare goal while streaming', () => { - setExperimentalFlags({ 'goal-command': true }); + setExperimentalFlags({ 'goal_command': true }); for (const sub of ['status', 'pause', 'cancel']) { expect(resolve(`/goal ${sub}`, { isStreaming: true })).toMatchObject({ kind: 'builtin', diff --git a/apps/kimi-code/test/tui/components/dialogs/experiments-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/experiments-selector.test.ts new file mode 100644 index 0000000000..96fd9526bb --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/experiments-selector.test.ts @@ -0,0 +1,155 @@ +import type { ExperimentalFeatureState } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { + ExperimentsSelectorComponent, + type ExperimentalFeatureDraftChange, +} from '#/tui/components/dialogs/experiments-selector'; +import { darkColors } from '#/tui/theme/colors'; + +const ANSI = /\u001B\[[0-9;]*m/g; +const ESC = String.fromCodePoint(27); +const DOWN = `${ESC}[B`; +const ENTER = '\r'; + +function strip(text: string): string { + return text.replaceAll(ANSI, ''); +} + +function feature( + overrides: Partial = {}, +): ExperimentalFeatureState { + return { + id: 'goal_command', + title: 'Goal command', + description: 'Enable goal mode.', + surface: 'both', + env: 'KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND', + defaultEnabled: false, + enabled: false, + source: 'default', + ...overrides, + }; +} + +function text(component: ExperimentsSelectorComponent, width = 120): string { + return component.render(width).map(strip).join('\n'); +} + +describe('ExperimentsSelectorComponent', () => { + it('renders searchable feature toggles with source details', () => { + const selector = new ExperimentsSelectorComponent({ + features: [ + feature({ enabled: true, source: 'config', configValue: true }), + feature({ + id: 'background_ask', + title: 'Background questions', + description: 'Ask questions without blocking the current turn.', + env: 'KIMI_CODE_EXPERIMENTAL_BACKGROUND_ASK', + enabled: false, + source: 'env', + }), + ], + colors: darkColors, + onApply: vi.fn(), + onCancel: vi.fn(), + }); + + const out = text(selector); + + expect(out).toContain(' Experimental features (type to search)'); + expect(out).toContain(' ↑↓ navigate · Space toggle · Enter apply · Esc cancel'); + expect(out).toContain(' ❯ Goal command enabled'); + expect(out).toContain(' id goal_command · config · KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND'); + expect(out).toContain(' Enable goal mode.'); + expect(out).toContain(' Background questions disabled'); + expect(out).toContain(' id background_ask · locked by KIMI_CODE_EXPERIMENTAL_BACKGROUND_ASK'); + expect(out).toContain(' [ Apply changes and reload ] no changes'); + }); + + it('drafts changes with Space and applies them with Enter', () => { + const onApply = vi.fn<(changes: readonly ExperimentalFeatureDraftChange[]) => void>(); + const first = feature({ id: 'goal_command', title: 'Goal command' }); + const second = feature({ + id: 'micro_compaction', + title: 'Micro compaction', + env: 'KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION', + }); + const selector = new ExperimentsSelectorComponent({ + features: [first, second], + colors: darkColors, + onApply, + onCancel: vi.fn(), + }); + + selector.handleInput(' '); + + expect(onApply).not.toHaveBeenCalled(); + expect(text(selector)).toContain(' ❯ Goal command enabled'); + expect(text(selector)).toContain( + ' id goal_command · default · KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND · modified', + ); + expect(text(selector)).toContain(' [ Apply changes and reload ] 1 change'); + + selector.handleInput(DOWN); + selector.handleInput(' '); + selector.handleInput(ENTER); + + expect(onApply).toHaveBeenCalledWith([ + { id: 'goal_command', enabled: true }, + { id: 'micro_compaction', enabled: true }, + ]); + }); + + it('does not draft changes for env-locked features', () => { + const onApply = vi.fn<(changes: readonly ExperimentalFeatureDraftChange[]) => void>(); + const selector = new ExperimentsSelectorComponent({ + features: [ + feature({ + enabled: true, + source: 'env', + }), + ], + colors: darkColors, + onApply, + onCancel: vi.fn(), + }); + + selector.handleInput(' '); + selector.handleInput(ENTER); + + expect(text(selector)).toContain(' ❯ Goal command enabled'); + expect(text(selector)).toContain(' [ Apply changes and reload ] no changes'); + expect(onApply).not.toHaveBeenCalled(); + }); + + it('filters by typing and clears the query before cancelling', () => { + const onCancel = vi.fn(); + const selector = new ExperimentsSelectorComponent({ + features: [ + feature({ id: 'goal_command', title: 'Goal command' }), + feature({ + id: 'background_ask', + title: 'Background questions', + env: 'KIMI_CODE_EXPERIMENTAL_BACKGROUND_ASK', + }), + ], + colors: darkColors, + onApply: vi.fn(), + onCancel, + }); + + selector.handleInput('b'); + selector.handleInput('a'); + selector.handleInput('c'); + selector.handleInput('k'); + expect(text(selector)).toContain('Search: back'); + expect(text(selector)).toContain('Background questions'); + expect(text(selector)).not.toContain('Goal command'); + + selector.handleInput(ESC); + expect(onCancel).not.toHaveBeenCalled(); + selector.handleInput(ESC); + expect(onCancel).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index ca4874ae78..4156ca2eaa 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -265,7 +265,7 @@ describe("KimiTUI startup", () => { }); const harness = makeHarness(session, { listSessions: vi.fn(async () => [{ id: "ses-latest" }]), - getExperimentalFlags: vi.fn(async () => ({ "goal-command": true })), + getExperimentalFlags: vi.fn(async () => ({ "goal_command": true })), }); const driver = makeDriver(harness, makeStartupInput({ continue: true })); @@ -294,7 +294,7 @@ describe("KimiTUI startup", () => { getGoal: vi.fn(async () => ({ goal })), }); const harness = makeHarness(session, { - getExperimentalFlags: vi.fn(async () => ({ "goal-command": true })), + getExperimentalFlags: vi.fn(async () => ({ "goal_command": true })), }); const driver = makeDriver(harness, makeStartupInput()) as unknown as RuntimeStateDriver; diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index df2198acaa..a29489977f 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -52,6 +52,11 @@ max_running_tasks = 4 keep_alive_on_exit = false agent_task_timeout_s = 900 +[experimental] +goal_command = false +micro_compaction = false +background_ask = false + [[permission.rules]] decision = "allow" pattern = "Read" @@ -85,11 +90,12 @@ Fields in the config file fall into two categories: **top-level scalars** that d | `thinking` | `table` | — | Default parameters for Thinking mode → [`thinking`](#thinking) | | `loop_control` | `table` | — | Agent loop control parameters → [`loop_control`](#loop_control) | | `background` | `table` | — | Background task runtime parameters → [`background`](#background) | +| `experimental` | `table` | — | Persistent experimental feature toggles → [`experimental`](#experimental) | | `services` | `table` | — | Built-in external service configuration → [`services`](#services) | | `permission` | `table` | — | Initial permission rules → [`permission`](#permission) | | `hooks` | `array` | — | Lifecycle hooks; see [Hooks](../customization/hooks.md) | -The following sections cover each of the seven nested tables in turn: `providers`, `models`, `thinking`, `loop_control`, `background`, `services`, and `permission`. +The following sections cover each of the nested tables in turn: `providers`, `models`, `thinking`, `loop_control`, `background`, `experimental`, `services`, and `permission`. ## `providers` @@ -171,6 +177,18 @@ You can also switch models temporarily without touching the config file — by s `keep_alive_on_exit` can be overridden by the `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` environment variable, which takes higher priority than `config.toml`. +## `experimental` + +`experimental` stores persistent opt-ins for features that are not public by default yet. You can edit this table directly or run `/experiments` in the TUI. The TUI panel stages changes locally until you confirm them, then writes `config.toml` and reloads the current session. Each TOML key is the experimental flag ID, for example `goal_command`. + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `goal_command` | `boolean` | `false` | Enable `/goal` and goal-management tools | +| `micro_compaction` | `boolean` | `false` | Trim older large tool results from context while preserving recent conversation | +| `background_ask` | `boolean` | `false` | Allow `AskUserQuestion` to start a background question task when the Agent can continue working | + +Environment variables take priority over this table. `KIMI_CODE_EXPERIMENTAL_` overrides one feature, and `KIMI_CODE_EXPERIMENTAL_FLAG=1` enables all experimental features for that process. When a feature is controlled by the environment, `/experiments` shows it as locked. + ## `services` `services` configures two built-in services: web search (`moonshot_search`) and web fetch (`moonshot_fetch`). Only these two fixed keys are recognized; other keys are ignored. Both entries share the same fields: diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index e147682087..6ae65276c5 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -125,6 +125,10 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_DISABLE_TELEMETRY` | Disable anonymous telemetry reporting | `1`, `true`, `yes`, `y` (case-insensitive) | | `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | Whether to keep background tasks when the session closes; takes higher priority than `config.toml` | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | Override the plugin marketplace JSON loaded by `/plugins` | URL or local path | +| `KIMI_CODE_EXPERIMENTAL_FLAG` | Enable all experimental features for this process; takes higher priority than `[experimental]` in `config.toml` | `1`, `true`, `yes`, `on` | +| `KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND` | Override `[experimental].goal_command` for this process | Truthy or falsy | +| `KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION` | Override `[experimental].micro_compaction` for this process | Truthy or falsy | +| `KIMI_CODE_EXPERIMENTAL_BACKGROUND_ASK` | Override `[experimental].background_ask` for this process | Truthy or falsy | | `KIMI_SHELL_PATH` | Override the Git Bash path on Windows (used when auto-detection fails) | Absolute path | | `KIMI_MODEL_MAX_COMPLETION_TOKENS` | Hard cap on `max_completion_tokens` per LLM step; applies to the `kimi` provider only | Positive integer; `0` or negative disables clamping | | `KIMI_DISABLE_CRON` | Disable the scheduled-task tool (`CronCreate` rejects new schedules; existing tasks do not fire) | `1` to disable | diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 19edfd5e53..88b188a156 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -17,6 +17,7 @@ Some commands are only available in the idle state. Executing these commands whi | `/provider` | — | Open the interactive provider manager to view, add, and remove configured providers. See [Platforms & Models — `/provider` and provider management](../configuration/providers.md#provider-与供应商管理) | Yes | | `/model` | — | Switch the LLM model used in the current session | Yes | | `/settings` | `/config` | Open the settings panel inside the TUI | Yes | +| `/experiments` | `/experimental` | Open the experimental feature panel. Confirm changes to persist them to `config.toml` and reload the current session | Yes | | `/permission` | — | Select a permission mode | Yes | | `/editor` | — | Configure the external editor launched by `Ctrl-G` | Yes | | `/theme` | — | Switch the terminal UI color theme | Yes | @@ -45,7 +46,7 @@ Some commands are only available in the idle state. Executing these commands whi | `/auto [on\|off]` | — | Toggle auto permission mode. When enabled, tool approvals are handled automatically and the Agent will not ask the user questions | Yes | | `/plan [on\|off]` | — | Toggle Plan mode. Without arguments, flips the current state; explicitly passing `on`/`off` forces the setting. Simply toggling does not create an empty plan file | Yes | | `/plan clear` | — | Clear the current plan | No | -| `/goal [...]` | — | Start or manage an autonomous goal (experimental feature, requires `KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1`) | See below | +| `/goal [...]` | — | Start or manage an autonomous goal (experimental feature; enable it from `/experiments`, `[experimental].goal_command`, or `KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1`) | See below | ::: warning `/yolo` skips approval for regular tool calls. Please make sure you understand the potential risks before enabling it. Plan mode exit approval is not bypassed by `/yolo`; `Bash` inside Plan mode is still subject to the regular `/yolo` allow rules. @@ -54,7 +55,13 @@ Some commands are only available in the idle state. Executing these commands whi ## Autonomous Goal (Experimental) ::: info -`/goal` is an experimental command. Enable it by setting an environment variable when starting `kimi`: +`/goal` is an experimental command. Enable it from `/experiments`, or write it in `~/.kimi-code/config.toml`: +```toml +[experimental] +goal_command = true +``` + +You can also override the setting for one process with an environment variable: ```sh KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1 kimi ``` diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index bb04fb5898..6995237278 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -52,6 +52,11 @@ max_running_tasks = 4 keep_alive_on_exit = false agent_task_timeout_s = 900 +[experimental] +goal_command = false +micro_compaction = false +background_ask = false + [[permission.rules]] decision = "allow" pattern = "Read" @@ -85,11 +90,12 @@ timeout = 5 | `thinking` | `table` | — | Thinking 模式默认参数 → [`thinking`](#thinking) | | `loop_control` | `table` | — | Agent 循环控制参数 → [`loop_control`](#loop_control) | | `background` | `table` | — | 后台任务运行参数 → [`background`](#background) | +| `experimental` | `table` | — | 持久化实验功能开关 → [`experimental`](#experimental) | | `services` | `table` | — | 内置外部服务配置 → [`services`](#services) | | `permission` | `table` | — | 初始权限规则 → [`permission`](#permission) | | `hooks` | `array
` | — | 生命周期 hook,详见 [Hooks](../customization/hooks.md) | -以下各节对 `providers`、`models`、`thinking`、`loop_control`、`background`、`services`、`permission` 七个嵌套表逐一展开。 +以下各节对 `providers`、`models`、`thinking`、`loop_control`、`background`、`experimental`、`services`、`permission` 等嵌套表逐一展开。 ## `providers` @@ -171,6 +177,18 @@ max_context_size = 1047576 `keep_alive_on_exit` 可被环境变量 `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` 覆盖,优先级高于配置文件。 +## `experimental` + +`experimental` 存放尚未默认公开的功能开关。可以直接编辑这个表,也可以在 TUI 中运行 `/experiments`。TUI 面板会先暂存选择,确认后写入 `config.toml` 并重载当前会话。每个 TOML key 就是实验 flag ID,例如 `goal_command`。 + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `goal_command` | `boolean` | `false` | 启用 `/goal` 和 goal 管理工具 | +| `micro_compaction` | `boolean` | `false` | 清理较旧的大型工具结果内容,同时保留最近对话 | +| `background_ask` | `boolean` | `false` | 允许 `AskUserQuestion` 在 Agent 可以继续工作时启动后台提问任务 | + +环境变量优先级高于这个表。`KIMI_CODE_EXPERIMENTAL_` 可以覆盖单个功能,`KIMI_CODE_EXPERIMENTAL_FLAG=1` 会在当前进程启用所有实验功能。某个功能被环境变量控制时,`/experiments` 会显示为 locked。 + ## `services` `services` 配置网页搜索(`moonshot_search`)和网页抓取(`moonshot_fetch`)两项内置服务。只识别这两个固定 key,其他 key 会被忽略。两项字段相同: diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index f668d76148..ca45fbf5b8 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -125,6 +125,10 @@ kimi | `KIMI_DISABLE_TELEMETRY` | 关闭匿名遥测上报 | `1`、`true`、`yes`、`y`(不区分大小写) | | `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | 会话关闭时是否保留后台任务,优先级高于 `config.toml` | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | 替换 `/plugins` 加载的 marketplace JSON | URL 或本地路径 | +| `KIMI_CODE_EXPERIMENTAL_FLAG` | 在当前进程启用所有实验功能,优先级高于 `config.toml` 的 `[experimental]` | `1`、`true`、`yes`、`on` | +| `KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND` | 覆盖当前进程的 `[experimental].goal_command` | 真值或假值 | +| `KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION` | 覆盖当前进程的 `[experimental].micro_compaction` | 真值或假值 | +| `KIMI_CODE_EXPERIMENTAL_BACKGROUND_ASK` | 覆盖当前进程的 `[experimental].background_ask` | 真值或假值 | | `KIMI_SHELL_PATH` | Windows 上覆盖 Git Bash 路径(自动探测失败时使用) | 绝对路径 | | `KIMI_MODEL_MAX_COMPLETION_TOKENS` | 单步 LLM 请求的 `max_completion_tokens` 硬上限,仅对 `kimi` 供应商生效 | 正整数;`0` 或负数禁用 clamp | | `KIMI_DISABLE_CRON` | 禁用定时任务工具(`CronCreate` 拒绝新计划,已有任务不触发) | `1` 表示禁用 | diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 275a52c4f3..c528c1f078 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -17,6 +17,7 @@ | `/provider` | — | 打开交互式供应商管理器,查看、添加和删除已配置的供应商。详见[平台与模型 — `/provider` 与供应商管理](../configuration/providers.md#provider-与供应商管理) | 是 | | `/model` | — | 切换当前会话使用的 LLM 模型 | 是 | | `/settings` | `/config` | 打开 TUI 内的设置面板 | 是 | +| `/experiments` | `/experimental` | 打开实验功能面板。确认后把变更持久化到 `config.toml` 并重载当前会话 | 是 | | `/permission` | — | 选择权限模式 | 是 | | `/editor` | — | 配置 `Ctrl-G` 调起的外部编辑器 | 是 | | `/theme` | — | 切换终端 UI 配色主题 | 是 | @@ -43,7 +44,7 @@ | `/auto [on\|off]` | — | 切换 auto 权限模式。开启后工具审批自动处理,Agent 不会向用户提问 | 是 | | `/plan [on\|off]` | — | 切换 Plan 模式。不带参数时翻转;显式传 `on`/`off` 时强制设置。单纯切换不会创建空计划文件 | 是 | | `/plan clear` | — | 清除当前 plan 方案 | 否 | -| `/goal [...]` | — | 开始或管理一个自主 goal(实验功能,需启用 `KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1`) | 见下文 | +| `/goal [...]` | — | 开始或管理一个自主 goal(实验功能;可通过 `/experiments`、`[experimental].goal_command` 或 `KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1` 启用) | 见下文 | ::: warning 注意 `/yolo` 会跳过普通工具调用的审批确认,使用前请确保了解可能的风险。Plan 模式的退出审批不会被 `/yolo` 跳过;Plan 模式下的 `Bash` 也按 `/yolo` 的普通放行规则处理。 @@ -52,7 +53,13 @@ ## 自主 goal(实验功能) ::: info -`/goal` 是实验命令,需在启动 `kimi` 时设置环境变量启用: +`/goal` 是实验命令。可以通过 `/experiments` 启用,也可以写入 `~/.kimi-code/config.toml`: +```toml +[experimental] +goal_command = true +``` + +也可以用环境变量只覆盖当前进程: ```sh KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1 kimi ``` diff --git a/packages/agent-core/src/agent/compaction/micro.ts b/packages/agent-core/src/agent/compaction/micro.ts index 2dbfa5a529..da352db697 100644 --- a/packages/agent-core/src/agent/compaction/micro.ts +++ b/packages/agent-core/src/agent/compaction/micro.ts @@ -3,7 +3,6 @@ import type { ContentPart } from '@moonshot-ai/kosong'; import type { Agent } from '..'; import type { ContextMessage } from '../context'; import { estimateTokensForContentParts } from '../../utils/tokens'; -import { flags } from '../../flags'; export interface MicroCompactionConfig { keepRecentMessages: number; @@ -45,7 +44,7 @@ export class MicroCompaction { } detect(): void { - if (!flags.enabled('micro-compaction')) return; + if (!this.agent.experimentalFlags.enabled('micro_compaction')) return; const config = this.config; const { history, lastAssistantAt } = this.agent.context; @@ -78,7 +77,7 @@ export class MicroCompaction { } compact(messages: readonly ContextMessage[]): readonly ContextMessage[] { - if (!flags.enabled('micro-compaction')) return messages; + if (!this.agent.experimentalFlags.enabled('micro_compaction')) return messages; const config = this.config; const result: ContextMessage[] = []; diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index d465b2003b..d753c7e055 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -15,6 +15,7 @@ import { import type { EnabledPluginSessionStart } from '#/plugin'; import type { McpConnectionManager } from '../mcp'; +import { FlagResolver, type ExperimentalFlagResolver } from '../flags'; import type { PreparedSystemPromptContext, ResolvedAgentProfile } from '../profile'; import type { ModelProvider } from '../session/provider-manager'; import type { SessionGoalStore } from '../session/goal'; @@ -90,6 +91,7 @@ export interface AgentOptions { readonly telemetry?: TelemetryClient | undefined; readonly pluginSessionStarts?: readonly EnabledPluginSessionStart[]; readonly appVersion?: string; + readonly experimentalFlags?: ExperimentalFlagResolver; } export class Agent { @@ -109,6 +111,7 @@ export class Agent { readonly log: Logger; readonly telemetry: TelemetryClient; readonly appVersion?: string; + readonly experimentalFlags: ExperimentalFlagResolver; readonly blobStore: BlobStore | undefined; readonly records: AgentRecords; @@ -146,6 +149,7 @@ export class Agent { this.appVersion = options.appVersion; this.log = options.log ?? log; this.telemetry = options.telemetry ?? noopTelemetryClient; + this.experimentalFlags = options.experimentalFlags ?? new FlagResolver(); this.blobStore = options.homedir ? new BlobStore({ blobsDir: join(options.homedir, 'blobs') }) diff --git a/packages/agent-core/src/agent/injection/manager.ts b/packages/agent-core/src/agent/injection/manager.ts index d2a0492e7c..d95f0cddc6 100644 --- a/packages/agent-core/src/agent/injection/manager.ts +++ b/packages/agent-core/src/agent/injection/manager.ts @@ -1,5 +1,4 @@ import type { Agent } from '..'; -import { flags } from '../../flags'; import { GoalInjector } from './goal'; import type { DynamicInjector } from './injector'; import { PermissionModeInjector } from './permission-mode'; @@ -23,8 +22,7 @@ export class InjectionManager { new PlanModeInjector(agent), new PermissionModeInjector(agent), ]; - this.goalInjector = - flags.enabled('goal-command') && agent.type === 'main' ? new GoalInjector(agent) : null; + this.goalInjector = agent.type === 'main' ? new GoalInjector(agent) : null; } async inject(): Promise { @@ -39,7 +37,7 @@ export class InjectionManager { * mode is off, the agent is not the main agent, or there is nothing to inject. */ async injectGoal(): Promise { - await this.goalInjector?.inject(); + await this.activeGoalInjector()?.inject(); } onContextClear(): void { @@ -66,6 +64,12 @@ export class InjectionManager { /** Per-step injectors plus the boundary goal injector, for lifecycle events. */ private lifecycleInjectors(): DynamicInjector[] { - return this.goalInjector === null ? this.injectors : [this.goalInjector, ...this.injectors]; + const goalInjector = this.activeGoalInjector(); + return goalInjector === null ? this.injectors : [goalInjector, ...this.injectors]; + } + + private activeGoalInjector(): GoalInjector | null { + if (!this.agent.experimentalFlags.enabled('goal_command')) return null; + return this.goalInjector; } } diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index 845d2d8f8d..3bdc72c140 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -4,7 +4,6 @@ import picomatch from 'picomatch'; import type { Agent } from '..'; import { makeErrorPayload } from '../../errors'; -import { flags } from '../../flags'; import type { ExecutableTool } from '../../loop'; import { createMcpAuthTool } from '../../mcp/auth-tool'; import type { McpConnectionManager, McpServerEntry } from '../../mcp'; @@ -376,6 +375,8 @@ export class ToolManager { this.enabledTools.has('TaskList') && this.enabledTools.has('TaskOutput') && this.enabledTools.has('TaskStop'); + const goalCommandEnabled = + this.agent.experimentalFlags.enabled('goal_command') && this.agent.type === 'main'; this.builtinTools = new Map( [ new b.ReadTool(kaos, workspace), @@ -390,19 +391,11 @@ export class ToolManager { new b.ReadMediaFileTool(kaos, workspace, modelCapabilities, videoUploader), new b.EnterPlanModeTool(this.agent), new b.ExitPlanModeTool(this.agent), - // Goal tools are main-agent-only and gated by the goal-command flag. - flags.enabled('goal-command') && - this.agent.type === 'main' && - new b.CreateGoalTool(this.agent), - flags.enabled('goal-command') && - this.agent.type === 'main' && - new b.GetGoalTool(this.agent), - flags.enabled('goal-command') && - this.agent.type === 'main' && - new b.SetGoalBudgetTool(this.agent), - flags.enabled('goal-command') && - this.agent.type === 'main' && - new b.UpdateGoalTool(this.agent), + // Goal tools are main-agent-only and gated by the goal_command flag. + goalCommandEnabled && new b.CreateGoalTool(this.agent), + goalCommandEnabled && new b.GetGoalTool(this.agent), + goalCommandEnabled && new b.SetGoalBudgetTool(this.agent), + goalCommandEnabled && new b.UpdateGoalTool(this.agent), this.agent.rpc?.requestQuestion && new b.AskUserQuestionTool(this.agent), new b.TodoListTool(this.toolStore), new b.TaskListTool(background), diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index c68966778d..0fac303b92 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -15,7 +15,6 @@ import { import { basename } from 'pathe'; import type { Agent } from '..'; -import { flags } from '../../flags'; import { ErrorCodes, type KimiErrorPayload, @@ -110,7 +109,7 @@ export class TurnFlow { /** Whether goal-mode runtime behavior (continuation, abnormal-end marking) applies. */ private get goalRuntimeEnabled(): boolean { - return flags.enabled('goal-command') && this.agent.type === 'main'; + return this.agent.experimentalFlags.enabled('goal_command') && this.agent.type === 'main'; } // Returns the new turnId, or null if the turn was marked as resuming. diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index ae10f5c8f3..6c898b63a3 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -1,6 +1,7 @@ import { HOOK_EVENT_TYPES } from '../session/hooks/types'; import { parsePattern } from '#/agent/permission/matches-rule'; import { ErrorCodes, KimiError } from '#/errors'; +import { FLAG_DEFINITIONS, type FlagId } from '#/flags/registry'; import { z } from 'zod'; export const ProviderTypeSchema = z.enum([ @@ -105,6 +106,23 @@ export const BackgroundConfigSchema = z.object({ export type BackgroundConfig = z.infer; +const ExperimentalFlagIdSet = new Set(FLAG_DEFINITIONS.map((def) => def.id)); + +export const ExperimentalConfigSchema = z + .record(z.string(), z.boolean()) + .superRefine((config, ctx) => { + for (const key of Object.keys(config)) { + if (ExperimentalFlagIdSet.has(key)) continue; + ctx.addIssue({ + code: 'custom', + path: [key], + message: `Unknown experimental feature "${key}".`, + }); + } + }) as z.ZodType>>; + +export type ExperimentalConfig = z.infer; + export const HookDefSchema = z .object({ event: z.enum(HOOK_EVENT_TYPES), @@ -200,6 +218,7 @@ export const KimiConfigSchema = z.object({ extraSkillDirs: z.array(z.string()).optional(), loopControl: LoopControlSchema.optional(), background: BackgroundConfigSchema.optional(), + experimental: ExperimentalConfigSchema.optional(), telemetry: z.boolean().optional(), raw: z.record(z.string(), z.unknown()).optional(), }); @@ -212,6 +231,7 @@ const ThinkingConfigPatchSchema = ThinkingConfigSchema.partial(); const PermissionConfigPatchSchema = PermissionConfigSchema.partial(); const LoopControlPatchSchema = LoopControlSchema.partial(); const BackgroundConfigPatchSchema = BackgroundConfigSchema.partial(); +const ExperimentalConfigPatchSchema = ExperimentalConfigSchema; const MoonshotServiceConfigPatchSchema = MoonshotServiceConfigSchema.partial(); const ServicesConfigPatchSchema = z.object({ moonshotSearch: MoonshotServiceConfigPatchSchema.optional(), @@ -237,6 +257,7 @@ export const KimiConfigPatchSchema = z extraSkillDirs: z.array(z.string()).optional(), loopControl: LoopControlPatchSchema.optional(), background: BackgroundConfigPatchSchema.optional(), + experimental: ExperimentalConfigPatchSchema.optional(), telemetry: z.boolean().optional(), }) .strict(); diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index 36191f6037..56452e41a7 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -9,6 +9,7 @@ import { formatConfigValidationError, getDefaultConfig, type BackgroundConfig, + type ExperimentalConfig, type HookDefConfig, type KimiConfig, type LoopControl, @@ -132,6 +133,8 @@ export function transformTomlData(data: Record): Record { setSection(out, 'services', config.services, servicesToToml); setSection(out, 'loop_control', config.loopControl, loopControlToToml); setSection(out, 'background', config.background, backgroundToToml); + setSection(out, 'experimental', config.experimental, experimentalToToml); setSection(out, 'permission', config.permission, permissionToToml); setHooks(out, config.hooks); @@ -463,6 +467,17 @@ function backgroundToToml( return out; } +function experimentalToToml( + experimental: ExperimentalConfig, + _rawExperimental: unknown, +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(experimental)) { + setDefined(out, key, value); + } + return out; +} + function setHooks(out: Record, hooks: readonly HookDefConfig[] | undefined): void { if (hooks === undefined) { delete out['hooks']; diff --git a/packages/agent-core/src/flags/registry.ts b/packages/agent-core/src/flags/registry.ts index df3ea15ee8..1f9b896d40 100644 --- a/packages/agent-core/src/flags/registry.ts +++ b/packages/agent-core/src/flags/registry.ts @@ -1,10 +1,11 @@ import type { FlagDefinitionInput } from './types'; /** - * Experimental feature flags. Empty by default — there are no experimental features yet. + * Experimental feature flags. * - * To add one, append an entry and gate the feature with `flags.enabled('my-feature')`: - * { id: 'my-feature', env: 'KIMI_CODE_EXPERIMENTAL_MY_FEATURE', default: false, surface: 'both' } + * To add one, append an entry and gate runtime behavior through the scoped + * resolver available on `KimiCore`, `Session`, or `Agent`: + * { id: 'my_feature', title: 'My feature', description: '...', env: 'KIMI_CODE_EXPERIMENTAL_MY_FEATURE', default: false, surface: 'both' } * * Keep the `as const satisfies` — it derives the literal `FlagId` union that gives `enabled()` * autocomplete and typo-checking. `env` must start with 'KIMI_CODE_EXPERIMENTAL_', be unique, and @@ -12,19 +13,25 @@ import type { FlagDefinitionInput } from './types'; */ export const FLAG_DEFINITIONS = [ { - id: 'goal-command', + id: 'goal_command', + title: 'Goal command', + description: 'Enable /goal and goal-management tools for longer autonomous tasks.', env: 'KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND', default: false, surface: 'both', }, { - id: 'micro-compaction', + id: 'micro_compaction', + title: 'Micro compaction', + description: 'Trim older large tool results from context while keeping recent conversation intact.', env: 'KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION', default: false, surface: 'core', }, { - id: 'background-ask', + id: 'background_ask', + title: 'Background questions', + description: 'Allow AskUserQuestion to return a background task when the agent can continue working.', env: 'KIMI_CODE_EXPERIMENTAL_BACKGROUND_ASK', default: false, surface: 'core', diff --git a/packages/agent-core/src/flags/resolver.ts b/packages/agent-core/src/flags/resolver.ts index 3675b7a7ef..2f12608a7d 100644 --- a/packages/agent-core/src/flags/resolver.ts +++ b/packages/agent-core/src/flags/resolver.ts @@ -1,7 +1,11 @@ import { parseBooleanEnv } from '#/config/resolve'; import { FLAG_DEFINITIONS, type FlagId } from './registry'; -import type { FlagDefinitionInput } from './types'; +import type { + ExperimentalFeatureState, + ExperimentalFlagConfig, + FlagDefinitionInput, +} from './types'; /** Master switch: when truthy, forces every flag on (highest priority). */ export const MASTER_ENV = 'KIMI_CODE_EXPERIMENTAL_FLAG'; @@ -14,7 +18,8 @@ export const MASTER_ENV = 'KIMI_CODE_EXPERIMENTAL_FLAG'; * Precedence (highest wins): * L1 master switch KIMI_CODE_EXPERIMENTAL_FLAG → every flag is on * L2 per-feature def.env (parseBooleanEnv, may force on or off) - * L3 registry default + * L3 config.toml [experimental] per-feature override + * L4 registry default */ export class FlagResolver { private readonly byId: ReadonlyMap; @@ -22,17 +27,30 @@ export class FlagResolver { constructor( private readonly env: Readonly> = process.env, private readonly definitions: readonly FlagDefinitionInput[] = FLAG_DEFINITIONS, + private configOverrides: ExperimentalFlagConfig = {}, ) { this.byId = new Map(definitions.map((def) => [def.id, def])); } + setConfigOverrides(overrides: ExperimentalFlagConfig | undefined): void { + this.configOverrides = overrides ?? {}; + } + enabled(id: FlagId): boolean { + return this.explain(id)?.enabled ?? false; + } + + explain(id: FlagId): ExperimentalFeatureState | undefined { const def = this.byId.get(id); - if (def === undefined) return false; - if (parseBooleanEnv(this.env[MASTER_ENV]) === true) return true; // L1 master switch + if (def === undefined) return undefined; + const configValue = this.configOverrides[def.id as FlagId]; + if (parseBooleanEnv(this.env[MASTER_ENV]) === true) { + return this.state(def, true, 'master-env', configValue); + } const override = parseBooleanEnv(this.env[def.env]); // L2 per-feature - if (override !== undefined) return override; - return def.default; // L3 default + if (override !== undefined) return this.state(def, override, 'env', configValue); + if (configValue !== undefined) return this.state(def, configValue, 'config', configValue); + return this.state(def, def.default, 'default', undefined); } snapshot(): Record { @@ -46,11 +64,36 @@ export class FlagResolver { .filter((def) => this.enabled(def.id as FlagId)) .map((def) => def.id as FlagId); } + + explainAll(): readonly ExperimentalFeatureState[] { + return this.definitions + .map((def) => this.explain(def.id as FlagId)) + .filter((state): state is ExperimentalFeatureState => state !== undefined); + } + + private state( + def: FlagDefinitionInput, + enabled: boolean, + source: ExperimentalFeatureState['source'], + configValue: boolean | undefined, + ): ExperimentalFeatureState { + return { + id: def.id as FlagId, + title: def.title, + description: def.description, + surface: def.surface, + env: def.env, + defaultEnabled: def.default, + enabled, + source, + configValue, + }; + } } /** - * Process-global flag accessor. Flags are env-driven and process-global, so a single shared - * instance (reading live process.env) is the canonical way to consult them — import this directly - * rather than constructing or injecting a resolver. + * Compatibility accessor for callers that only need process-global env behavior. + * Runtime code that belongs to a KimiCore/Session/Agent should use the scoped + * resolver on that owner instead. */ export const flags = new FlagResolver(); diff --git a/packages/agent-core/src/flags/types.ts b/packages/agent-core/src/flags/types.ts index 3c66680365..0610293697 100644 --- a/packages/agent-core/src/flags/types.ts +++ b/packages/agent-core/src/flags/types.ts @@ -6,6 +6,8 @@ export type FlagSurface = 'core' | 'tui' | 'both'; /** Shape of a registry entry (id is a loose string so `as const satisfies` can validate it). */ export interface FlagDefinitionInput { readonly id: string; + readonly title: string; + readonly description: string; /** Full environment variable name, e.g. `KIMI_CODE_EXPERIMENTAL_MY_FEATURE`. Read directly by the resolver. */ readonly env: string; readonly default: boolean; @@ -17,3 +19,29 @@ export type FlagDefinition = FlagDefinitionInput & { readonly id: FlagId }; /** Resolved enabled-state of every experimental flag (flag id → enabled); used for the SDK snapshot. */ export type ExperimentalFlagMap = Record; + +/** User config overrides for experimental flags (flag id → enabled). */ +export type ExperimentalFlagConfig = Partial>; + +export type ExperimentalFlagSource = 'master-env' | 'env' | 'config' | 'default'; + +export interface ExperimentalFeatureState { + readonly id: FlagId; + readonly title: string; + readonly description: string; + readonly surface: FlagSurface; + readonly env: string; + readonly defaultEnabled: boolean; + readonly enabled: boolean; + readonly source: ExperimentalFlagSource; + readonly configValue?: boolean; +} + +export interface ExperimentalFlagResolver { + enabled(id: FlagId): boolean; + snapshot(): ExperimentalFlagMap; + enabledIds(): readonly FlagId[]; + explain(id: FlagId): ExperimentalFeatureState | undefined; + explainAll(): readonly ExperimentalFeatureState[]; + setConfigOverrides(overrides: ExperimentalFlagConfig | undefined): void; +} diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 76c4bbb89a..0f9c559ff5 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -5,7 +5,7 @@ import type { PermissionData, PermissionMode } from '#/agent/permission'; import type { PlanData } from '#/agent/plan'; import type { ToolInfo } from '#/agent/tool'; import type { KimiConfig, KimiConfigPatch, McpServerConfig } from '#/config'; -import type { ExperimentalFlagMap } from '#/flags'; +import type { ExperimentalFeatureState, ExperimentalFlagMap } from '#/flags'; import type { ResumeSessionResult } from '#/rpc/resumed'; import type { SessionMeta } from '#/session'; import type { @@ -358,6 +358,7 @@ type SessionAPIWithId = WithSessionId; export interface CoreAPI extends SessionAPIWithId { getCoreInfo: (payload: EmptyPayload) => CoreInfo; getExperimentalFlags: (payload: EmptyPayload) => ExperimentalFlagMap; + getExperimentalFeatures: (payload: EmptyPayload) => readonly ExperimentalFeatureState[]; getKimiConfig: (payload: GetKimiConfigPayload) => KimiConfig; setKimiConfig: (payload: SetKimiConfigPayload) => KimiConfig; removeKimiProvider: (payload: RemoveKimiProviderPayload) => KimiConfig; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 1ffd92b1d8..378b6bedd5 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -23,7 +23,9 @@ import { type MoonshotServiceConfig, } from '../config'; import { - flags, + FLAG_DEFINITIONS, + FlagResolver, + type ExperimentalFeatureState, type ExperimentalFlagMap, } from '../flags'; import type { Logger } from '../logging/types'; @@ -135,6 +137,7 @@ export class KimiCore implements PromisableMethods { private pluginsReady: Promise; private pluginsLoadError: Error | undefined; private readonly appVersion: string | undefined; + private readonly experimentalFlags: FlagResolver; constructor( protected readonly rpcClient: CoreRPCClient, @@ -161,6 +164,11 @@ export class KimiCore implements PromisableMethods { this.appVersion = options.appVersion; ensureKimiHome(this.homeDir); this.config = loadRuntimeConfig(this.configPath); + this.experimentalFlags = new FlagResolver( + process.env, + FLAG_DEFINITIONS, + this.config.experimental, + ); this.sessionStore = new SessionStore(this.homeDir); this.plugins = new PluginManager({ kimiHomeDir: this.homeDir }); // Capture the error rather than swallow it: mutators and explicit /plugins @@ -170,7 +178,7 @@ export class KimiCore implements PromisableMethods { this.pluginsReady = this.plugins.load().catch((error: unknown) => { this.pluginsLoadError = error instanceof Error ? error : new Error(String(error)); }); - log.info('experimental flags enabled', { flags: flags.enabledIds() }); + log.info('experimental flags enabled', { flags: this.experimentalFlags.enabledIds() }); this.sdk = rpcClient(this); } @@ -217,6 +225,7 @@ export class KimiCore implements PromisableMethods { permissionRules: config.permission?.rules, skills: this.resolveSessionSkillConfig(config), mcpConfig, + experimentalFlags: this.experimentalFlags, telemetry: withTelemetryContext(this.telemetry, { sessionId: summary.id }), pluginSessionStarts, appVersion: this.appVersion, @@ -262,7 +271,11 @@ export class KimiCore implements PromisableMethods { } getExperimentalFlags(): ExperimentalFlagMap { - return flags.snapshot(); + return this.experimentalFlags.snapshot(); + } + + getExperimentalFeatures(): readonly ExperimentalFeatureState[] { + return this.experimentalFlags.explainAll(); } async closeSession({ sessionId }: CloseSessionPayload): Promise { @@ -304,6 +317,7 @@ export class KimiCore implements PromisableMethods { permissionRules: config.permission?.rules, skills: this.resolveSessionSkillConfig(config), mcpConfig, + experimentalFlags: this.experimentalFlags, telemetry: withTelemetryContext(this.telemetry, { sessionId: summary.id }), initializeMainAgent: false, pluginSessionStarts, @@ -410,7 +424,7 @@ export class KimiCore implements PromisableMethods { async getKimiConfig(input?: GetKimiConfigPayload): Promise { if (input?.reload) { - this.config = loadRuntimeConfig(this.configPath); + this.setRuntimeConfig(loadRuntimeConfig(this.configPath)); } return this.config; } @@ -418,7 +432,7 @@ export class KimiCore implements PromisableMethods { async setKimiConfig(input: SetKimiConfigPayload): Promise { const config = mergeConfigPatch(readConfigFile(this.configPath), input); await writeConfigFile(this.configPath, config); - return this.config = loadRuntimeConfig(this.configPath); + return this.setRuntimeConfig(loadRuntimeConfig(this.configPath)); } async removeKimiProvider(input: RemoveKimiProviderPayload): Promise { @@ -449,7 +463,7 @@ export class KimiCore implements PromisableMethods { } await writeConfigFile(this.configPath, config); - return this.config = loadRuntimeConfig(this.configPath); + return this.setRuntimeConfig(loadRuntimeConfig(this.configPath)); } prompt({ sessionId, ...payload }: SessionAgentPayload) { @@ -757,7 +771,9 @@ export class KimiCore implements PromisableMethods { ...pluginServers, }, }; - } private sessionApi(sessionId: string): SessionAPIImpl { + } + + private sessionApi(sessionId: string): SessionAPIImpl { const session = this.sessions.get(sessionId); if (session === undefined) { throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `Session "${sessionId}" was not found`, { @@ -768,7 +784,13 @@ export class KimiCore implements PromisableMethods { } private reloadProviderManager(): KimiConfig { - return this.config = loadRuntimeConfig(this.configPath); + return this.setRuntimeConfig(loadRuntimeConfig(this.configPath)); + } + + private setRuntimeConfig(config: KimiConfig): KimiConfig { + this.config = config; + this.experimentalFlags.setConfigOverrides(config.experimental); + return this.config; } private clearRuntimeCache(): void { diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index ca0913be5a..64c71ce050 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -40,6 +40,7 @@ import { import { noopTelemetryClient, type TelemetryClient } from '../telemetry'; import { SessionSubagentHost } from './subagent-host'; import type { ToolServices } from '../tools/support/services'; +import { FlagResolver, type ExperimentalFlagResolver } from '../flags'; export interface SessionOptions { readonly kaos: Kaos; @@ -59,6 +60,7 @@ export interface SessionOptions { readonly telemetry?: TelemetryClient | undefined; readonly pluginSessionStarts?: readonly EnabledPluginSessionStart[]; readonly appVersion?: string; + readonly experimentalFlags?: ExperimentalFlagResolver; } export interface SessionSkillConfig { @@ -112,6 +114,7 @@ export class Session { private readonly logHandle: SessionLogHandle | undefined; readonly hookEngine: HookEngine; readonly goals: SessionGoalStore; + readonly experimentalFlags: ExperimentalFlagResolver; private agentIdCounter = 0; private readonly skillsReady: Promise; metadata: SessionMeta = { @@ -139,6 +142,7 @@ export class Session { this.logHandle?.logger ?? (options.id === undefined ? log : log.createChild({ sessionId: options.id })); this.rpc = options.rpc; + this.experimentalFlags = options.experimentalFlags ?? new FlagResolver(); this.hookEngine = new HookEngine(options.hooks, { cwd: options.kaos.getcwd(), sessionId: options.id, @@ -493,6 +497,7 @@ export class Session { log: this.log.createChild({ agentId: id }), pluginSessionStarts: type === 'main' ? this.options.pluginSessionStarts : undefined, appVersion: this.options.appVersion, + experimentalFlags: this.experimentalFlags, }); } diff --git a/packages/agent-core/src/session/rpc.ts b/packages/agent-core/src/session/rpc.ts index 18f85b6e50..d500216bc3 100644 --- a/packages/agent-core/src/session/rpc.ts +++ b/packages/agent-core/src/session/rpc.ts @@ -31,7 +31,6 @@ import type { import type { PromisableMethods } from '#/utils/types'; import type { Session, SessionMeta } from '.'; -import { flags } from '../flags'; import { promptMetadataTextFromPayload, promptMetadataTextFromSkill, @@ -148,7 +147,7 @@ export class SessionAPIImpl implements PromisableMethods { } private assertGoalCommandEnabled(): void { - if (flags.enabled('goal-command')) return; + if (this.session.experimentalFlags.enabled('goal_command')) return; throw new KimiError(ErrorCodes.NOT_IMPLEMENTED, 'Goal command is disabled'); } diff --git a/packages/agent-core/src/tools/builtin/collaboration/ask-user.ts b/packages/agent-core/src/tools/builtin/collaboration/ask-user.ts index 75b4d10667..3d63456d6b 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/ask-user.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/ask-user.ts @@ -19,7 +19,6 @@ import type { BuiltinTool } from '../../../agent/tool'; import { ErrorCodes, KimiError } from '../../../errors'; import { errorMessage, isAbortError } from '../../../loop/errors'; import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../../loop/types'; -import { flags } from '../../../flags'; import type { QuestionAnswers, QuestionAnswerMethod, @@ -86,9 +85,7 @@ const AskUserQuestionInputSchemaWithBackground = AskUserQuestionInputBaseSchema. }); export const AskUserQuestionInputSchema: z.ZodType = - flags.enabled('background-ask') - ? AskUserQuestionInputSchemaWithBackground - : AskUserQuestionInputBaseSchema; + AskUserQuestionInputBaseSchema; const QUESTION_DISMISSED_MESSAGE = 'User dismissed the question without answering.'; @@ -99,15 +96,20 @@ const QUESTION_UNSUPPORTED_FAILURE_MESSAGE = export class AskUserQuestionTool implements BuiltinTool { readonly name = 'AskUserQuestion' as const; - readonly description: string = flags.enabled('background-ask') - ? `${DESCRIPTION}- Set background=true when you can keep working without the answer. This starts a background question task and returns a task_id immediately. The answer arrives automatically in a later turn — you do not need to poll, sleep, or check on it. Continue with other work; never fabricate or predict the answer.` - : DESCRIPTION; - readonly parameters: Record = toInputJsonSchema(AskUserQuestionInputSchema); + readonly description: string; + readonly parameters: Record; + private readonly backgroundEnabled: boolean; - constructor(private readonly agent: Agent) {} + constructor(private readonly agent: Agent) { + this.backgroundEnabled = agent.experimentalFlags.enabled('background_ask'); + this.description = this.backgroundEnabled + ? `${DESCRIPTION}- Set background=true when you can keep working without the answer. This starts a background question task and returns a task_id immediately. The answer arrives automatically in a later turn — you do not need to poll, sleep, or check on it. Continue with other work; never fabricate or predict the answer.` + : DESCRIPTION; + this.parameters = toInputJsonSchema(this.inputSchema()); + } resolveExecution(args: AskUserQuestionInput): ToolExecution { - const isBackground = args.background === true && flags.enabled('background-ask'); + const isBackground = args.background === true && this.backgroundEnabled; return { description: isBackground ? `Starting background question: ${questionDescription(args.questions)}` @@ -125,13 +127,19 @@ export class AskUserQuestionTool implements BuiltinTool { turnId, }: ExecutableToolContext, ): Promise { - if (args.background === true && flags.enabled('background-ask')) { + if (args.background === true && this.backgroundEnabled) { return this.executeInBackground(args, { toolCallId, turnId, signal }); } return this.executeQuestion(args, { toolCallId, turnId, signal }); } + private inputSchema(): z.ZodType { + return this.backgroundEnabled + ? AskUserQuestionInputSchemaWithBackground + : AskUserQuestionInputBaseSchema; + } + private async executeQuestion( args: AskUserQuestionInput, { diff --git a/packages/agent-core/test/agent/basic.test.ts b/packages/agent-core/test/agent/basic.test.ts index 5006f94f73..99575eec46 100644 --- a/packages/agent-core/test/agent/basic.test.ts +++ b/packages/agent-core/test/agent/basic.test.ts @@ -1,8 +1,17 @@ import type { ToolCall } from '@moonshot-ai/kosong'; import { expect, it } from 'vitest'; +import { FLAG_DEFINITIONS, FlagResolver } from '../../src/flags'; import { createCommandKaos, testAgent } from './harness/agent'; +it('creates an independent agent with a scoped experimental flag resolver', () => { + const ctx = testAgent({ + experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS), + }); + + expect(ctx.agent.experimentalFlags.enabled('goal_command')).toBe(false); +}); + it('runs a text-only agent turn from prompt to completion', async () => { const ctx = testAgent(); ctx.configure(); diff --git a/packages/agent-core/test/agent/compaction/full.test.ts b/packages/agent-core/test/agent/compaction/full.test.ts index 60944ce041..d6bb81b552 100644 --- a/packages/agent-core/test/agent/compaction/full.test.ts +++ b/packages/agent-core/test/agent/compaction/full.test.ts @@ -1714,9 +1714,9 @@ function enableMicroCompactionFlag(): void { } function getMicroCompactionFlagEnv(): string { - const flag = FLAG_DEFINITIONS.find((definition) => definition.id === 'micro-compaction'); + const flag = FLAG_DEFINITIONS.find((definition) => definition.id === 'micro_compaction'); if (flag === undefined) { - throw new Error('Missing micro-compaction flag definition.'); + throw new Error('Missing micro_compaction flag definition.'); } return flag.env; } diff --git a/packages/agent-core/test/agent/compaction/micro.test.ts b/packages/agent-core/test/agent/compaction/micro.test.ts index 7975f6a0ee..287e1cbddd 100644 --- a/packages/agent-core/test/agent/compaction/micro.test.ts +++ b/packages/agent-core/test/agent/compaction/micro.test.ts @@ -438,7 +438,7 @@ describe('MicroCompaction', () => { ]); }); - it('tracks telemetry when a cache miss advances the micro-compaction cutoff', () => { + it('tracks telemetry when a cache miss advances the micro_compaction cutoff', () => { vi.useFakeTimers(); const records: TelemetryRecord[] = []; const microCompaction = { @@ -485,7 +485,7 @@ describe('MicroCompaction', () => { expect(records.filter((record) => record.event === 'micro_compaction_applied')).toHaveLength(1); }); - it('leaves context unchanged when the micro-compaction flag is disabled', () => { + it('leaves context unchanged when the micro_compaction flag is disabled', () => { vi.stubEnv(MICRO_COMPACTION_FLAG_ENV, '0'); vi.useFakeTimers(); const persistence = new InMemoryAgentRecordPersistence(); @@ -907,9 +907,9 @@ function hasMarker(messages: readonly Message[]): boolean { } function getMicroCompactionFlagEnv(): string { - const flag = FLAG_DEFINITIONS.find((definition) => definition.id === 'micro-compaction'); + const flag = FLAG_DEFINITIONS.find((definition) => definition.id === 'micro_compaction'); if (flag === undefined) { - throw new Error('Missing micro-compaction flag definition.'); + throw new Error('Missing micro_compaction flag definition.'); } return flag.env; } diff --git a/packages/agent-core/test/agent/harness/agent.ts b/packages/agent-core/test/agent/harness/agent.ts index 7c0db3ecff..6adb39ff87 100644 --- a/packages/agent-core/test/agent/harness/agent.ts +++ b/packages/agent-core/test/agent/harness/agent.ts @@ -108,6 +108,7 @@ export interface TestAgentOptions { readonly homedir?: AgentOptions['homedir']; readonly telemetry?: TelemetryClient | undefined; readonly log?: Logger; + readonly experimentalFlags?: AgentOptions['experimentalFlags']; } interface ConfigureOptions { @@ -195,6 +196,7 @@ export class AgentTestContext { hookEngine: options.hookEngine, telemetry: options.telemetry, log: options.log, + experimentalFlags: options.experimentalFlags, }); this.rpc = this.createPromiseAgentApi(this.agent); // The Agent constructor now eagerly binds a SIGUSR1 listener via diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index dcade8cfd1..3af4834881 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -254,6 +254,47 @@ oauth = { storage = "file", key = "oauth/kimi-code-env-1234", oauth_host = "http ); }); + it('parses and round-trips experimental feature flags', async () => { + const dir = makeTempDir(); + const configPath = join(dir, 'experimental.toml'); + const toml = ` +[experimental] +goal_command = true +micro_compaction = false +background_ask = true +`; + const config = parseConfigString(toml, configPath); + + expect(config.experimental).toEqual({ + 'goal_command': true, + 'micro_compaction': false, + 'background_ask': true, + }); + + await writeConfigFile(configPath, config); + const text = await readFile(configPath, 'utf-8'); + + expect(text).toContain('[experimental]'); + expect(text).toContain('goal_command = true'); + expect(text).toContain('micro_compaction = false'); + expect(text).toContain('background_ask = true'); + expect(parseConfigString(text, configPath).experimental).toEqual(config.experimental); + }); + + it('rejects unknown experimental feature keys', () => { + expectKimiErrorCode( + () => + parseConfigString( + ` +[experimental] +not_registered = true +`, + 'unknown-experimental.toml', + ), + ErrorCodes.CONFIG_INVALID, + ); + }); + it('loads defaults for absent files and writes typed fields without dropping raw sections', async () => { const dir = makeTempDir(); const configPath = join(dir, 'config.toml'); @@ -455,6 +496,25 @@ describe('harness config schema and patch merge', () => { expect(merged.raw?.['theme']).toBe('dark'); }); + it('deep-merges experimental config patches', () => { + const base = parseConfigString(` +[experimental] +goal_command = true +micro_compaction = false +`); + + const merged = mergeConfigPatch(base, { + experimental: { + 'micro_compaction': true, + }, + }); + + expect(merged.experimental).toEqual({ + 'goal_command': true, + 'micro_compaction': true, + }); + }); + it('rejects unknown fields in config patches', () => { expectKimiErrorCode( () => mergeConfigPatch({ providers: {} }, { theme: 'dark' } as never), diff --git a/packages/agent-core/test/flags/resolver.test.ts b/packages/agent-core/test/flags/resolver.test.ts index f484a3eb73..d6bbb698e9 100644 --- a/packages/agent-core/test/flags/resolver.test.ts +++ b/packages/agent-core/test/flags/resolver.test.ts @@ -13,12 +13,16 @@ import { const DEFS = [ { id: 'a-on-default', + title: 'A on default', + description: 'Fake flag A.', env: 'KIMI_CODE_EXPERIMENTAL_A', default: true, surface: 'core', }, { id: 'b-off-default', + title: 'B off default', + description: 'Fake flag B.', env: 'KIMI_CODE_EXPERIMENTAL_B', default: false, surface: 'tui', @@ -91,6 +95,74 @@ describe('FlagResolver', () => { it('unknown id resolves to false (defensive)', () => { expect(make({})('not-a-real-flag')).toBe(false); }); + + it('uses config overrides between env and registry defaults', () => { + const resolver = new FlagResolver({}, DEFS, { + 'a-on-default': false, + 'b-off-default': true, + } as never); + + expect(resolver.enabled('a-on-default' as FlagId)).toBe(false); + expect(resolver.enabled('b-off-default' as FlagId)).toBe(true); + }); + + it('keeps env precedence above config overrides', () => { + const resolver = new FlagResolver( + { + KIMI_CODE_EXPERIMENTAL_A: '1', + KIMI_CODE_EXPERIMENTAL_B: '0', + }, + DEFS, + { + 'a-on-default': false, + 'b-off-default': true, + } as never, + ); + + expect(resolver.enabled('a-on-default' as FlagId)).toBe(true); + expect(resolver.enabled('b-off-default' as FlagId)).toBe(false); + }); + + it('updates config overrides without leaking into another resolver', () => { + const first = new FlagResolver({}, DEFS, { 'b-off-default': true } as never); + const second = new FlagResolver({}, DEFS); + + expect(first.enabled('b-off-default' as FlagId)).toBe(true); + expect(second.enabled('b-off-default' as FlagId)).toBe(false); + + first.setConfigOverrides({ 'b-off-default': false } as never); + + expect(first.enabled('b-off-default' as FlagId)).toBe(false); + expect(second.enabled('b-off-default' as FlagId)).toBe(false); + }); + + it('explains the source of the effective value', () => { + const defaulted = new FlagResolver({}, DEFS); + expect(defaulted.explain('b-off-default' as FlagId)).toMatchObject({ + id: 'b-off-default', + enabled: false, + source: 'default', + configValue: undefined, + }); + + const configured = new FlagResolver({}, DEFS, { 'b-off-default': true } as never); + expect(configured.explain('b-off-default' as FlagId)).toMatchObject({ + enabled: true, + source: 'config', + configValue: true, + }); + + const fromEnv = new FlagResolver( + { KIMI_CODE_EXPERIMENTAL_B: '0' }, + DEFS, + { 'b-off-default': true } as never, + ); + expect(fromEnv.explain('b-off-default' as FlagId)).toMatchObject({ + enabled: false, + source: 'env', + configValue: true, + }); + }); }); describe('FLAG_DEFINITIONS invariants', () => { @@ -102,6 +174,8 @@ describe('FLAG_DEFINITIONS invariants', () => { expect(def.env.startsWith('KIMI_CODE_EXPERIMENTAL_')).toBe(true); expect(def.env).not.toBe(MASTER_ENV); expect(def.id).not.toBe('flag'); // reserved: would collide with the master switch + expect(def.title.length).toBeGreaterThan(0); + expect(def.description.length).toBeGreaterThan(0); expect(seenEnv.has(def.env)).toBe(false); expect(seenId.has(def.id)).toBe(false); seenEnv.add(def.env); diff --git a/packages/agent-core/test/harness/runtime.test.ts b/packages/agent-core/test/harness/runtime.test.ts index eb64073285..33b4236fbd 100644 --- a/packages/agent-core/test/harness/runtime.test.ts +++ b/packages/agent-core/test/harness/runtime.test.ts @@ -28,6 +28,13 @@ function requiredFlagEnv(id: string): string { return def.env; } +function clearExperimentalEnv(): void { + vi.stubEnv(MASTER_ENV, '0'); + for (const def of FLAG_DEFINITIONS) { + vi.stubEnv(def.env, ''); + } +} + describe('KimiCore runtime config', () => { let tmp: string; @@ -50,19 +57,132 @@ describe('KimiCore runtime config', () => { for (const def of FLAG_DEFINITIONS) { vi.stubEnv(def.env, '0'); } - vi.stubEnv(requiredFlagEnv('goal-command'), '1'); - vi.stubEnv(requiredFlagEnv('background-ask'), '1'); + vi.stubEnv(requiredFlagEnv('goal_command'), '1'); + vi.stubEnv(requiredFlagEnv('background_ask'), '1'); void new KimiCore(async () => ({}) as never, { homeDir }); await getRootLogger().flushGlobal(); const text = await readFile(resolveGlobalLogPath(homeDir), 'utf-8'); expect(text).toContain('experimental flags enabled'); - expect(text).toContain('goal-command'); - expect(text).toContain('background-ask'); + expect(text).toContain('goal_command'); + expect(text).toContain('background_ask'); expect(text.match(/experimental flags enabled/g)).toHaveLength(1); }); + it('resolves experimental flags from each core config independently', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const firstHome = join(tmp, 'first-home'); + const secondHome = join(tmp, 'second-home'); + await mkdir(firstHome, { recursive: true }); + await mkdir(secondHome, { recursive: true }); + await writeFile( + join(firstHome, 'config.toml'), + ` +[experimental] +goal_command = true +background_ask = false +`, + ); + await writeFile( + join(secondHome, 'config.toml'), + ` +[experimental] +goal_command = false +background_ask = true +`, + ); + clearExperimentalEnv(); + + const first = new KimiCore(async () => ({}) as never, { homeDir: firstHome }); + const second = new KimiCore(async () => ({}) as never, { homeDir: secondHome }); + + expect(first.getExperimentalFlags()).toMatchObject({ + 'goal_command': true, + 'background_ask': false, + }); + expect(second.getExperimentalFlags()).toMatchObject({ + 'goal_command': false, + 'background_ask': true, + }); + }); + + it('updates the scoped experimental resolver after setKimiConfig', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + await mkdir(homeDir, { recursive: true }); + await writeFile( + join(homeDir, 'config.toml'), + ` +[experimental] +goal_command = false +`, + ); + clearExperimentalEnv(); + + const core = new KimiCore(async () => ({}) as never, { homeDir }); + expect(core.getExperimentalFlags()['goal_command']).toBe(false); + + await core.setKimiConfig({ + experimental: { + 'goal_command': true, + }, + }); + + expect(core.getExperimentalFlags()['goal_command']).toBe(true); + }); + + it('updates the shared experimental resolver without hot-refreshing materialized tools', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await writeFile( + join(homeDir, 'config.toml'), + `${baseModelConfig()} +[experimental] +goal_command = false +`, + ); + clearExperimentalEnv(); + + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + + const created = await rpc.createSession({ + id: 'ses_runtime_experimental_refresh', + workDir, + model: 'default-mock', + }); + const session = core.sessions.get(created.id); + const mainAgent = session?.getReadyAgent('main'); + + expect(session?.experimentalFlags.enabled('goal_command')).toBe(false); + expect(mainAgent?.experimentalFlags.enabled('goal_command')).toBe(false); + expect(mainAgent?.tools.data().some((tool) => tool.name === 'CreateGoal')).toBe(false); + + await core.setKimiConfig({ + experimental: { + 'goal_command': true, + }, + }); + + expect(session?.experimentalFlags.enabled('goal_command')).toBe(true); + expect(mainAgent?.experimentalFlags.enabled('goal_command')).toBe(true); + expect(mainAgent?.tools.data().some((tool) => tool.name === 'CreateGoal')).toBe(false); + + await rpc.reloadSession({ sessionId: created.id }); + const reloadedMainAgent = core.sessions.get(created.id)?.getReadyAgent('main'); + expect(reloadedMainAgent?.tools.data().some((tool) => tool.name === 'CreateGoal')).toBe(true); + }); + it('uses the shared OAuth resolver for Moonshot service tokens', async () => { tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); const homeDir = join(tmp, 'home'); diff --git a/packages/agent-core/test/tools/ask-user.test.ts b/packages/agent-core/test/tools/ask-user.test.ts index 325c2742fa..80cd2e18c9 100644 --- a/packages/agent-core/test/tools/ask-user.test.ts +++ b/packages/agent-core/test/tools/ask-user.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Agent } from '../../src/agent'; import type { PermissionMode } from '../../src/agent/permission'; import { ErrorCodes, KimiError } from '../../src/errors'; +import { FLAG_DEFINITIONS, FlagResolver, MASTER_ENV } from '../../src/flags'; import type { QuestionRequest, QuestionResult } from '../../src/rpc'; import { AskUserQuestionInputSchema, @@ -57,6 +58,7 @@ function makeTool( permission: { mode: options.mode ?? 'manual' }, rpc: { requestQuestion }, telemetry: { track: telemetryTrack }, + experimentalFlags: new FlagResolver(), } as unknown as Agent; return { tool: new AskUserQuestionTool(agent), requestQuestion, telemetryTrack }; } @@ -111,6 +113,34 @@ describe('AskUserQuestionTool', () => { expect(labelSchema.description).toContain("append '(Recommended)'"); }); + it('builds the background-question schema from the agent scoped resolver', () => { + vi.stubEnv(MASTER_ENV, '1'); + const enabledAgent = { + rpc: { requestQuestion: vi.fn() }, + telemetry: { track: vi.fn() }, + background: createBackgroundManager().manager, + experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS, { + 'background_ask': true, + }), + } as unknown as Agent; + const disabledAgent = { + rpc: { requestQuestion: vi.fn() }, + telemetry: { track: vi.fn() }, + background: createBackgroundManager().manager, + experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS, { + 'background_ask': false, + }), + } as unknown as Agent; + + const enabledTool = new AskUserQuestionTool(enabledAgent); + const disabledTool = new AskUserQuestionTool(disabledAgent); + + expect(enabledTool.description).toContain('Set background=true'); + expect(JSON.stringify(enabledTool.parameters)).toContain('background'); + expect(disabledTool.description).not.toContain('Set background=true'); + expect(JSON.stringify(disabledTool.parameters)).not.toContain('background'); + }); + it.each(['manual', 'yolo'] as const)( 'dispatches questions through the agent rpc in %s mode', async (mode) => { @@ -186,6 +216,7 @@ describe('AskUserQuestionTool', () => { rpc: { requestQuestion }, telemetry: { track: telemetryTrack }, background: manager, + experimentalFlags: new FlagResolver(), } as unknown as Agent; const tool = new AskUserQuestionTool(agent); expect(tool.description).toContain('Set background=true'); @@ -232,6 +263,7 @@ describe('AskUserQuestionTool', () => { rpc: { requestQuestion }, telemetry: { track: vi.fn() }, background: manager, + experimentalFlags: new FlagResolver(), } as unknown as Agent; const tool = new AskUserQuestionTool(agent); expect(tool.description).not.toContain('Set background=true'); diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index 83823f9072..2cf0bb16e0 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -60,7 +60,9 @@ export { buildGoalCompletionMessage } from '@moonshot-ai/agent-core'; // Experimental feature flags — types only. Resolved values come from // `KimiHarness.getExperimentalFlags()` over RPC, not from a re-exported runtime value. export type { + ExperimentalFeatureState, ExperimentalFlagMap, + ExperimentalFlagSource, FlagDefinition, FlagDefinitionInput, FlagId, diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index 183de20295..ce65f06d99 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -2,6 +2,7 @@ import { ErrorCodes, KimiError, withTelemetryContext, + type ExperimentalFeatureState, type ExperimentalFlagMap, } from '@moonshot-ai/agent-core'; @@ -207,6 +208,10 @@ export class KimiHarness { return this.rpc.getExperimentalFlags(); } + async getExperimentalFeatures(): Promise { + return this.rpc.getExperimentalFeatures(); + } + async ensureConfigFile(): Promise { await this.ensureConfigFileImpl(); } diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 86cc6dd1e9..4348d497db 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -6,6 +6,7 @@ import { type ApprovalResponse, type CoreAPI, type Event, + type ExperimentalFeatureState, type ExperimentalFlagMap, type QuestionRequest, type QuestionResult, @@ -166,6 +167,11 @@ export abstract class SDKRpcClientBase { return rpc.getExperimentalFlags({}); } + async getExperimentalFeatures(): Promise { + const rpc = await this.getRpc(); + return rpc.getExperimentalFeatures({}); + } + async setConfig(input: KimiConfigPatch): Promise { const rpc = await this.getRpc(); return rpc.setKimiConfig(input); diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 5803d19786..1ef8a3277a 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -23,6 +23,9 @@ export type { BackgroundTaskStatus, ContextMessage, CreateGoalInput, + ExperimentalFeatureState, + ExperimentalFlagMap, + ExperimentalFlagSource, ExportSessionManifest, GoalBudgetLimits, GoalBudgetReport, diff --git a/packages/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index 4d551e9b73..6c60b5f9d2 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createKimiHarness, KimiError } from '#/index'; @@ -16,6 +16,7 @@ import { TEST_IDENTITY } from './test-identity'; const tempDirs: string[] = []; afterEach(async () => { + vi.unstubAllEnvs(); for (const dir of tempDirs.splice(0)) { await rm(dir, { recursive: true, force: true }); } @@ -285,6 +286,40 @@ describe('KimiHarness config API', () => { await expect(harness.getConfig()).resolves.toEqual({ providers: {} }); }); + it('returns experimental feature metadata through the harness', async () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0'); + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND', ''); + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION', ''); + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_BACKGROUND_ASK', ''); + const homeDir = await makeTempDir(); + await writeFile( + join(homeDir, 'config.toml'), + ` +[experimental] +goal_command = true +background_ask = false +`, + 'utf-8', + ); + const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); + + const features = await harness.getExperimentalFeatures(); + const goalCommand = features.find((feature) => feature.id === 'goal_command'); + + expect(goalCommand).toMatchObject({ + id: 'goal_command', + title: 'Goal command', + enabled: true, + source: 'config', + configValue: true, + env: 'KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND', + }); + await expect(harness.getExperimentalFlags()).resolves.toMatchObject({ + 'goal_command': true, + 'background_ask': false, + }); + }); + it('can create the default config scaffold without selecting a model', async () => { const homeDir = await makeTempDir(); const configPath = join(homeDir, 'config.toml'); From c14d9acf43d32409ac99a18cec8626285a707965 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 4 Jun 2026 17:34:47 +0800 Subject: [PATCH 2/4] test(agent-core): isolate experimental flag tests --- packages/agent-core/test/session/goal.test.ts | 21 ++++------ .../test/tools/builtin-current.test.ts | 2 + packages/agent-core/test/tools/goal.test.ts | 40 +++++++++---------- .../test/tools/input-schema-io.test.ts | 13 ++++-- 4 files changed, 39 insertions(+), 37 deletions(-) diff --git a/packages/agent-core/test/session/goal.test.ts b/packages/agent-core/test/session/goal.test.ts index 88a43f3070..8aabe5a243 100644 --- a/packages/agent-core/test/session/goal.test.ts +++ b/packages/agent-core/test/session/goal.test.ts @@ -5,6 +5,7 @@ import { join } from 'pathe'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ErrorCodes } from '../../src/errors'; +import { FLAG_DEFINITIONS, FlagResolver } from '../../src/flags'; import { Session } from '../../src/session'; import { SessionAPIImpl } from '../../src/session/rpc'; import { @@ -20,8 +21,6 @@ import type { TelemetryClient } from '../../src/telemetry'; import { testKaos } from '../fixtures/test-kaos'; import { recordingTelemetry, type TelemetryRecord } from '../fixtures/telemetry'; -const GOAL_FLAG = 'KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND'; - /** An in-memory store backing plus a controllable lazy audit sink. */ function makeAuditStore(opts: { sinkReady?: boolean } = {}) { let state: SessionGoalState | undefined; @@ -716,27 +715,22 @@ describe('SessionAPIImpl.updateSessionMetadata goal reservation', () => { }); describe('SessionAPIImpl goal flag gating', () => { - const originalGoalFlag = process.env[GOAL_FLAG]; - - afterEach(() => { - if (originalGoalFlag === undefined) delete process.env[GOAL_FLAG]; - else process.env[GOAL_FLAG] = originalGoalFlag; - }); - - function makeSession(sessionDir: string): Session { + function makeSession(sessionDir: string, goalEnabled: boolean): Session { return new Session({ id: 'goal-rpc-flag', kaos: testKaos.withCwd(sessionDir), homedir: sessionDir, rpc: createSessionRpc(), skills: { explicitDirs: [join(sessionDir, 'missing')] }, + experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS, { + goal_command: goalEnabled, + }), }); } it('rejects SDK goal creation when the flag is disabled', async () => { - delete process.env[GOAL_FLAG]; const sessionDir = await makeTempDir(); - const session = makeSession(sessionDir); + const session = makeSession(sessionDir, false); const api = new SessionAPIImpl(session); let thrown: unknown; @@ -750,9 +744,8 @@ describe('SessionAPIImpl goal flag gating', () => { }); it('allows SDK goal creation when the flag is enabled', async () => { - process.env[GOAL_FLAG] = 'true'; const sessionDir = await makeTempDir(); - const session = makeSession(sessionDir); + const session = makeSession(sessionDir, true); const api = new SessionAPIImpl(session); const snapshot = await api.createGoal({ objective: 'work' }); diff --git a/packages/agent-core/test/tools/builtin-current.test.ts b/packages/agent-core/test/tools/builtin-current.test.ts index ad6c9a0c5c..8de2825735 100644 --- a/packages/agent-core/test/tools/builtin-current.test.ts +++ b/packages/agent-core/test/tools/builtin-current.test.ts @@ -11,6 +11,7 @@ import type { Kaos, KaosProcess } from '@moonshot-ai/kaos'; import { describe, expect, it, vi } from 'vitest'; import type { Agent } from '../../src/agent'; +import { FLAG_DEFINITIONS, FlagResolver } from '../../src/flags'; import type { SessionSubagentHost } from '../../src/session/subagent-host'; import { SkillRegistry } from '../../src/skill'; import { TaskListInputSchema } from '../../src/tools/background/task-list'; @@ -226,6 +227,7 @@ describe('current builtin file and shell tools', () => { describe('current builtin collaboration tools', () => { it('AskUserQuestion exposes parameters and asks through rpc in yolo mode', async () => { const tool = new AskUserQuestionTool({ + experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS), permission: { mode: 'yolo' }, rpc: { requestQuestion: vi.fn(async () => ({ 'Which path?': 'A' })), diff --git a/packages/agent-core/test/tools/goal.test.ts b/packages/agent-core/test/tools/goal.test.ts index e05dbf8dfb..3c458cd4e3 100644 --- a/packages/agent-core/test/tools/goal.test.ts +++ b/packages/agent-core/test/tools/goal.test.ts @@ -1,7 +1,8 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; import type { Agent } from '../../src/agent'; import { ErrorCodes } from '../../src/errors'; +import { FLAG_DEFINITIONS, FlagResolver } from '../../src/flags'; import { compileToolArgsValidator, validateToolArgs } from '../../src/tools/args-validator'; import { CreateGoalTool, @@ -37,8 +38,6 @@ function ctx(args: Input) { return { turnId: '0', toolCallId: 'call_1', args, signal }; } -const GOAL_FLAG = 'KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND'; - describe('CreateGoalTool', () => { it('creates a goal through the goal store', async () => { const store = makeStore(); @@ -297,48 +296,49 @@ describe('goal tools are main-agent-only', () => { }); describe('ToolManager goal tool registration', () => { - const original = process.env[GOAL_FLAG]; - afterEach(() => { - if (original === undefined) delete process.env[GOAL_FLAG]; - else process.env[GOAL_FLAG] = original; - }); - - function loopToolNames(type: 'main' | 'sub'): readonly string[] { - const ctxAgent = testAgent({ type }); + function loopToolNames(type: 'main' | 'sub', goalEnabled: boolean): readonly string[] { + const ctxAgent = testAgent({ + type, + experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS, { + goal_command: goalEnabled, + }), + }); // configure() gives the agent a provider so builtin tools can initialize. ctxAgent.configure({ tools: ['Read', 'CreateGoal', 'GetGoal', 'SetGoalBudget'] }); - // Re-run registration so the gate reads the current flag state. + // Re-run registration so the gate reads the scoped flag resolver state. ctxAgent.agent.tools.initializeBuiltinTools(); return ctxAgent.agent.tools.loopTools.map((tool) => tool.name); } it('omits goal tools when the flag is disabled', () => { - delete process.env[GOAL_FLAG]; - const names = loopToolNames('main'); + const names = loopToolNames('main', false); expect(names).not.toContain('CreateGoal'); expect(names).not.toContain('GetGoal'); expect(names).not.toContain('SetGoalBudget'); }); it('exposes goal tools to the main agent when the flag is enabled', () => { - process.env[GOAL_FLAG] = 'true'; - const names = loopToolNames('main'); + const names = loopToolNames('main', true); expect(names).toEqual(expect.arrayContaining(['CreateGoal', 'GetGoal'])); expect(names).not.toContain('SetGoalBudget'); }); it('does not expose goal tools to subagents even when enabled', () => { - process.env[GOAL_FLAG] = 'true'; - const names = loopToolNames('sub'); + const names = loopToolNames('sub', true); expect(names).not.toContain('CreateGoal'); expect(names).not.toContain('GetGoal'); expect(names).not.toContain('SetGoalBudget'); }); it('hides goal mutation tools until a goal exists, then exposes them', async () => { - process.env[GOAL_FLAG] = 'true'; const store = makeStore(); - const ctxAgent = testAgent({ type: 'main', goals: store }); + const ctxAgent = testAgent({ + type: 'main', + goals: store, + experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS, { + goal_command: true, + }), + }); ctxAgent.configure({ tools: ['Read', 'CreateGoal', 'GetGoal', 'SetGoalBudget', 'UpdateGoal'] }); ctxAgent.agent.tools.initializeBuiltinTools(); // No goal yet -> mutation tools are filtered out of the model's tool list. diff --git a/packages/agent-core/test/tools/input-schema-io.test.ts b/packages/agent-core/test/tools/input-schema-io.test.ts index cbacc69038..3be6558598 100644 --- a/packages/agent-core/test/tools/input-schema-io.test.ts +++ b/packages/agent-core/test/tools/input-schema-io.test.ts @@ -14,6 +14,7 @@ import { describe, expect, it } from 'vitest'; +import { FLAG_DEFINITIONS, FlagResolver } from '../../src/flags'; import { TaskListTool } from '../../src/tools/background/task-list'; import { compileToolArgsValidator, validateToolArgs } from '../../src/tools/args-validator'; import { AskUserQuestionTool } from '../../src/tools/builtin/collaboration/ask-user'; @@ -35,9 +36,15 @@ function collectRequired(schema: unknown, acc: string[] = []): string[] { return acc; } +function askUserQuestionTool(): AskUserQuestionTool { + return new AskUserQuestionTool({ + experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS), + } as never); +} + describe('builtin tool input JSON Schema', () => { it('keeps AskUserQuestion defaulted fields out of `required`', () => { - const schema = new AskUserQuestionTool({} as never).parameters; + const schema = askUserQuestionTool().parameters; const required = collectRequired(schema); // `background`, `header`, `multi_select` and option `description` all carry `.default()` // and must therefore stay optional in the model-facing schema. @@ -60,7 +67,7 @@ describe('builtin tool input JSON Schema', () => { }); it('rejects an unknown top-level argument through runtime validation', () => { - const tool = new AskUserQuestionTool({} as never); + const tool = askUserQuestionTool(); const validator = compileToolArgsValidator(tool.parameters); const question = { question: 'Which?', @@ -75,7 +82,7 @@ describe('builtin tool input JSON Schema', () => { }); it('rejects an unknown nested argument through runtime validation', () => { - const tool = new AskUserQuestionTool({} as never); + const tool = askUserQuestionTool(); const validator = compileToolArgsValidator(tool.parameters); const question = { question: 'Which?', From fb4c83f8a92a909bd3fadd999aa984068acbdef9 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 4 Jun 2026 17:43:22 +0800 Subject: [PATCH 3/4] fix(tui): block experiments panel while busy --- apps/kimi-code/src/tui/commands/registry.ts | 2 +- apps/kimi-code/test/tui/commands/resolve.test.ts | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index b334ecfe9b..ef0abf5e48 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -119,7 +119,7 @@ export const BUILTIN_SLASH_COMMANDS = [ aliases: ['experimental'], description: 'Manage experimental features', priority: 60, - availability: 'always', + availability: 'idle-only', }, { name: 'reload', diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts index 07344bad9e..49b0586ac2 100644 --- a/apps/kimi-code/test/tui/commands/resolve.test.ts +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -84,6 +84,11 @@ describe('resolveSlashCommandInput', () => { commandName: 'reload', reason: 'streaming', }); + expect(resolve('/experiments', { isStreaming: true })).toEqual({ + kind: 'blocked', + commandName: 'experiments', + reason: 'streaming', + }); }); it('blocks model and session pickers while compacting', () => { @@ -102,6 +107,11 @@ describe('resolveSlashCommandInput', () => { commandName: 'reload', reason: 'compacting', }); + expect(resolve('/experiments', { isCompacting: true })).toEqual({ + kind: 'blocked', + commandName: 'experiments', + reason: 'compacting', + }); }); it('allows always-available built-ins while streaming', () => { @@ -130,11 +140,6 @@ describe('resolveSlashCommandInput', () => { name: 'reload-tui', args: '', }); - expect(resolve('/experiments', { isStreaming: true })).toMatchObject({ - kind: 'builtin', - name: 'experiments', - args: '', - }); expect(resolve('/btw side question', { isStreaming: true })).toMatchObject({ kind: 'builtin', name: 'btw', From 3d875613a5294f0a145b782471d23633917b97ac Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 4 Jun 2026 18:06:19 +0800 Subject: [PATCH 4/4] refactor(experimental): merge feature query APIs --- apps/kimi-code/src/cli/run-prompt.ts | 3 ++- apps/kimi-code/src/tui/commands/config.ts | 6 +++--- .../src/tui/commands/experimental-flags.ts | 14 ++++++++----- apps/kimi-code/src/tui/commands/reload.ts | 4 ++-- apps/kimi-code/src/tui/kimi-tui.ts | 4 ++-- .../src/utils/experimental-features.ts | 10 ++++++++++ apps/kimi-code/test/cli/goal-prompt.test.ts | 8 ++++---- apps/kimi-code/test/cli/run-prompt.test.ts | 4 ++-- .../test/tui/commands/experiments.test.ts | 9 +++------ apps/kimi-code/test/tui/commands/goal.test.ts | 8 ++++---- .../test/tui/commands/reload.test.ts | 12 +++++------ .../test/tui/commands/resolve.test.ts | 12 +++++------ .../test/tui/kimi-tui-message-flow.test.ts | 2 +- .../test/tui/kimi-tui-startup.test.ts | 6 +++--- .../kimi-code/test/tui/message-replay.test.ts | 2 +- packages/agent-core/src/rpc/core-api.ts | 3 +-- packages/agent-core/src/rpc/core-impl.ts | 5 ----- .../agent-core/test/harness/runtime.test.ts | 20 +++++++++---------- packages/node-sdk/src/index.ts | 2 +- packages/node-sdk/src/kimi-harness.ts | 6 ------ packages/node-sdk/src/rpc.ts | 6 ------ packages/node-sdk/test/config.test.ts | 10 ++++++---- 22 files changed, 76 insertions(+), 80 deletions(-) create mode 100644 apps/kimi-code/src/utils/experimental-features.ts diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index 37706fda74..7d41e6246e 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -19,6 +19,7 @@ import { } from '@moonshot-ai/kimi-code-sdk'; import { CLI_SHUTDOWN_TIMEOUT_MS } from '#/constant/app'; +import { experimentalFeatureMap } from '#/utils/experimental-features'; import type { CLIOptions, PromptOutputFormat } from './options'; import { @@ -146,7 +147,7 @@ export async function runPrompt( // the turn-run alive across continuation turns, so the normal prompt-turn // waiter blocks until the goal is terminal; we then emit a summary and set a // distinct exit code. - const flagMap = await harness.getExperimentalFlags(); + const flagMap = experimentalFeatureMap(await harness.getExperimentalFeatures()); const goalCreate = parseHeadlessGoalCreate(opts.prompt!, flagMap['goal_command'] === true); if (goalCreate !== undefined) { await runHeadlessGoal(session, goalCreate, goalModel, outputFormat, stdout, stderr); diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index b8d86c8eb4..3e652217e8 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -21,7 +21,7 @@ import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; import { isTheme } from '../theme/index'; import { formatErrorMessage } from '../utils/event-payload'; import { showUsage } from './info'; -import { setExperimentalFlags } from './experimental-flags'; +import { setExperimentalFeatures } from './experimental-flags'; import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- @@ -461,8 +461,8 @@ export async function applyExperimentalFeatureChanges( try { await host.harness.setConfig({ experimental }); - const flags = await host.harness.getExperimentalFlags(); - setExperimentalFlags(flags); + const features = await host.harness.getExperimentalFeatures(); + setExperimentalFeatures(features); host.refreshSlashCommandAutocomplete(); host.restoreEditor(); if (host.session !== undefined) { diff --git a/apps/kimi-code/src/tui/commands/experimental-flags.ts b/apps/kimi-code/src/tui/commands/experimental-flags.ts index e1231742cd..0800e7b363 100644 --- a/apps/kimi-code/src/tui/commands/experimental-flags.ts +++ b/apps/kimi-code/src/tui/commands/experimental-flags.ts @@ -1,12 +1,16 @@ -import type { ExperimentalFlagMap } from '@moonshot-ai/kimi-code-sdk'; +import type { ExperimentalFeatureState, ExperimentalFlagMap } from '@moonshot-ai/kimi-code-sdk'; -// Resolved experimental flags, fetched once from the core over RPC at startup and then read +import { experimentalFeatureMap } from '#/utils/experimental-features'; + +// Resolved experimental features, fetched once from the core over RPC at startup and then read // synchronously by the command palette and dispatch. App-local cache, not a source of truth. let snapshot: ExperimentalFlagMap = {}; -/** Replace the cached flag snapshot. Call once after fetching via `harness.getExperimentalFlags()`. */ -export function setExperimentalFlags(flags: ExperimentalFlagMap): void { - snapshot = flags; +/** Replace the cached flag snapshot. Call after fetching via `harness.getExperimentalFeatures()`. */ +export function setExperimentalFeatures( + features: readonly Pick[], +): void { + snapshot = experimentalFeatureMap(features); } /** An `undefined` flag means "not gated" → always enabled, so callers can pass an optional flag id. */ diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index 17100f5500..a93d7b6ecf 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -2,7 +2,7 @@ import type { KimiConfig } from '@moonshot-ai/kimi-code-sdk'; import { loadTuiConfig, type TuiConfig } from '../config'; import type { SlashCommandHost } from './dispatch'; -import { setExperimentalFlags } from './experimental-flags'; +import { setExperimentalFeatures } from './experimental-flags'; export async function handleReloadTuiCommand(host: SlashCommandHost): Promise { const tuiConfig = await loadTuiConfig(); @@ -20,7 +20,7 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise } const config = await host.harness.getConfig({ reload: true }); - setExperimentalFlags(await host.harness.getExperimentalFlags()); + setExperimentalFeatures(await host.harness.getExperimentalFeatures()); host.refreshSlashCommandAutocomplete(); applyRuntimeConfig(host, config); applyReloadedTuiConfig(host, tuiConfig); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index ffd45a23e8..cd777a0f65 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -35,7 +35,7 @@ import { BUILTIN_SLASH_COMMANDS, buildSkillSlashCommands, isExperimentalFlagEnabled, - setExperimentalFlags, + setExperimentalFeatures, sortSlashCommands, type KimiSlashCommand, type SkillListSession, @@ -479,7 +479,7 @@ export class KimiTUI { } private async init(): Promise { - setExperimentalFlags(await this.harness.getExperimentalFlags()); + setExperimentalFeatures(await this.harness.getExperimentalFeatures()); await this.authFlow.refreshAvailableModels(); void this.refreshProviderModelsInBackground(); diff --git a/apps/kimi-code/src/utils/experimental-features.ts b/apps/kimi-code/src/utils/experimental-features.ts new file mode 100644 index 0000000000..b14e083fb1 --- /dev/null +++ b/apps/kimi-code/src/utils/experimental-features.ts @@ -0,0 +1,10 @@ +import type { + ExperimentalFeatureState, + ExperimentalFlagMap, +} from '@moonshot-ai/kimi-code-sdk'; + +export function experimentalFeatureMap( + features: readonly Pick[], +): ExperimentalFlagMap { + return Object.fromEntries(features.map((feature) => [feature.id, feature.enabled])); +} diff --git a/apps/kimi-code/test/cli/goal-prompt.test.ts b/apps/kimi-code/test/cli/goal-prompt.test.ts index 53535bdd1c..b7966a5a38 100644 --- a/apps/kimi-code/test/cli/goal-prompt.test.ts +++ b/apps/kimi-code/test/cli/goal-prompt.test.ts @@ -110,7 +110,7 @@ const mocks = vi.hoisted(() => { session, eventHandlers, mainEvent, - experimentalFlags: { 'goal_command': true } as Record, + experimentalFeatures: [{ id: 'goal_command', enabled: true }], sessions: [] as Array<{ readonly id: string; readonly workDir: string }>, }; }); @@ -124,7 +124,7 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { auth: { getCachedAccessToken: vi.fn() }, ensureConfigFile: vi.fn(), getConfig: vi.fn(async () => ({ providers: {}, defaultModel: 'k2', telemetry: true })), - getExperimentalFlags: vi.fn(async () => mocks.experimentalFlags), + getExperimentalFeatures: vi.fn(async () => mocks.experimentalFeatures), createSession: vi.fn(async () => mocks.session), resumeSession: vi.fn(async () => mocks.session), listSessions: vi.fn(async () => mocks.sessions), @@ -168,7 +168,7 @@ describe('runPrompt headless goal mode', () => { beforeEach(() => { savedExitCode = process.exitCode; - mocks.experimentalFlags = { 'goal_command': true }; + mocks.experimentalFeatures = [{ id: 'goal_command', enabled: true }]; mocks.sessions = []; mocks.session.createGoal.mockClear(); mocks.session.getStatus.mockResolvedValue({ permission: 'auto', model: 'k2' } as never); @@ -238,7 +238,7 @@ describe('runPrompt headless goal mode', () => { }); it('treats /goal as a normal prompt when the flag is disabled', async () => { - mocks.experimentalFlags = {}; + mocks.experimentalFeatures = []; const stdout = writer(); const stderr = writer(); await runPrompt(opts(), 'test', { diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index 123c9920aa..9a26c5a7f0 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -54,7 +54,7 @@ const mocks = vi.hoisted(() => { telemetry: true, }), ), - harnessGetExperimentalFlags: vi.fn(async (): Promise> => ({})), + harnessGetExperimentalFeatures: vi.fn(async () => []), harnessCreateSession: vi.fn(async () => session), harnessResumeSession: vi.fn(async () => session), harnessListSessions: vi.fn(async () => [{ id: 'ses_previous', workDir: process.cwd() }]), @@ -91,7 +91,7 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { auth: { getCachedAccessToken: mocks.harnessGetCachedAccessToken }, ensureConfigFile: mocks.harnessEnsureConfigFile, getConfig: mocks.harnessGetConfig, - getExperimentalFlags: mocks.harnessGetExperimentalFlags, + getExperimentalFeatures: mocks.harnessGetExperimentalFeatures, createSession: mocks.harnessCreateSession, resumeSession: mocks.harnessResumeSession, listSessions: mocks.harnessListSessions, diff --git a/apps/kimi-code/test/tui/commands/experiments.test.ts b/apps/kimi-code/test/tui/commands/experiments.test.ts index 307b3988ce..6e823f9950 100644 --- a/apps/kimi-code/test/tui/commands/experiments.test.ts +++ b/apps/kimi-code/test/tui/commands/experiments.test.ts @@ -7,7 +7,7 @@ import { } from '#/tui/commands/config'; import { isExperimentalFlagEnabled, - setExperimentalFlags, + setExperimentalFeatures, } from '#/tui/commands/experimental-flags'; import { darkColors } from '#/tui/theme/colors'; @@ -39,7 +39,6 @@ function makeHost() { }, harness: { setConfig: vi.fn(async () => ({ providers: {} })), - getExperimentalFlags: vi.fn(async () => ({ 'goal_command': true })), getExperimentalFeatures: vi.fn(async () => [ feature({ enabled: true, source: 'config', configValue: true }), ]), @@ -55,7 +54,6 @@ function makeHost() { } as unknown as SlashCommandHost & { harness: { setConfig: ReturnType; - getExperimentalFlags: ReturnType; getExperimentalFeatures: ReturnType; }; refreshSlashCommandAutocomplete: ReturnType; @@ -72,7 +70,7 @@ function makeHost() { describe('experimental feature command handlers', () => { afterEach(() => { - setExperimentalFlags({}); + setExperimentalFeatures([]); }); it('persists config overrides, refreshes command flags, closes the panel, and reloads', async () => { @@ -85,8 +83,7 @@ describe('experimental feature command handlers', () => { expect(host.harness.setConfig).toHaveBeenCalledWith({ experimental: { 'goal_command': true }, }); - expect(host.harness.getExperimentalFlags).toHaveBeenCalled(); - expect(host.harness.getExperimentalFeatures).not.toHaveBeenCalled(); + expect(host.harness.getExperimentalFeatures).toHaveBeenCalledOnce(); expect(isExperimentalFlagEnabled('goal_command')).toBe(true); expect(host.refreshSlashCommandAutocomplete).toHaveBeenCalled(); expect(host.restoreEditor).toHaveBeenCalled(); diff --git a/apps/kimi-code/test/tui/commands/goal.test.ts b/apps/kimi-code/test/tui/commands/goal.test.ts index aa95c446a4..c285a352fd 100644 --- a/apps/kimi-code/test/tui/commands/goal.test.ts +++ b/apps/kimi-code/test/tui/commands/goal.test.ts @@ -6,7 +6,7 @@ import { goalArgumentCompletions, handleGoalCommand, parseGoalCommand, - setExperimentalFlags, + setExperimentalFeatures, } from '#/tui/commands/index'; import type { SlashCommandHost } from '#/tui/commands/dispatch'; import { getColorPalette } from '#/tui/theme/colors'; @@ -403,11 +403,11 @@ describe('handleGoalCommand', () => { describe('dispatchInput /goal integration', () => { afterEach(() => { - setExperimentalFlags({}); + setExperimentalFeatures([]); }); it('routes /goal through the real resolver, creates the goal, and sends the objective', async () => { - setExperimentalFlags({ 'goal_command': true }); + setExperimentalFeatures([{ id: 'goal_command', enabled: true }]); const { host, session } = makeHost(); dispatchInput(host, '/goal Ship feature X'); @@ -422,7 +422,7 @@ describe('dispatchInput /goal integration', () => { }); it('treats /goal as a normal message when the flag is disabled', async () => { - setExperimentalFlags({}); + setExperimentalFeatures([]); const { host, session } = makeHost(); dispatchInput(host, '/goal Ship feature X'); diff --git a/apps/kimi-code/test/tui/commands/reload.test.ts b/apps/kimi-code/test/tui/commands/reload.test.ts index dea06f0968..5d6b41f550 100644 --- a/apps/kimi-code/test/tui/commands/reload.test.ts +++ b/apps/kimi-code/test/tui/commands/reload.test.ts @@ -11,14 +11,14 @@ import { import type { SlashCommandHost } from '#/tui/commands'; import { isExperimentalFlagEnabled, - setExperimentalFlags, + setExperimentalFeatures, } from '#/tui/commands/experimental-flags'; const tempDirs: string[] = []; const originalKimiCodeHome = process.env['KIMI_CODE_HOME']; afterEach(async () => { - setExperimentalFlags({}); + setExperimentalFeatures([]); for (const dir of tempDirs.splice(0)) { await rm(dir, { recursive: true, force: true }); } @@ -50,7 +50,7 @@ auto_install = false await handleReloadTuiCommand(host); expect(host.harness.getConfig).not.toHaveBeenCalled(); - expect(host.harness.getExperimentalFlags).not.toHaveBeenCalled(); + expect(host.harness.getExperimentalFeatures).not.toHaveBeenCalled(); expect(session.reloadSession).not.toHaveBeenCalled(); expect(host.state.appState).toMatchObject({ theme: 'light', @@ -77,7 +77,7 @@ auto_install = false 'Session reloaded.', ); expect(host.harness.getConfig).toHaveBeenCalledWith({ reload: true }); - expect(host.harness.getExperimentalFlags).toHaveBeenCalledOnce(); + expect(host.harness.getExperimentalFeatures).toHaveBeenCalledOnce(); expect(host.refreshSlashCommandAutocomplete).toHaveBeenCalledOnce(); expect(isExperimentalFlagEnabled('goal_command')).toBe(true); expect(host.state.appState.theme).toBe('light'); @@ -128,7 +128,7 @@ function makeHost({ test: { type: 'kimi', apiKey: 'test-key' }, }, })), - getExperimentalFlags: vi.fn(async () => ({ goal_command: true })), + getExperimentalFeatures: vi.fn(async () => [{ id: 'goal_command', enabled: true }]), }, setAppState: vi.fn((patch: Record) => { Object.assign(state.appState, patch); @@ -143,7 +143,7 @@ function makeHost({ } as unknown as SlashCommandHost & { readonly harness: { readonly getConfig: ReturnType; - readonly getExperimentalFlags: ReturnType; + readonly getExperimentalFeatures: ReturnType; }; readonly refreshSlashCommandAutocomplete: ReturnType; readonly reloadCurrentSessionView: ReturnType; diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts index 49b0586ac2..fb54f7d9df 100644 --- a/apps/kimi-code/test/tui/commands/resolve.test.ts +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -1,7 +1,7 @@ import { resolveSkillCommand, resolveSlashCommandInput, - setExperimentalFlags, + setExperimentalFeatures, slashBusyMessage, slashCommandBusyReason, } from '#/tui/commands/index'; @@ -182,11 +182,11 @@ describe('resolveSlashCommandInput', () => { describe('goal command resolution', () => { afterEach(() => { - setExperimentalFlags({}); + setExperimentalFeatures([]); }); it('resolves /goal to the builtin command when goal_command is enabled', () => { - setExperimentalFlags({ 'goal_command': true }); + setExperimentalFeatures([{ id: 'goal_command', enabled: true }]); expect(resolve('/goal Ship feature X')).toMatchObject({ kind: 'builtin', name: 'goal', @@ -195,7 +195,7 @@ describe('goal command resolution', () => { }); it('treats /goal as a normal message when goal_command is disabled', () => { - setExperimentalFlags({}); + setExperimentalFeatures([]); expect(resolve('/goal Ship feature X')).toEqual({ kind: 'message', input: '/goal Ship feature X', @@ -203,7 +203,7 @@ describe('goal command resolution', () => { }); it('blocks goal creation while streaming', () => { - setExperimentalFlags({ 'goal_command': true }); + setExperimentalFeatures([{ id: 'goal_command', enabled: true }]); expect(resolve('/goal Ship feature X', { isStreaming: true })).toEqual({ kind: 'blocked', commandName: 'goal', @@ -212,7 +212,7 @@ describe('goal command resolution', () => { }); it('does not block status/pause/cancel/bare goal while streaming', () => { - setExperimentalFlags({ 'goal_command': true }); + setExperimentalFeatures([{ id: 'goal_command', enabled: true }]); for (const sub of ['status', 'pause', 'cancel']) { expect(resolve(`/goal ${sub}`, { isStreaming: true })).toMatchObject({ kind: 'builtin', diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 71d7a9a9ba..87cfff7f80 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -209,7 +209,7 @@ function makeHarness(session = makeSession(), overrides: Record track: vi.fn(), setTelemetryContext: vi.fn(), interactiveAgentId: 'main', - getExperimentalFlags: vi.fn(async () => ({})), + getExperimentalFeatures: vi.fn(async () => []), auth: { status: vi.fn(), login: vi.fn(), diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 4156ca2eaa..3b5a0c14ac 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -166,7 +166,7 @@ function makeHarness(session = makeSession(), overrides: Record close: vi.fn(async () => {}), track: vi.fn(), setTelemetryContext: vi.fn(), - getExperimentalFlags: vi.fn(async () => ({})), + getExperimentalFeatures: vi.fn(async () => []), auth: { status: vi.fn(async () => ({ providers: [] })), login: vi.fn(async () => {}), @@ -265,7 +265,7 @@ describe("KimiTUI startup", () => { }); const harness = makeHarness(session, { listSessions: vi.fn(async () => [{ id: "ses-latest" }]), - getExperimentalFlags: vi.fn(async () => ({ "goal_command": true })), + getExperimentalFeatures: vi.fn(async () => [{ id: "goal_command", enabled: true }]), }); const driver = makeDriver(harness, makeStartupInput({ continue: true })); @@ -294,7 +294,7 @@ describe("KimiTUI startup", () => { getGoal: vi.fn(async () => ({ goal })), }); const harness = makeHarness(session, { - getExperimentalFlags: vi.fn(async () => ({ "goal_command": true })), + getExperimentalFeatures: vi.fn(async () => [{ id: "goal_command", enabled: true }]), }); const driver = makeDriver(harness, makeStartupInput()) as unknown as RuntimeStateDriver; diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index df66fbd3af..81def12375 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -167,7 +167,7 @@ function makeHarness(initialSession: Session) { close: vi.fn(async () => {}), track: vi.fn(), setTelemetryContext: vi.fn(), - getExperimentalFlags: vi.fn(async () => ({})), + getExperimentalFeatures: vi.fn(async () => []), interactiveAgentId: 'main', auth: { status: vi.fn(), diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 0f9c559ff5..da186c7218 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -5,7 +5,7 @@ import type { PermissionData, PermissionMode } from '#/agent/permission'; import type { PlanData } from '#/agent/plan'; import type { ToolInfo } from '#/agent/tool'; import type { KimiConfig, KimiConfigPatch, McpServerConfig } from '#/config'; -import type { ExperimentalFeatureState, ExperimentalFlagMap } from '#/flags'; +import type { ExperimentalFeatureState } from '#/flags'; import type { ResumeSessionResult } from '#/rpc/resumed'; import type { SessionMeta } from '#/session'; import type { @@ -357,7 +357,6 @@ type SessionAPIWithId = WithSessionId; export interface CoreAPI extends SessionAPIWithId { getCoreInfo: (payload: EmptyPayload) => CoreInfo; - getExperimentalFlags: (payload: EmptyPayload) => ExperimentalFlagMap; getExperimentalFeatures: (payload: EmptyPayload) => readonly ExperimentalFeatureState[]; getKimiConfig: (payload: GetKimiConfigPayload) => KimiConfig; setKimiConfig: (payload: SetKimiConfigPayload) => KimiConfig; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 378b6bedd5..61cb011316 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -26,7 +26,6 @@ import { FLAG_DEFINITIONS, FlagResolver, type ExperimentalFeatureState, - type ExperimentalFlagMap, } from '../flags'; import type { Logger } from '../logging/types'; import { resolveSessionMcpConfig, mergeCallerMcpServers, type SessionMcpConfig } from '../mcp'; @@ -270,10 +269,6 @@ export class KimiCore implements PromisableMethods { return { version: getCoreVersion() }; } - getExperimentalFlags(): ExperimentalFlagMap { - return this.experimentalFlags.snapshot(); - } - getExperimentalFeatures(): readonly ExperimentalFeatureState[] { return this.experimentalFlags.explainAll(); } diff --git a/packages/agent-core/test/harness/runtime.test.ts b/packages/agent-core/test/harness/runtime.test.ts index 33b4236fbd..4030c85180 100644 --- a/packages/agent-core/test/harness/runtime.test.ts +++ b/packages/agent-core/test/harness/runtime.test.ts @@ -35,6 +35,10 @@ function clearExperimentalEnv(): void { } } +function experimentalFeatureEnabled(core: KimiCore, id: string): boolean | undefined { + return core.getExperimentalFeatures().find((feature) => feature.id === id)?.enabled; +} + describe('KimiCore runtime config', () => { let tmp: string; @@ -97,14 +101,10 @@ background_ask = true const first = new KimiCore(async () => ({}) as never, { homeDir: firstHome }); const second = new KimiCore(async () => ({}) as never, { homeDir: secondHome }); - expect(first.getExperimentalFlags()).toMatchObject({ - 'goal_command': true, - 'background_ask': false, - }); - expect(second.getExperimentalFlags()).toMatchObject({ - 'goal_command': false, - 'background_ask': true, - }); + expect(experimentalFeatureEnabled(first, 'goal_command')).toBe(true); + expect(experimentalFeatureEnabled(first, 'background_ask')).toBe(false); + expect(experimentalFeatureEnabled(second, 'goal_command')).toBe(false); + expect(experimentalFeatureEnabled(second, 'background_ask')).toBe(true); }); it('updates the scoped experimental resolver after setKimiConfig', async () => { @@ -121,7 +121,7 @@ goal_command = false clearExperimentalEnv(); const core = new KimiCore(async () => ({}) as never, { homeDir }); - expect(core.getExperimentalFlags()['goal_command']).toBe(false); + expect(experimentalFeatureEnabled(core, 'goal_command')).toBe(false); await core.setKimiConfig({ experimental: { @@ -129,7 +129,7 @@ goal_command = false }, }); - expect(core.getExperimentalFlags()['goal_command']).toBe(true); + expect(experimentalFeatureEnabled(core, 'goal_command')).toBe(true); }); it('updates the shared experimental resolver without hot-refreshing materialized tools', async () => { diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index 2cf0bb16e0..deb51ef0d0 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -58,7 +58,7 @@ export type { LogContext, LogLevel, LogPayload, Logger } from '@moonshot-ai/agen export { buildGoalCompletionMessage } from '@moonshot-ai/agent-core'; // Experimental feature flags — types only. Resolved values come from -// `KimiHarness.getExperimentalFlags()` over RPC, not from a re-exported runtime value. +// `KimiHarness.getExperimentalFeatures()` over RPC, not from a re-exported runtime value. export type { ExperimentalFeatureState, ExperimentalFlagMap, diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index ce65f06d99..86175843c1 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -3,7 +3,6 @@ import { KimiError, withTelemetryContext, type ExperimentalFeatureState, - type ExperimentalFlagMap, } from '@moonshot-ai/agent-core'; import { Session } from '#/session'; @@ -203,11 +202,6 @@ export class KimiHarness { return this.rpc.getConfig(options); } - /** Resolved enabled-state of every experimental flag (flag id → enabled). */ - async getExperimentalFlags(): Promise { - return this.rpc.getExperimentalFlags(); - } - async getExperimentalFeatures(): Promise { return this.rpc.getExperimentalFeatures(); } diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 4348d497db..c3a220ea87 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -7,7 +7,6 @@ import { type CoreAPI, type Event, type ExperimentalFeatureState, - type ExperimentalFlagMap, type QuestionRequest, type QuestionResult, type RPCMethods, @@ -162,11 +161,6 @@ export abstract class SDKRpcClientBase { return rpc.getKimiConfig(input ?? {}); } - async getExperimentalFlags(): Promise { - const rpc = await this.getRpc(); - return rpc.getExperimentalFlags({}); - } - async getExperimentalFeatures(): Promise { const rpc = await this.getRpc(); return rpc.getExperimentalFeatures({}); diff --git a/packages/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index 6c60b5f9d2..6dc6e0a55d 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -314,10 +314,12 @@ background_ask = false configValue: true, env: 'KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND', }); - await expect(harness.getExperimentalFlags()).resolves.toMatchObject({ - 'goal_command': true, - 'background_ask': false, - }); + expect(features).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'goal_command', enabled: true }), + expect.objectContaining({ id: 'background_ask', enabled: false }), + ]), + ); }); it('can create the default config scaffold without selecting a model', async () => {