diff --git a/.changeset/session-rating-survey.md b/.changeset/session-rating-survey.md new file mode 100644 index 00000000000..a2ac6ba4247 --- /dev/null +++ b/.changeset/session-rating-survey.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add an occasional session rating prompt above the input box. diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index dc32fe2af1a..4845c47a479 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -140,6 +140,7 @@ export async function runShell( migrationPlan, migrateOnly: runOptions.migrateOnly, engineV2, + telemetryDisabled: config.telemetry === false, }); initializeCliTelemetry({ diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index e585a821ae3..c5f7bf52b09 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -64,6 +64,7 @@ export const KIMI_CODE_UPDATE_REEXEC_ENV = 'KIMI_CODE_UPDATE_REEXEC'; export const KIMI_CODE_INPUT_HISTORY_DIR_NAME = 'user-history'; export const KIMI_CODE_BANNER_DIR_NAME = 'banner'; export const KIMI_CODE_BANNER_STATE_FILE_NAME = 'state.json'; +export const KIMI_CODE_SURVEY_STATE_FILE_NAME = 'feedback-survey-state.json'; // Managed Kimi auth provider key shared with OAuth/SDK config. export const DEFAULT_OAUTH_PROVIDER_NAME = 'managed:kimi-code'; diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index c798983aaa8..10086bf8123 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -19,6 +19,7 @@ import { modelDisplayName, segmentsFor } from '../components/dialogs/model-selec import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector'; import { PermissionSelectorComponent } from '../components/dialogs/permission-selector'; import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector'; +import { SurveyPreferenceSelectorComponent } from '../components/dialogs/survey-preference-selector'; import { ThemeSelectorComponent } from '../components/dialogs/theme-selector'; import { UpdatePreferenceSelectorComponent } from '../components/dialogs/update-preference-selector'; import { DEFAULT_TUI_CONFIG, saveTuiConfig, type TuiConfig } from '../config'; @@ -60,6 +61,8 @@ export function currentTuiConfig(host: Pick): TuiConf disablePasteBurst: host.state.appState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, renderLatex: host.state.appState.renderLatex ?? DEFAULT_TUI_CONFIG.renderLatex ?? true, cacheExpiryHint: host.state.appState.cacheExpiryHint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint, + disableFeedbackSurvey: + host.state.appState.disableFeedbackSurvey ?? DEFAULT_TUI_CONFIG.disableFeedbackSurvey, notifications: host.state.appState.notifications, upgrade: host.state.appState.upgrade, statusLine: host.state.appState.statusLine ?? DEFAULT_TUI_CONFIG.statusLine, @@ -916,6 +919,61 @@ async function applyPermissionChoice(host: SlashCommandHost, mode: PermissionMod } } +export function showSurveyPreferencePicker(host: SlashCommandHost): void { + host.mountEditorReplacement( + new SurveyPreferenceSelectorComponent({ + currentValue: host.state.appState.disableFeedbackSurvey !== true, + onSelect: (value) => { + host.restoreEditor(); + void applySurveyPreferenceChoice(host, value); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +type SurveyPreferenceHost = { + readonly state: { + readonly appState: Pick< + SlashCommandHost['state']['appState'], + 'theme' | 'editorCommand' | 'notifications' | 'upgrade' | 'disableFeedbackSurvey' + >; + }; + setAppState( + patch: Pick, + ): void; + showStatus(msg: string, color?: string): void; +}; + +export async function applySurveyPreferenceChoice( + host: SurveyPreferenceHost, + enabled: boolean, +): Promise { + const disableFeedbackSurvey = !enabled; + if (disableFeedbackSurvey === (host.state.appState.disableFeedbackSurvey === true)) { + host.showStatus(`Feedback survey already ${enabled ? 'enabled' : 'disabled'}.`); + return; + } + + try { + await saveTuiConfig({ + ...currentTuiConfig(host as unknown as SlashCommandHost), + disableFeedbackSurvey, + }); + } catch (error) { + host.showStatus( + `Failed to save session rating setting: ${formatErrorMessage(error)}`, + 'error', + ); + return; + } + + host.setAppState({ disableFeedbackSurvey }); + host.showStatus(`Feedback survey ${enabled ? 'enabled' : 'disabled'}.`); +} + export function showSettingsSelector(host: SlashCommandHost): void { host.mountEditorReplacement( new SettingsSelectorComponent({ @@ -936,6 +994,7 @@ function handleSettingsSelection(host: SlashCommandHost, value: SettingsSelectio case 'permission': showPermissionPicker(host); return; case 'theme': showThemePicker(host); return; case 'editor': showEditorPicker(host); return; + case 'survey': showSurveyPreferencePicker(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/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index 81b95ee484f..608c947c40e 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -73,6 +73,7 @@ export async function applyReloadedTuiConfig( disablePasteBurst: config.disablePasteBurst, renderLatex: config.renderLatex, cacheExpiryHint: config.cacheExpiryHint, + disableFeedbackSurvey: config.disableFeedbackSurvey, notifications: config.notifications, upgrade: config.upgrade, statusLine: config.statusLine, 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 81e4b8d1251..581407d57a8 100644 --- a/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts @@ -5,6 +5,7 @@ export type SettingsSelection = | 'theme' | 'editor' | 'permission' + | 'survey' | 'experiments' | 'upgrade' | 'usage'; @@ -30,6 +31,11 @@ const SETTINGS_OPTIONS: readonly ChoiceOption[] = [ label: 'Editor', description: 'Set the external editor command.', }, + { + value: 'survey', + label: 'Feedback survey', + description: 'Turn the occasional session rating prompt on or off.', + }, { value: 'experiments', label: 'Experiments', @@ -53,6 +59,7 @@ function isSettingsSelection(value: string): value is SettingsSelection { value === 'theme' || value === 'editor' || value === 'permission' || + value === 'survey' || value === 'experiments' || value === 'upgrade' || value === 'usage' diff --git a/apps/kimi-code/src/tui/components/dialogs/survey-preference-selector.ts b/apps/kimi-code/src/tui/components/dialogs/survey-preference-selector.ts new file mode 100644 index 00000000000..0ef788e8831 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/survey-preference-selector.ts @@ -0,0 +1,34 @@ +import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; + +const SURVEY_PREFERENCE_OPTIONS: readonly ChoiceOption[] = [ + { + value: 'on', + label: 'On', + description: 'Show the occasional rating prompt above the editor.', + }, + { + value: 'off', + label: 'Off', + description: 'Never show the rating prompt.', + }, +]; + +export interface SurveyPreferenceSelectorOptions { + readonly currentValue: boolean; + readonly onSelect: (value: boolean) => void; + readonly onCancel: () => void; +} + +export class SurveyPreferenceSelectorComponent extends ChoicePickerComponent { + constructor(opts: SurveyPreferenceSelectorOptions) { + super({ + title: 'Feedback survey', + options: [...SURVEY_PREFERENCE_OPTIONS], + currentValue: opts.currentValue ? 'on' : 'off', + onSelect: (value) => { + opts.onSelect(value === 'on'); + }, + onCancel: opts.onCancel, + }); + } +} diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index 2a280209325..9c8aa8b9f49 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -125,6 +125,7 @@ export class CustomEditor extends Editor { * double-Esc so only two consecutive Escape presses trigger the shortcut. */ public onNonEscapeInput?: () => void; + public onPreInput?: (data: string) => boolean; public onCtrlD?: () => void; public onCtrlC?: () => void; public onToggleToolExpand?: () => void; @@ -256,7 +257,7 @@ export class CustomEditor extends Editor { return false; } - private hasAutocompleteActivity(): boolean { + public hasAutocompleteActivity(): boolean { const autocomplete = this as unknown as AutocompleteInternals; return ( this.isShowingAutocomplete() || @@ -382,6 +383,10 @@ export class CustomEditor extends Editor { this.onNonEscapeInput?.(); } + if (this.onPreInput?.(normalized) === true) { + return; + } + // When a paste marker was just expanded, discard the trailing bracketed // paste data that the terminal sends alongside the Ctrl-V keystroke. if (this.consumingPaste) { diff --git a/apps/kimi-code/src/tui/components/panes/survey-panel.ts b/apps/kimi-code/src/tui/components/panes/survey-panel.ts new file mode 100644 index 00000000000..046b72f4d63 --- /dev/null +++ b/apps/kimi-code/src/tui/components/panes/survey-panel.ts @@ -0,0 +1,96 @@ +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from '@moonshot-ai/pi-tui'; + +import { + SURVEY_MIN_OPTIONS_WIDTH, + SURVEY_OPTION_GAP, + SURVEY_OPTION_LABELS, + SURVEY_QUESTION, +} from '../../constant/survey'; + +import { currentTheme } from '../../theme'; +import type { SurveyResponse } from '../../utils/survey-policy'; + +export type SurveyPanelPhase = 'open' | 'pending' | 'thanks'; + +export interface SurveyPanelView { + phase: SurveyPanelPhase; + response?: Exclude; + hoverIndex?: number; +} + +const DOT = '●'; +const DOT_PREFIX_WIDTH = 2; +const OPTION_INDENT = ' '; + +const RESPONSE_LABELS: Record, string> = { + bad: 'Bad', + fine: 'Fine', + good: 'Good', +}; +const THANKS = 'Thanks for your feedback!'; + +export class SurveyPanelComponent implements Component { + constructor(private readonly view: SurveyPanelView) {} + + invalidate(): void {} + + render(width: number): string[] { + if (width < 1) return ['']; + switch (this.view.phase) { + case 'open': + return this.renderOpen(width); + case 'pending': { + const label = + this.view.response === undefined ? '' : RESPONSE_LABELS[this.view.response]; + return this.renderStatusLine(width, currentTheme.fg('textDim', `Feedback: ${label} · [escape: undo]`)); + } + case 'thanks': + return this.renderStatusLine(width, currentTheme.fg('success', THANKS)); + } + } + + private renderOpen(width: number): string[] { + const title = wrapTextWithAnsi(SURVEY_QUESTION, Math.max(1, width - DOT_PREFIX_WIDTH)).map( + (line, index) => + (index === 0 ? this.dotPrefix() : ' '.repeat(DOT_PREFIX_WIDTH)) + + currentTheme.boldFg('textStrong', line), + ); + const optionsLine = OPTION_INDENT + this.styledOptions(); + if (visibleWidth(optionsLine) <= width) { + return [...title, optionsLine]; + } + if (width >= SURVEY_MIN_OPTIONS_WIDTH) { + return [ + ...title, + ...this.styledOptionsPerLine().map((option) => OPTION_INDENT + option), + ]; + } + return title; + } + + private renderStatusLine(width: number, styledText: string): string[] { + return [truncateToWidth(this.dotPrefix() + styledText, width)]; + } + + private dotPrefix(): string { + return currentTheme.fg('accent', DOT) + ' '; + } + + private styledOptions(): string { + return SURVEY_OPTION_LABELS.map((label, index) => this.styleOption(label, index)).join( + ' '.repeat(SURVEY_OPTION_GAP), + ); + } + + private styledOptionsPerLine(): string[] { + return SURVEY_OPTION_LABELS.map((label, index) => this.styleOption(label, index)); + } + + private styleOption(label: string, index: number): string { + if (this.view.hoverIndex === index) { + return currentTheme.bg('border', currentTheme.boldFg('textStrong', label)); + } + return currentTheme.fg('text', label); + } +} diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index 5a08af8ff77..e09a8fe28e6 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -56,6 +56,7 @@ export const TuiConfigFileSchema = z.object({ render_latex: z.boolean().optional(), disable_paste_burst: z.boolean().optional(), cache_expiry_hint: z.boolean().optional(), + disable_feedback_survey: z.boolean().optional(), editor: z .object({ command: z.string().optional(), @@ -84,6 +85,7 @@ export const TuiConfigSchema = z.object({ /** Present in every normalized config; optional only so hand-built test * fixtures from before this field existed still typecheck. */ cacheExpiryHint: z.boolean().optional(), + disableFeedbackSurvey: z.boolean().optional(), editorCommand: z.string().nullable(), notifications: NotificationsConfigSchema, upgrade: UpgradePreferencesSchema, @@ -111,6 +113,7 @@ export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, + disableFeedbackSurvey: false, editorCommand: null, notifications: DEFAULT_NOTIFICATIONS_CONFIG, upgrade: DEFAULT_UPGRADE_PREFERENCES, @@ -198,6 +201,8 @@ export function normalizeTuiConfig( renderLatex: config.render_latex ?? DEFAULT_TUI_CONFIG.renderLatex, disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, cacheExpiryHint: config.cache_expiry_hint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint, + disableFeedbackSurvey: + config.disable_feedback_survey ?? DEFAULT_TUI_CONFIG.disableFeedbackSurvey, editorCommand: command === undefined || command.length === 0 ? null : command, notifications: { enabled: config.notifications?.enabled ?? DEFAULT_NOTIFICATIONS_CONFIG.enabled, @@ -248,6 +253,7 @@ theme = "${escapeTomlBasicString(config.theme)}" # "auto" | "dark" | "light" | c render_latex = ${String(config.renderLatex !== false)} # false keeps LaTeX math in assistant messages as raw source disable_paste_burst = ${String(config.disablePasteBurst)} # true disables non-bracketed paste-burst fallback cache_expiry_hint = ${String(config.cacheExpiryHint !== false)} # false disables the "cache expired" dialog on resume / idle submit +disable_feedback_survey = ${String(config.disableFeedbackSurvey === true)} # true hides the occasional session rating prompt [editor] command = "${escapeTomlBasicString(config.editorCommand ?? '')}" # Empty uses $VISUAL / $EDITOR diff --git a/apps/kimi-code/src/tui/constant/survey.ts b/apps/kimi-code/src/tui/constant/survey.ts new file mode 100644 index 00000000000..dd11b3f8638 --- /dev/null +++ b/apps/kimi-code/src/tui/constant/survey.ts @@ -0,0 +1,71 @@ +export const SURVEY_IDLE_EVALUATION_DELAY_MS = 2000; + +export const SURVEY_IDLE_STABILITY_MS = 2000; + +export const SURVEY_MOUNT_PROTECTION_MS = 600; + +export const SURVEY_DIGIT_DEBOUNCE_MS = 400; + +export const SURVEY_PENDING_UNDO_WINDOW_MS = 3000; + +export const SURVEY_THANKS_DURATION_MS = 5000; + +export const SURVEY_CONFIG_REFRESH_INTERVAL_MS = 3_600_000; + +export const SURVEY_MIN_OPTIONS_WIDTH = 12; + +export const SURVEY_QUESTION = 'How is Kimi doing this session? (optional)'; + +export const SURVEY_OPTION_LABELS = ['1: Bad', '2: Fine', '3: Good', '0: Dismiss'] as const; + +export const SURVEY_OPTION_GAP = 2; + +const SURVEY_SPACER_ROWS = 1; +const SURVEY_PANEL_HORIZONTAL_CHROME = 2; +const SURVEY_EDITOR_MIN_ROWS = 3; +const SURVEY_TRANSCRIPT_MIN_ROWS = 1; +const SURVEY_FOOTER_MIN_ROWS = 1; + +export function surveyMinTotalHeight(contentWidth: number): number { + const innerWidth = Math.max(1, contentWidth - SURVEY_PANEL_HORIZONTAL_CHROME); + const inlineWidth = + SURVEY_OPTION_LABELS.reduce((total, label) => total + label.length, 0) + + SURVEY_OPTION_GAP * (SURVEY_OPTION_LABELS.length - 1); + const optionsRows = innerWidth >= inlineWidth ? 1 : SURVEY_OPTION_LABELS.length; + return ( + SURVEY_SPACER_ROWS + + surveyQuestionRows(innerWidth) + + optionsRows + + SURVEY_EDITOR_MIN_ROWS + + SURVEY_TRANSCRIPT_MIN_ROWS + + SURVEY_FOOTER_MIN_ROWS + ); +} + +function surveyQuestionRows(width: number): number { + let rows = 1; + let lineLength = 0; + for (const word of SURVEY_QUESTION.split(' ')) { + if (lineLength > 0 && lineLength + 1 + word.length > width) { + rows += 1; + lineLength = word.length; + } else { + lineLength += (lineLength > 0 ? 1 : 0) + word.length; + } + } + return rows; +} + +export const SURVEY_ORDERED_LIST_START = /^[ \t]*\d{1,2}[.)][ \t]/m; + +export const SURVEY_SINGLE_OPTION_DIGIT = /^[0-3]$/; + +export const SURVEY_OPTION_COUNT = 4; + +export const SURVEY_DISMISS_OPTION_INDEX = SURVEY_OPTION_COUNT - 1; + +export const SURVEY_DIGIT_RESPONSES: Record = { + '1': 'bad', + '2': 'fine', + '3': 'good', +}; diff --git a/apps/kimi-code/src/tui/controllers/btw-panel.ts b/apps/kimi-code/src/tui/controllers/btw-panel.ts index 186e1b85bd1..2a46f0e9d7e 100644 --- a/apps/kimi-code/src/tui/controllers/btw-panel.ts +++ b/apps/kimi-code/src/tui/controllers/btw-panel.ts @@ -55,6 +55,10 @@ export class BtwPanelController { panel.submit(initialPrompt, inlineSkillActivations); } + isActive(): boolean { + return this.active !== undefined; + } + clear(): void { const active = this.active; if (active !== undefined && this.shouldCancelOnUnmount(active.panel)) { diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index e4474e7425f..669065bda78 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -19,6 +19,7 @@ import { LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE, } from '../constant/kimi-tui'; +import { Key, matchesKey } from '@moonshot-ai/pi-tui'; import { MEDIA_STAGING_TTL_SECONDS } from '../constant/media'; import { formatErrorMessage } from '../utils/event-payload'; import type { @@ -31,6 +32,7 @@ import { extractInlineSkillActivations } from '../utils/inline-skill-tokens'; import type { PendingExit, QueuedMessage, SteerInputItem } from '../types'; import type { TUIState } from '../tui-state'; import type { BtwPanelController } from './btw-panel'; +import type { SurveyController } from './survey-controller'; export interface EditorKeyboardHost { state: TUIState; @@ -52,6 +54,7 @@ export interface EditorKeyboardHost { handleUserInput(text: string): void; readonly btwPanelController: BtwPanelController; + readonly surveyController: SurveyController; readonly skillCommandMap: Map; steerMessage(session: Session, input: readonly SteerInputItem[]): void; steerSkillActivation(session: Session, skillName: string, skillArgs: string): void; @@ -97,11 +100,20 @@ export class EditorKeyboardController { const editor = host.state.editor; editor.onSubmit = (text: string) => { + if (host.surveyController.handleSubmit(text)) return; host.handleUserInput(text); }; + editor.onPreInput = (data: string) => { + if (matchesKey(data, Key.escape)) this.clearPendingExit(); + const consumed = host.surveyController.handlePreInput(data); + if (consumed) this.clearPendingUndoEsc(); + return consumed; + }; + editor.onChange = (text: string) => { if (this.pendingExit) this.clearPendingExit(); + host.surveyController.handleEditorChange(text); host.updateEditorBorderHighlight(text); // Expanding paste markers costs a full-text pass, and only `/goal` // input can trip the objective length limit — so skip the expansion @@ -282,6 +294,7 @@ export class EditorKeyboardController { }; editor.onOpenExternalEditor = () => { + host.surveyController.closeSilently(); host.track('shortcut_editor'); void this.openExternalEditor(); }; diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 0d623d01c2b..7c35e98fa20 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -77,15 +77,17 @@ import { nextTranscriptId } from '../utils/transcript-id'; import type { BtwPanelController } from './btw-panel'; import { isPluginMcpToolName, PluginUpdateNotifier } from './plugin-update-notifier'; import type { StreamingUIController } from './streaming-ui'; +import type { SurveyController } from './survey-controller'; import type { TasksBrowserController } from './tasks-browser'; import { SubAgentEventHandler } from './subagent-event-handler'; -import type { - AppState, - LivePaneState, - QueuedMessage, - ToolCallBlockData, - ToolResultBlockData, - TranscriptEntry, +import { + sumTokenUsage, + type AppState, + type LivePaneState, + type QueuedMessage, + type ToolCallBlockData, + type ToolResultBlockData, + type TranscriptEntry, } from '../types'; import type { TUIState } from '../tui-state'; import { createGoal as startGoalCommand } from '../commands/goal'; @@ -123,6 +125,7 @@ export interface SessionEventHost { handleTurnEnded?(event: TurnEndedEvent): void; readonly btwPanelController: BtwPanelController; readonly tasksBrowserController: TasksBrowserController; + readonly surveyController: SurveyController; } export class SessionEventHandler { @@ -610,6 +613,7 @@ export class SessionEventHandler { private handleToolCall(event: ToolCallStartedEvent): void { const { streamingUI } = this.host; + this.host.surveyController.notifyToolCallStarted(); streamingUI.flushNow(); const { turnId, step } = streamingUI.getTurnContext(); const toolCall: ToolCallBlockData = { @@ -734,6 +738,9 @@ export class SessionEventHandler { } if (event.model !== undefined) patch.model = event.model; if (event.thinkingEffort !== undefined) patch.thinkingEffort = event.thinkingEffort; + if (event.usage?.total !== undefined) { + patch.cumulativeTokens = sumTokenUsage(event.usage.total); + } if (Object.keys(patch).length > 0) this.host.setAppState(patch); if (event.swarmMode === false) { this.host.state.swarmModeEntry = undefined; @@ -1130,6 +1137,7 @@ export class SessionEventHandler { // is expected). Cancellations do neither: the context was not cut. this.host.recordSessionActivity(); this.host.noteCompactionFinished(); + this.host.surveyController.notifyCompactionFinished(); this.finishCompaction(sendQueued); } diff --git a/apps/kimi-code/src/tui/controllers/survey-controller.ts b/apps/kimi-code/src/tui/controllers/survey-controller.ts new file mode 100644 index 00000000000..fb71633946b --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/survey-controller.ts @@ -0,0 +1,682 @@ +import { randomUUID } from 'node:crypto'; + +import { isTelemetryDisabledByEnv } from '@moonshot-ai/kimi-telemetry'; +import { Key, matchesKey, Spacer } from '@moonshot-ai/pi-tui'; + +import { + getSurveyPopupConfig, + peekSurveyPopupConfig, + peekSurveyPopupConfigFresh, + type SurveyPopupConfig, +} from '#/utils/survey-popup-config'; +import { readSurveyLastShownTime, writeSurveyLastShownTime } from '#/utils/survey-state-store'; +import { currentKimiRegion } from '#/utils/region'; + +import { SurveyPanelComponent, type SurveyPanelView } from '../components/panes/survey-panel'; +import { CHROME_GUTTER } from '../constant/rendering'; +import { printableChar } from '../utils/printable-key'; +import { + SURVEY_DIGIT_DEBOUNCE_MS, + SURVEY_IDLE_EVALUATION_DELAY_MS, + SURVEY_MOUNT_PROTECTION_MS, + SURVEY_PENDING_UNDO_WINDOW_MS, + SURVEY_ORDERED_LIST_START, + SURVEY_SINGLE_OPTION_DIGIT, + SURVEY_OPTION_COUNT, + SURVEY_DISMISS_OPTION_INDEX, + SURVEY_DIGIT_RESPONSES, + SURVEY_MIN_OPTIONS_WIDTH, + surveyMinTotalHeight, + SURVEY_CONFIG_REFRESH_INTERVAL_MS, + SURVEY_THANKS_DURATION_MS, +} from '../constant/survey'; +import type { TUIState } from '../tui-state'; +import { + buildSurveyEventProperties, + evaluateSurveyGate, + SURVEY_EVENT_NAMES, + SURVEY_MACHINE_CLOSED, + surveyMachineReduce, + type LongContextArmGateInput, + type SessionArmGateInput, + type SharedArmGateInput, + type SurveyAppearance, + type SurveyEventEnvironmentFields, + type SurveyKind, + type SurveyMachineAction, + type SurveyMachineEffect, + type SurveyMachineState, +} from '../utils/survey-policy'; +import type { BtwPanelController } from './btw-panel'; + +export interface SurveyHost { + readonly state: TUIState; + readonly btwPanelController: BtwPanelController; + track(event: string, props?: Record): void; +} + +export interface SurveyControllerDeps { + readonly config?: () => SurveyPopupConfig; + readonly monotonicNow?: () => number; + readonly wallNow?: () => number; + readonly random?: () => number; + readonly appearanceId?: () => string; + readonly setTimer?: (fn: () => void, ms: number) => unknown; + readonly clearTimer?: (handle: unknown) => void; + readonly telemetryDisabled?: () => boolean; + readonly feedbackSurveyDisabled?: () => boolean; + readonly refreshConfig?: () => unknown; + readonly terminalHeight?: () => number; + readonly configFresh?: () => boolean; + readonly terminalWidth?: () => number; + readonly configRegion?: () => string; + readonly accessToken?: () => Promise; + readonly readGlobalLastShown?: () => Promise; + readonly writeGlobalLastShown?: (wallTime: number) => void; +} + +const defaultDeps = { + monotonicNow: () => performance.now(), + wallNow: () => Date.now(), + random: () => Math.random(), + appearanceId: () => randomUUID(), + setTimer: (fn: () => void, ms: number) => setTimeout(fn, ms), + clearTimer: (handle: unknown) => { + clearTimeout(handle as Parameters[0]); + }, + telemetryDisabled: () => isTelemetryDisabledByEnv(), + config: () => peekSurveyPopupConfig(), + configFresh: () => peekSurveyPopupConfigFresh(), + terminalHeight: () => process.stdout.rows, + configRegion: () => currentKimiRegion(), + terminalWidth: () => process.stdout.columns, + readGlobalLastShown: readSurveyLastShownTime, + writeGlobalLastShown: writeSurveyLastShownTime, +} satisfies Omit< + Required, + 'feedbackSurveyDisabled' | 'refreshConfig' | 'accessToken' +>; + +export class SurveyController { + private machine: SurveyMachineState = SURVEY_MACHINE_CLOSED; + private readonly view: SurveyPanelView = { phase: 'open' }; + private mounted = false; + private mountedAt: number; + private userTurnCount = 0; + private lastShownAt: number | undefined; + private userTurnsAtLastShown: number | undefined; + private appearanceCount = 0; + private globalLastShownAt: number | undefined; + private longContextRollConsumed = false; + private generation = 0; + private idleSince: number | undefined; + private openedAt = 0; + private stickySample: { readonly turnCount: number; readonly value: number } | undefined; + private lastTypedDigit: string | undefined; + private openedEditorText: string | undefined; + private idleTimer: unknown; + private digitTimer: unknown; + private phaseTimer: unknown; + private toolCallCount = 0; + private compactionCount = 0; + private currentTurnUserOrigin: boolean | undefined; + private evaluationPending = false; + private appearanceConfig: SurveyPopupConfig | undefined; + private configReady = false; + private cooldownReady = false; + private configRefreshedAt = 0; + private coldRefreshAttemptedAt = 0; + private configRegion: string | undefined; + + constructor( + private readonly host: SurveyHost, + private readonly deps: SurveyControllerDeps = {}, + ) { + this.mountedAt = this.now(); + this.reset(); + this.refreshConfig(); + this.coldRefreshAttemptedAt = this.now(); + } + + reset(): void { + this.generation += 1; + this.clearIdleTimer(); + this.clearDigitTimer(); + this.clearPhaseTimer(); + this.applyClose(); + this.machine = SURVEY_MACHINE_CLOSED; + this.mountedAt = this.now(); + this.userTurnCount = 0; + this.lastShownAt = undefined; + this.userTurnsAtLastShown = undefined; + this.appearanceCount = 0; + this.longContextRollConsumed = false; + this.idleSince = undefined; + this.stickySample = undefined; + this.toolCallCount = 0; + this.compactionCount = 0; + this.appearanceConfig = undefined; + this.currentTurnUserOrigin = undefined; + this.evaluationPending = false; + const generation = this.generation; + this.cooldownReady = false; + void (this.deps.readGlobalLastShown ?? defaultDeps.readGlobalLastShown)() + .then((lastShown) => { + if (this.generation !== generation) return; + if (lastShown !== undefined) { + this.globalLastShownAt = Math.max(lastShown, this.globalLastShownAt ?? 0); + } + this.cooldownReady = true; + }) + .catch(() => { + if (this.generation === generation) this.cooldownReady = true; + }); + } + + dispose(): void { + this.generation += 1; + this.clearIdleTimer(); + this.clearDigitTimer(); + this.clearPhaseTimer(); + this.applyClose(); + } + + notifyTurnStarted(userOrigin: boolean): void { + this.currentTurnUserOrigin = userOrigin; + this.idleSince = undefined; + this.clearIdleTimer(); + if (this.machine.phase !== 'closed') this.applyAction({ type: 'close-silently' }); + if (userOrigin) { + this.userTurnCount += 1; + this.evaluationPending = false; + } + } + + notifyTurnEnded(): void { + if (this.currentTurnUserOrigin === true) this.evaluationPending = true; + this.currentTurnUserOrigin = undefined; + if (!this.evaluationPending) return; + this.idleSince = this.now(); + this.clearIdleTimer(); + this.idleTimer = this.setT(() => { + this.idleTimer = undefined; + this.evaluationPending = false; + this.evaluate(); + }, SURVEY_IDLE_EVALUATION_DELAY_MS); + } + + notifyToolCallStarted(): void { + this.toolCallCount += 1; + } + + notifyCompactionFinished(): void { + this.compactionCount += 1; + } + + notifyInputModeChanged(mode: 'prompt' | 'bash'): void { + if (mode !== 'bash') return; + if (this.machine.phase === 'closed') return; + if (this.machine.phase === 'open') { + this.applyAction({ type: 'abandon' }); + return; + } + this.applyAction({ type: 'close-silently' }); + } + + closeSilently(): void { + if (this.machine.phase === 'closed') return; + this.applyAction({ type: 'close-silently' }); + } + + handlePreInput(data: string): boolean { + const phase = this.machine.phase; + if (phase === 'closed') return false; + if (this.inMountProtection()) { + const printable = printableChar(data); + if (SURVEY_SINGLE_OPTION_DIGIT.test(printable)) { + this.lastTypedDigit = printable; + } + return false; + } + if (this.host.state.editor.hasAutocompleteActivity()) return false; + if (matchesKey(data, Key.escape)) { + switch (phase) { + case 'open': + if (this.tooNarrow() || this.tooShort()) return false; + this.applyAction({ type: 'dismiss' }); + return true; + case 'pending': + this.applyAction({ type: 'undo' }); + return true; + case 'thanks': + this.applyAction({ type: 'close-silently' }); + return true; + } + } + if (this.tooNarrow()) return false; + if (this.tooShort()) return false; + if (phase !== 'open') return false; + const editor = this.host.state.editor; + const empty = editor.getText().length === 0; + const printable = printableChar(data); + if (SURVEY_SINGLE_OPTION_DIGIT.test(printable)) { + this.lastTypedDigit = printable; + if (!empty) this.clearDigitTimer(); + return false; + } + this.lastTypedDigit = undefined; + if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) { + if (!empty) this.clearDigitTimer(); + return false; + } + if (!empty) { + this.clearDigitTimer(); + return false; + } + if (matchesKey(data, Key.left)) { + this.moveHover(-1); + return true; + } + if (matchesKey(data, Key.right)) { + this.moveHover(1); + return true; + } + return false; + } + + handleEditorChange(text: string): void { + if (this.machine.phase !== 'open') return; + const wasTyped = text === this.lastTypedDigit; + this.lastTypedDigit = undefined; + if (this.inMountProtection()) { + if (text.length === 0 || wasTyped) return; + this.applyAction({ type: 'abandon' }); + return; + } + if (this.openedEditorText !== undefined) { + if (text.length > 0 && text !== this.openedEditorText) { + this.openedEditorText = undefined; + } else if (text === this.openedEditorText && !wasTyped) { + this.clearDigitTimer(); + return; + } + } + const bashMode = this.host.state.editor.inputMode === 'bash'; + if (text.length === 0) { + this.clearDigitTimer(); + return; + } + if (!bashMode && SURVEY_SINGLE_OPTION_DIGIT.test(text)) { + if (!wasTyped) { + this.applyAction({ type: 'abandon' }); + return; + } + this.clearDigitTimer(); + this.digitTimer = this.setT(() => { + this.digitTimer = undefined; + if (this.tooNarrow() || this.tooShort()) return; + this.chooseDigit(text); + }, SURVEY_DIGIT_DEBOUNCE_MS); + return; + } + this.applyAction({ type: 'abandon' }); + } + + handleSubmit(text: string): boolean { + if (this.machine.phase !== 'open') return false; + if (this.inMountProtection()) return false; + if (this.tooNarrow()) return false; + if (this.tooShort()) return false; + if (this.host.state.editor.inputMode === 'bash') { + this.applyAction({ type: 'abandon' }); + return false; + } + if (SURVEY_SINGLE_OPTION_DIGIT.test(text) && text !== this.openedEditorText) { + this.chooseDigit(text); + return true; + } + if (text.trim().length === 0 && this.view.hoverIndex !== undefined) { + this.chooseHovered(); + return true; + } + if (text.trim().length > 0) { + this.applyAction({ type: 'abandon' }); + } + return false; + } + + private evaluate(): void { + if (this.machine.phase !== 'closed') return; + if (!this.configReady) return; + if (!this.cooldownReady) return; + const region = (this.deps.configRegion ?? defaultDeps.configRegion)(); + const cacheCold = !(this.deps.configFresh ?? defaultDeps.configFresh)(); + if ( + cacheCold && + this.now() - this.coldRefreshAttemptedAt >= SURVEY_CONFIG_REFRESH_INTERVAL_MS + ) { + this.coldRefreshAttemptedAt = this.now(); + if (this.refreshConfig()) return; + } else if ( + (region !== this.configRegion || + this.now() - this.configRefreshedAt >= SURVEY_CONFIG_REFRESH_INTERVAL_MS) && + this.refreshConfig() + ) { + return; + } + const config = (this.deps.config ?? defaultDeps.config)(); + const verdict = evaluateSurveyGate({ ...this.gateInputs(), config }); + if (verdict.longContextRollConsumed === true) this.longContextRollConsumed = true; + if (!verdict.show) return; + this.open(verdict.survey, config); + } + + private gateInputs(): { session: SessionArmGateInput; longContext: LongContextArmGateInput } { + const { appState } = this.host.state; + const now = this.now(); + const shared: SharedArmGateInput = { + phase: this.machine.phase, + turnInProgress: appState.streamingPhase !== 'idle' || appState.isCompacting, + idleForMs: this.idleSince === undefined ? 0 : now - this.idleSince, + promptActive: this.promptActive(), + editorBashActive: this.host.state.editor.inputMode === 'bash', + editorAutocompleteActive: this.host.state.editor.hasAutocompleteActivity(), + externalEditorActive: this.host.state.externalEditorRunning, + terminalWidth: (this.deps.terminalWidth ?? defaultDeps.terminalWidth)() - 2 * CHROME_GUTTER, + terminalHeight: (this.deps.terminalHeight ?? defaultDeps.terminalHeight)(), + feedbackSurveyDisabled: + this.deps.feedbackSurveyDisabled?.() ?? + this.host.state.appState.disableFeedbackSurvey === true, + telemetryDisabled: (this.deps.telemetryDisabled ?? defaultDeps.telemetryDisabled)(), + currentModel: appState.model, + lastUserMessageStartsOrderedList: this.lastUserMessageStartsOrderedList(), + }; + return { + session: { + ...shared, + mountedForMs: now - this.mountedAt, + userTurnsSinceMount: this.userTurnCount, + msSinceLastShown: this.lastShownAt === undefined ? undefined : now - this.lastShownAt, + userTurnsSinceLastShown: + this.userTurnsAtLastShown === undefined + ? undefined + : this.userTurnCount - this.userTurnsAtLastShown, + sample: this.currentSample(), + msSinceGlobalLastShown: + this.globalLastShownAt === undefined ? undefined : this.wallNow() - this.globalLastShownAt, + }, + longContext: { + ...shared, + cumulativeTokens: appState.cumulativeTokens ?? 0, + virtualContextTokens: appState.contextTokens, + mountRollConsumed: this.longContextRollConsumed, + drawMountRoll: () => (this.deps.random ?? defaultDeps.random)(), + }, + }; + } + + private promptActive(): boolean { + const { state } = this.host; + return ( + state.editorReplacementMounted || + state.activeDialog !== null || + state.livePane.pendingApproval !== null || + state.livePane.pendingQuestion !== null || + state.tasksBrowser !== undefined || + this.host.btwPanelController.isActive() + ); + } + + private lastUserMessageStartsOrderedList(): boolean { + const entries = this.host.state.transcriptEntries; + for (let index = entries.length - 1; index >= 0; index--) { + const entry = entries[index]!; + if (entry.kind !== 'user' || entry.bullet === '') continue; + return SURVEY_ORDERED_LIST_START.test(entry.content); + } + return false; + } + + private currentSample(): number { + if (this.stickySample?.turnCount !== this.userTurnCount) { + this.stickySample = { + turnCount: this.userTurnCount, + value: (this.deps.random ?? defaultDeps.random)(), + }; + } + return this.stickySample.value; + } + + private open(survey: SurveyKind, config: SurveyPopupConfig): void { + this.appearanceCount += 1; + const appearance: SurveyAppearance = { + survey, + appearanceId: (this.deps.appearanceId ?? defaultDeps.appearanceId)(), + appearanceIndex: this.appearanceCount, + }; + const shownAt = this.now(); + this.appearanceConfig = config; + this.applyAction({ type: 'open', appearance }); + if (this.machine.phase !== 'open') return; + this.openedAt = shownAt; + this.openedEditorText = this.host.state.editor.getText(); + this.lastShownAt = shownAt; + this.userTurnsAtLastShown = this.userTurnCount; + if (survey !== 'session') return; + this.globalLastShownAt = this.wallNow(); + try { + (this.deps.writeGlobalLastShown ?? defaultDeps.writeGlobalLastShown)( + this.globalLastShownAt, + ); + } catch {} + } + + private applyAction(action: SurveyMachineAction): void { + this.clearDigitTimer(); + this.clearPhaseTimer(); + const transition = surveyMachineReduce(this.machine, action); + if (transition.state === this.machine && transition.effects.length === 0) return; + const appearance = transition.state.appearance ?? this.machine.appearance; + this.machine = transition.state; + for (const effect of transition.effects) { + this.runEffect(effect, appearance); + } + this.syncView(); + } + + private runEffect(effect: SurveyMachineEffect, appearance: SurveyAppearance | undefined): void { + switch (effect.type) { + case 'report': { + if (appearance === undefined) return; + this.host.track( + SURVEY_EVENT_NAMES[appearance.survey], + buildSurveyEventProperties( + { + event_type: effect.eventType, + appearance_id: appearance.appearanceId, + appearance_index: appearance.appearanceIndex, + response: effect.response, + }, + this.environmentFields(), + this.appearanceConfig ?? (this.deps.config ?? defaultDeps.config)(), + ), + ); + return; + } + case 'schedule': { + if (effect.timer === 'pending-settle') { + this.phaseTimer = this.setT(() => { + this.phaseTimer = undefined; + this.applyAction({ type: 'settle' }); + }, SURVEY_PENDING_UNDO_WINDOW_MS); + } else { + this.phaseTimer = this.setT(() => { + this.phaseTimer = undefined; + this.applyAction({ type: 'thanks-elapsed' }); + }, SURVEY_THANKS_DURATION_MS); + } + } + } + } + + private syncView(): void { + if (this.machine.phase === 'closed') { + this.view.hoverIndex = undefined; + this.applyClose(); + return; + } + this.view.phase = this.machine.phase; + this.view.response = + this.machine.response === undefined || this.machine.response === 'dismissed' + ? undefined + : this.machine.response; + if (this.machine.phase !== 'open') this.view.hoverIndex = undefined; + this.mount(); + this.host.state.ui.requestRender(); + } + + private applyClose(): void { + if (!this.mounted) return; + this.mounted = false; + this.lastTypedDigit = undefined; + this.openedEditorText = undefined; + this.host.state.surveyContainer.clear(); + this.host.state.ui.requestRender(); + } + + private mount(): void { + if (this.mounted) return; + this.mounted = true; + const container = this.host.state.surveyContainer; + container.clear(); + container.addChild(new Spacer(1)); + container.addChild(new SurveyPanelComponent(this.view)); + } + + private chooseDigit(digit: string): void { + const response = SURVEY_DIGIT_RESPONSES[digit]; + if (response === undefined) { + this.applyAction({ type: 'dismiss' }); + } else { + this.applyAction({ type: 'select', response }); + } + this.host.state.editor.setText(''); + } + + private chooseHovered(): void { + const hoverIndex = this.view.hoverIndex; + if (hoverIndex === undefined) return; + if (hoverIndex === SURVEY_DISMISS_OPTION_INDEX) { + this.applyAction({ type: 'dismiss' }); + return; + } + const response = SURVEY_DIGIT_RESPONSES[String(hoverIndex + 1)]; + if (response === undefined) return; + this.applyAction({ type: 'select', response }); + } + + private moveHover(delta: number): void { + const current = this.view.hoverIndex; + this.view.hoverIndex = + current === undefined + ? (delta > 0 ? 0 : SURVEY_OPTION_COUNT - 1) + : (current + delta + SURVEY_OPTION_COUNT) % SURVEY_OPTION_COUNT; + this.host.state.ui.requestRender(); + } + + private environmentFields(): SurveyEventEnvironmentFields { + const { appState } = this.host.state; + return { + current_model: appState.model, + user_turn_count: this.userTurnCount, + cumulative_tokens: appState.cumulativeTokens ?? 0, + virtual_context_tokens: appState.contextTokens, + tool_call_count: this.toolCallCount, + compaction_count: this.compactionCount, + permission_mode: appState.permissionMode, + thinking_effort: appState.thinkingEffort, + }; + } + + private refreshConfig(): boolean { + this.configRefreshedAt = this.now(); + this.configRegion = (this.deps.configRegion ?? defaultDeps.configRegion)(); + const markReady = () => { + this.configReady = true; + }; + if (this.deps.refreshConfig !== undefined) { + this.configReady = false; + void Promise.resolve(this.deps.refreshConfig()) + .catch(() => undefined) + .finally(markReady); + return true; + } + const accessToken = this.deps.accessToken; + if (accessToken === undefined) { + markReady(); + return false; + } + this.configReady = false; + void (async () => { + const token = await accessToken(); + await getSurveyPopupConfig({ accessToken: token }); + })() + .catch(() => undefined) + .finally(markReady); + return true; + } + + private tooNarrow(): boolean { + return ( + (this.deps.terminalWidth ?? defaultDeps.terminalWidth)() - 2 * CHROME_GUTTER < + SURVEY_MIN_OPTIONS_WIDTH + ); + } + + private tooShort(): boolean { + return ( + (this.deps.terminalHeight ?? defaultDeps.terminalHeight)() < + surveyMinTotalHeight( + (this.deps.terminalWidth ?? defaultDeps.terminalWidth)() - 2 * CHROME_GUTTER, + ) + ); + } + + private inMountProtection(): boolean { + return this.now() - this.openedAt < SURVEY_MOUNT_PROTECTION_MS; + } + + private now(): number { + return (this.deps.monotonicNow ?? defaultDeps.monotonicNow)(); + } + + private wallNow(): number { + return (this.deps.wallNow ?? defaultDeps.wallNow)(); + } + + private setT(fn: () => void, ms: number): unknown { + return (this.deps.setTimer ?? defaultDeps.setTimer)(fn, ms); + } + + private clearT(handle: unknown): void { + (this.deps.clearTimer ?? defaultDeps.clearTimer)(handle); + } + + private clearIdleTimer(): void { + if (this.idleTimer === undefined) return; + this.clearT(this.idleTimer); + this.idleTimer = undefined; + } + + private clearDigitTimer(): void { + if (this.digitTimer === undefined) return; + this.clearT(this.digitTimer); + this.digitTimer = undefined; + } + + private clearPhaseTimer(): void { + if (this.phaseTimer === undefined) return; + this.clearT(this.phaseTimer); + this.phaseTimer = undefined; + } +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index da68c171c7a..eec3c49f282 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -21,6 +21,7 @@ import type { TurnStartedEvent, WorkspaceTrustInfo, } from '@moonshot-ai/kimi-code-sdk'; +import { isTelemetryDisabledByEnv } from '@moonshot-ai/kimi-telemetry'; import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; import { deleteAllKittyImages, @@ -126,6 +127,7 @@ import { SessionEventHandler } from './controllers/session-event-handler'; import { SessionReplayRenderer } from './controllers/session-replay'; import { StagingLeaseTracker, type StagingLease } from './controllers/staging-leases'; import { StreamingUIController } from './controllers/streaming-ui'; +import { SurveyController } from './controllers/survey-controller'; import { TasksBrowserController } from './controllers/tasks-browser'; import { installRainbowDance } from './easter-eggs/dance'; import { adaptPanelResponse } from './reverse-rpc/approval/adapter'; @@ -140,6 +142,7 @@ import type { ColorToken, ResolvedTheme, ThemeName } from './theme'; import { createTUIState, type TUIState } from './tui-state'; import { INITIAL_LIVE_PANE, + sumTokenUsage, type AppState, type InlineSkillActivation, type KimiTUIOptions, @@ -221,6 +224,7 @@ export interface KimiTUIStartupInput { readonly migrateOnly?: boolean; /** agent-core-v2 engine; enables the startup workspace-trust prompt. */ readonly engineV2?: boolean; + readonly telemetryDisabled?: boolean; } type EffectiveActivityPaneMode = ActivityPaneMode | 'idle' | 'session'; @@ -264,6 +268,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { contextUsage: 0, contextTokens: 0, maxContextTokens: 0, + cumulativeTokens: 0, isCompacting: false, isReplaying: false, streamingPhase: 'idle', @@ -275,6 +280,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { disablePasteBurst: input.tuiConfig.disablePasteBurst, renderLatex: input.tuiConfig.renderLatex, cacheExpiryHint: input.tuiConfig.cacheExpiryHint, + disableFeedbackSurvey: input.tuiConfig.disableFeedbackSurvey, notifications: input.tuiConfig.notifications, upgrade: input.tuiConfig.upgrade, statusLine: input.tuiConfig.statusLine, @@ -303,6 +309,21 @@ interface SendMessageOptions { /** How long the one-shot "moved to background" footer hint stays visible. */ const DETACH_HINT_DISPLAY_MS = 4_000; +function isUserSubmittedTurnOrigin(origin: TurnStartedEvent['origin'] | undefined): boolean { + if (origin === undefined) return false; + switch (origin.kind) { + case 'user': + return true; + case 'skill_activation': + case 'plugin_command': + return origin.trigger === 'user-slash'; + case 'shell_command': + return origin.phase === 'input'; + default: + return false; + } +} + export class KimiTUI { readonly harness: KimiHarness; readonly options: KimiTUIOptions; @@ -339,6 +360,7 @@ export class KimiTUI { private backgroundRefreshPromise: Promise | undefined; private readonly migrationPlan: MigrationPlan | null; private readonly migrateOnly: boolean; + private readonly telemetryDisabled: boolean; /** Whether the harness runs on the agent-core-v2 engine (lazy session creation). */ readonly engineV2: boolean; private startupNotice: string | undefined; @@ -360,6 +382,7 @@ export class KimiTUI { readonly sessionEventHandler: SessionEventHandler; readonly sessionReplay: SessionReplayRenderer; readonly tasksBrowserController: TasksBrowserController; + readonly surveyController: SurveyController; readonly editorKeyboard: EditorKeyboardController; /** Timer that auto-clears the one-shot "moved to background" footer hint. */ @@ -430,6 +453,7 @@ export class KimiTUI { this.options = tuiOptions; this.migrationPlan = startupInput.migrationPlan ?? null; this.migrateOnly = startupInput.migrateOnly ?? false; + this.telemetryDisabled = startupInput.telemetryDisabled ?? false; this.engineV2 = startupInput.engineV2 ?? false; this.startupNotice = startupInput.startupNotice; this.state = createTUIState(tuiOptions); @@ -459,6 +483,10 @@ export class KimiTUI { this.sessionEventHandler = new SessionEventHandler(this); this.sessionReplay = new SessionReplayRenderer(this); this.tasksBrowserController = new TasksBrowserController(this); + this.surveyController = new SurveyController(this, { + accessToken: () => this.harness.auth.getCachedAccessToken(), + telemetryDisabled: () => isTelemetryDisabledByEnv() || this.telemetryDisabled, + }); this.editorKeyboard = new EditorKeyboardController(this, this.imageStore); this.editorKeyboard.install(); this.buildLayout(); @@ -980,6 +1008,7 @@ export class KimiTUI { this.streamingUI.resetToolUi(); this.disposeTranscriptChildren(); this.editorKeyboard.dispose(); + this.surveyController.dispose(); this.state.footer.dispose(); for (const dispose of this.reverseRpcDisposers) { dispose(); @@ -1100,6 +1129,7 @@ export class KimiTUI { ui.addChild(this.state.todoPanelContainer); ui.addChild(this.state.queueContainer); ui.addChild(this.state.btwPanelContainer); + ui.addChild(this.state.surveyContainer); ui.addChild(this.state.editorContainer); // Footer is mounted later (mountFooter), not here. } @@ -1139,6 +1169,7 @@ export class KimiTUI { main.addChild(this.state.todoPanelContainer); main.addChild(this.state.queueContainer); main.addChild(this.state.btwPanelContainer); + main.addChild(this.state.surveyContainer); main.addChild(this.state.editorContainer); const footerWrap = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); footerWrap.addChild(this.state.footer); @@ -1159,6 +1190,7 @@ export class KimiTUI { handleInputModeChange(mode: 'prompt' | 'bash'): void { this.setAppState({ inputMode: mode }); + this.surveyController.notifyInputModeChanged(mode); this.updateEditorBorderHighlight(); } @@ -1727,10 +1759,12 @@ export class KimiTUI { handleTurnStarted(event: TurnStartedEvent): void { this.staging.handleTurnStarted(event); + this.surveyController.notifyTurnStarted(isUserSubmittedTurnOrigin(event.origin)); } handleTurnEnded(event: TurnEndedEvent): void { this.staging.handleTurnEnded(event); + this.surveyController.notifyTurnEnded(); } releaseStagingMedia(mediaAttachmentIds: readonly number[]): void { @@ -2373,6 +2407,8 @@ export class KimiTUI { contextTokens: status.contextTokens, maxContextTokens: status.maxContextTokens, contextUsage: status.contextUsage, + cumulativeTokens: + status.usage?.total === undefined ? 0 : sumTokenUsage(status.usage.total), sessionTitle: session.summary?.title ?? null, goal: goalResult.goal, }); @@ -2568,6 +2604,7 @@ export class KimiTUI { resetSessionRuntime(): void { this.aborted = false; this.cacheHint.resetRuntime(); + this.surveyController.reset(); this.streamingUI.discardPending(); this.clearQueuedMessages(); this.state.swarmModeEntry = undefined; @@ -3686,6 +3723,7 @@ export class KimiTUI { // ========================================================================= mountEditorReplacement(panel: Component & Focusable): void { + this.surveyController.closeSilently(); this.state.editorReplacementMounted = true; this.state.editorContainer.clear(); this.state.editorContainer.addChild(panel); diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index b5568ecbf5b..6be0aa788e2 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -41,6 +41,7 @@ export interface TUIState { todoPanel: TodoPanelComponent; queueContainer: Container; btwPanelContainer: Container; + surveyContainer: Container; editorContainer: Container; /** * Fullscreen mode only: the bottom dock (activity/todo/queue/btw/editor + @@ -123,6 +124,7 @@ export function createTUIState(options: KimiTUIOptions): TUIState { const todoPanel = new TodoPanelComponent(); const queueContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const btwPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + const surveyContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const editorContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const editor = new CustomEditor(ui, { disablePasteBurst: initialAppState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, @@ -151,6 +153,7 @@ export function createTUIState(options: KimiTUIOptions): TUIState { dockContainer.addChild(todoPanelContainer, { shrink: 1, minSize: 0 }); dockContainer.addChild(queueContainer, { shrink: 1, minSize: 0 }); dockContainer.addChild(btwPanelContainer, { shrink: 1, minSize: 0 }); + dockContainer.addChild(surveyContainer, { shrink: 0, minSize: 0 }); dockContainer.addChild(editorContainer, { shrink: 1, minSize: 3 }); const root = new VStack(); root.addChild(scrollView, { basis: 0, grow: 1, shrink: 1, minSize: 1 }); @@ -167,6 +170,7 @@ export function createTUIState(options: KimiTUIOptions): TUIState { todoPanel, queueContainer, btwPanelContainer, + surveyContainer, editorContainer, dockContainer, editor, diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 83aff8c8785..555acbc3000 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -6,6 +6,7 @@ import type { ProviderConfig, PromptPart, ThinkingEffort, + TokenUsage, ToolInputDisplay, } from '@moonshot-ai/kimi-code-sdk'; @@ -61,6 +62,7 @@ export interface AppState { contextUsage: number; contextTokens: number; maxContextTokens: number; + cumulativeTokens?: number; isCompacting: boolean; isReplaying: boolean; streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing' | 'shell'; @@ -76,6 +78,7 @@ export interface AppState { renderLatex?: boolean; /** Mirrors the TUI config toggle; defaults to true when absent from older fixtures. */ cacheExpiryHint?: boolean; + disableFeedbackSurvey?: boolean; notifications: NotificationsConfig; upgrade: UpgradePreferences; /** Footer status line customization from tui.toml; absent means the default layout. */ @@ -90,6 +93,10 @@ export interface AppState { banner?: BannerState | null; } +export function sumTokenUsage(total: TokenUsage): number { + return total.inputOther + total.output + total.inputCacheRead + total.inputCacheCreation; +} + export interface StepRetryState { /** Upcoming attempt number (1-based). */ nextAttempt: number; diff --git a/apps/kimi-code/src/tui/utils/survey-policy.ts b/apps/kimi-code/src/tui/utils/survey-policy.ts new file mode 100644 index 00000000000..5e82041d21f --- /dev/null +++ b/apps/kimi-code/src/tui/utils/survey-policy.ts @@ -0,0 +1,378 @@ + +import type { SurveyPopupConfig } from '#/utils/survey-popup-config'; + +import { + SURVEY_IDLE_STABILITY_MS, + SURVEY_MIN_OPTIONS_WIDTH, + surveyMinTotalHeight, +} from '../constant/survey'; + +export type SurveyKind = 'session' | 'long_context'; + +export type SurveyGateSkipReason = + | 'mount-roll-consumed' + | 'survey-active' + | 'turn-in-progress' + | 'idle-too-short' + | 'ordered-list-ambiguity' + | 'prompt-active' + | 'editor-bash-active' + | 'editor-autocomplete-active' + | 'external-editor-active' + | 'terminal-too-narrow' + | 'terminal-too-short' + | 'feature-disabled' + | 'telemetry-disabled' + | 'model-gated' + | 'warmup' + | 'pacing' + | 'threshold-invalid' + | 'below-threshold' + | 'sampled-out' + | 'global-cooldown'; + +export interface SharedArmGateInput { + readonly phase: SurveyPhase; + readonly turnInProgress: boolean; + readonly idleForMs: number; + readonly promptActive: boolean; + readonly editorBashActive: boolean; + readonly editorAutocompleteActive: boolean; + readonly externalEditorActive: boolean; + readonly terminalWidth: number; + readonly terminalHeight: number; + readonly feedbackSurveyDisabled: boolean; + readonly telemetryDisabled: boolean; + readonly currentModel: string; + readonly lastUserMessageStartsOrderedList: boolean; +} + +export interface SessionArmGateInput extends SharedArmGateInput { + readonly mountedForMs: number; + readonly userTurnsSinceMount: number; + readonly msSinceLastShown: number | undefined; + readonly userTurnsSinceLastShown: number | undefined; + readonly sample: number; + readonly msSinceGlobalLastShown: number | undefined; +} + +export interface LongContextArmGateInput extends SharedArmGateInput { + readonly cumulativeTokens: number; + readonly virtualContextTokens: number; + readonly mountRollConsumed: boolean; + readonly drawMountRoll: () => number; +} + +export interface SurveyGateInput { + readonly session: SessionArmGateInput; + readonly longContext: LongContextArmGateInput; + readonly config: SurveyPopupConfig; +} + +export type SurveyGateVerdict = + | { + readonly show: true; + readonly survey: SurveyKind; + readonly longContextRollConsumed?: boolean; + } + | { + readonly show: false; + readonly reason: SurveyGateSkipReason; + readonly longContextRollConsumed?: boolean; + }; + +function modelGatePasses(onForModels: readonly string[], currentModel: string): boolean { + if (onForModels.length === 0) return false; + if (onForModels.includes('*')) return true; + return onForModels.includes(currentModel); +} + +function evaluateSessionArm(input: SurveyGateInput): SurveyGateVerdict { + const { session, config } = input; + if (session.phase !== 'closed') return { show: false, reason: 'survey-active' }; + if (session.turnInProgress) return { show: false, reason: 'turn-in-progress' }; + if (session.idleForMs < SURVEY_IDLE_STABILITY_MS) { + return { show: false, reason: 'idle-too-short' }; + } + if (session.lastUserMessageStartsOrderedList) { + return { show: false, reason: 'ordered-list-ambiguity' }; + } + if (session.promptActive) return { show: false, reason: 'prompt-active' }; + if (session.editorBashActive) return { show: false, reason: 'editor-bash-active' }; + if (session.editorAutocompleteActive) { + return { show: false, reason: 'editor-autocomplete-active' }; + } + if (session.externalEditorActive) { + return { show: false, reason: 'external-editor-active' }; + } + if (session.terminalWidth < SURVEY_MIN_OPTIONS_WIDTH) { + return { show: false, reason: 'terminal-too-narrow' }; + } + if (session.terminalHeight < surveyMinTotalHeight(session.terminalWidth)) { + return { show: false, reason: 'terminal-too-short' }; + } + if (session.feedbackSurveyDisabled) return { show: false, reason: 'feature-disabled' }; + if (session.telemetryDisabled) return { show: false, reason: 'telemetry-disabled' }; + if (!modelGatePasses(config.on_for_models, session.currentModel)) { + return { show: false, reason: 'model-gated' }; + } + if (session.msSinceLastShown === undefined) { + if ( + session.mountedForMs < config.min_time_before_feedback_ms || + session.userTurnsSinceMount < config.min_user_turns_before_feedback + ) { + return { show: false, reason: 'warmup' }; + } + } else if ( + session.msSinceLastShown < config.min_time_between_feedback_ms || + (session.userTurnsSinceLastShown ?? 0) < config.min_user_turns_between_feedback + ) { + return { show: false, reason: 'pacing' }; + } + if (session.sample >= config.probability) return { show: false, reason: 'sampled-out' }; + if ( + session.msSinceGlobalLastShown !== undefined && + session.msSinceGlobalLastShown < config.min_time_between_global_feedback_ms + ) { + return { show: false, reason: 'global-cooldown' }; + } + return { show: true, survey: 'session' }; +} + +export function evaluateLongContextArm(input: SurveyGateInput): SurveyGateVerdict { + const { longContext, config } = input; + if (longContext.mountRollConsumed) return { show: false, reason: 'mount-roll-consumed' }; + if (longContext.phase !== 'closed') return { show: false, reason: 'survey-active' }; + if (longContext.turnInProgress) return { show: false, reason: 'turn-in-progress' }; + if (longContext.idleForMs < SURVEY_IDLE_STABILITY_MS) { + return { show: false, reason: 'idle-too-short' }; + } + if (longContext.lastUserMessageStartsOrderedList) { + return { show: false, reason: 'ordered-list-ambiguity' }; + } + if (longContext.promptActive) return { show: false, reason: 'prompt-active' }; + if (longContext.editorBashActive) return { show: false, reason: 'editor-bash-active' }; + if (longContext.editorAutocompleteActive) { + return { show: false, reason: 'editor-autocomplete-active' }; + } + if (longContext.externalEditorActive) { + return { show: false, reason: 'external-editor-active' }; + } + if (longContext.terminalWidth < SURVEY_MIN_OPTIONS_WIDTH) { + return { show: false, reason: 'terminal-too-narrow' }; + } + if (longContext.terminalHeight < surveyMinTotalHeight(longContext.terminalWidth)) { + return { show: false, reason: 'terminal-too-short' }; + } + if (longContext.feedbackSurveyDisabled) return { show: false, reason: 'feature-disabled' }; + if (longContext.telemetryDisabled) return { show: false, reason: 'telemetry-disabled' }; + if (!modelGatePasses(config.on_for_models, longContext.currentModel)) { + return { show: false, reason: 'model-gated' }; + } + if (!(config.long_context_survey_threshold > 0)) { + return { show: false, reason: 'threshold-invalid' }; + } + const counter = + config.long_context_trigger_mode === 'cumulative' + ? longContext.cumulativeTokens + : longContext.virtualContextTokens; + if (counter < config.long_context_survey_threshold) { + return { show: false, reason: 'below-threshold' }; + } + if (longContext.drawMountRoll() >= config.long_context_probability) { + return { show: false, reason: 'sampled-out', longContextRollConsumed: true }; + } + return { show: true, survey: 'long_context', longContextRollConsumed: true }; +} + +export function evaluateSurveyGate(input: SurveyGateInput): SurveyGateVerdict { + const longContext = evaluateLongContextArm(input); + if (longContext.show) return longContext; + const session = evaluateSessionArm(input); + if (longContext.longContextRollConsumed === true) { + return { ...session, longContextRollConsumed: true }; + } + return session; +} + +export type SurveyPhase = 'closed' | 'open' | 'pending' | 'thanks'; + +export type SurveyResponse = 'bad' | 'fine' | 'good' | 'dismissed'; + +export type SurveyEventType = 'appeared' | 'responded' | 'abandoned'; + +export interface SurveyAppearance { + readonly survey: SurveyKind; + readonly appearanceId: string; + readonly appearanceIndex: number; +} + +export interface SurveyMachineState { + readonly phase: SurveyPhase; + readonly appearance: SurveyAppearance | undefined; + readonly response: SurveyResponse | undefined; +} + +export const SURVEY_MACHINE_CLOSED: SurveyMachineState = { + phase: 'closed', + appearance: undefined, + response: undefined, +}; + +const SURVEY_PRIORITY: Record = { session: 0, long_context: 1 }; + +export type SurveyMachineAction = + | { readonly type: 'open'; readonly appearance: SurveyAppearance } + | { readonly type: 'select'; readonly response: 'bad' | 'fine' | 'good' } + | { readonly type: 'dismiss' } + | { readonly type: 'undo' } + | { readonly type: 'settle' } + | { readonly type: 'thanks-elapsed' } + | { readonly type: 'abandon' } + | { readonly type: 'close-silently' }; + +export type SurveyMachineEffect = + | { + readonly type: 'report'; + readonly eventType: SurveyEventType; + readonly response?: SurveyResponse; + } + | { readonly type: 'schedule'; readonly timer: 'pending-settle' | 'thanks-close' }; + +export interface SurveyMachineTransition { + readonly state: SurveyMachineState; + readonly effects: readonly SurveyMachineEffect[]; +} + +const NO_EFFECTS: readonly SurveyMachineEffect[] = []; + +function noTransition(state: SurveyMachineState): SurveyMachineTransition { + return { state, effects: NO_EFFECTS }; +} + +export function surveyMachineReduce( + state: SurveyMachineState, + action: SurveyMachineAction, +): SurveyMachineTransition { + switch (action.type) { + case 'open': { + if (state.phase === 'closed') { + return { + state: { phase: 'open', appearance: action.appearance, response: undefined }, + effects: [{ type: 'report', eventType: 'appeared' }], + }; + } + if (state.phase !== 'open' || state.appearance === undefined) { + return noTransition(state); + } + if (SURVEY_PRIORITY[action.appearance.survey] <= SURVEY_PRIORITY[state.appearance.survey]) { + return noTransition(state); + } + return { + state: { phase: 'open', appearance: action.appearance, response: undefined }, + effects: [{ type: 'report', eventType: 'appeared' }], + }; + } + case 'select': { + if (state.phase !== 'open') return noTransition(state); + return { + state: { ...state, phase: 'pending', response: action.response }, + effects: [{ type: 'schedule', timer: 'pending-settle' }], + }; + } + case 'dismiss': { + if (state.phase !== 'open') return noTransition(state); + return { + state: SURVEY_MACHINE_CLOSED, + effects: [{ type: 'report', eventType: 'responded', response: 'dismissed' }], + }; + } + case 'undo': { + if (state.phase !== 'pending') return noTransition(state); + return { + state: { ...state, phase: 'open', response: undefined }, + effects: NO_EFFECTS, + }; + } + case 'settle': { + if (state.phase !== 'pending' || state.response === undefined) { + return noTransition(state); + } + return { + state: { ...state, phase: 'thanks' }, + effects: [ + { type: 'report', eventType: 'responded', response: state.response }, + { type: 'schedule', timer: 'thanks-close' }, + ], + }; + } + case 'thanks-elapsed': { + if (state.phase !== 'thanks') return noTransition(state); + return { state: SURVEY_MACHINE_CLOSED, effects: NO_EFFECTS }; + } + case 'abandon': { + if (state.phase !== 'open') return noTransition(state); + return { + state: SURVEY_MACHINE_CLOSED, + effects: [{ type: 'report', eventType: 'abandoned' }], + }; + } + case 'close-silently': { + if (state.phase === 'closed') return noTransition(state); + if (state.phase === 'pending' && state.response !== undefined) { + return { + state: SURVEY_MACHINE_CLOSED, + effects: [{ type: 'report', eventType: 'responded', response: state.response }], + }; + } + return { state: SURVEY_MACHINE_CLOSED, effects: NO_EFFECTS }; + } + } +} + +export const SURVEY_EVENT_NAMES: Record = { + session: 'feedback_survey', + long_context: 'long_context_survey', +}; + +export interface SurveyEventCoreFields { + readonly event_type: SurveyEventType; + readonly appearance_id: string; + readonly appearance_index: number; + readonly response?: SurveyResponse; +} + +export interface SurveyEventEnvironmentFields { + readonly current_model: string; + readonly user_turn_count: number; + readonly cumulative_tokens: number; + readonly virtual_context_tokens: number; + readonly tool_call_count: number; + readonly compaction_count: number; + readonly permission_mode: string; + readonly thinking_effort: string; +} + +export function buildSurveyEventProperties( + core: SurveyEventCoreFields, + environment: SurveyEventEnvironmentFields, + config: SurveyPopupConfig, +): Record { + return { + event_type: core.event_type, + appearance_id: core.appearance_id, + appearance_index: core.appearance_index, + response: core.response, + ...environment, + config_probability: config.probability, + config_on_for_models: config.on_for_models.join(','), + config_min_time_before_feedback_ms: config.min_time_before_feedback_ms, + config_min_user_turns_before_feedback: config.min_user_turns_before_feedback, + config_min_time_between_feedback_ms: config.min_time_between_feedback_ms, + config_min_user_turns_between_feedback: config.min_user_turns_between_feedback, + config_min_time_between_global_feedback_ms: config.min_time_between_global_feedback_ms, + config_long_context_survey_threshold: config.long_context_survey_threshold, + config_long_context_probability: config.long_context_probability, + config_long_context_trigger_mode: config.long_context_trigger_mode, + }; +} diff --git a/apps/kimi-code/src/utils/paths.ts b/apps/kimi-code/src/utils/paths.ts index f9d595837c1..856b8d8a341 100644 --- a/apps/kimi-code/src/utils/paths.ts +++ b/apps/kimi-code/src/utils/paths.ts @@ -21,6 +21,7 @@ import { KIMI_CODE_NATIVE_STAGED_STATE_FILE_NAME, KIMI_CODE_NATIVE_STAGING_DIR_NAME, KIMI_CODE_PLUGIN_UPDATE_NOTICE_STATE_FILE_NAME, + KIMI_CODE_SURVEY_STATE_FILE_NAME, KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME, KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME, KIMI_CODE_UPDATE_DIR_NAME, @@ -126,6 +127,10 @@ export function getBannerStateFile(): string { return join(getCacheDir(), KIMI_CODE_BANNER_DIR_NAME, KIMI_CODE_BANNER_STATE_FILE_NAME); } +export function getSurveyStateFile(): string { + return join(getDataDir(), KIMI_CODE_SURVEY_STATE_FILE_NAME); +} + /** * Return the user input history file for a given working directory. * Layout: `/user-history/.jsonl`. diff --git a/apps/kimi-code/src/utils/persistence.ts b/apps/kimi-code/src/utils/persistence.ts index 0b60e5109cf..50cca0c778c 100644 --- a/apps/kimi-code/src/utils/persistence.ts +++ b/apps/kimi-code/src/utils/persistence.ts @@ -6,6 +6,7 @@ * these helpers. */ +import { mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; import { appendFile, link, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { basename, dirname, join } from 'node:path'; @@ -75,6 +76,22 @@ export async function writeJsonFile( } } +export function writeJsonFileSync(filePath: string, schema: z.ZodType, value: T): void { + assertNonConfigWrite(filePath); + const parsed = schema.parse(value); + mkdirSync(dirname(filePath), { recursive: true }); + const tmpPath = tempPathFor(filePath); + try { + writeFileSync(tmpPath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf-8'); + renameSync(tmpPath, filePath); + } catch (error) { + try { + unlinkSync(tmpPath); + } catch {} + throw error; + } +} + /** * Create `filePath` with `content` only while the path is still free — * atomically, and throwing EEXIST when it is already taken. diff --git a/apps/kimi-code/src/utils/survey-popup-config.ts b/apps/kimi-code/src/utils/survey-popup-config.ts new file mode 100644 index 00000000000..a7efd807ef3 --- /dev/null +++ b/apps/kimi-code/src/utils/survey-popup-config.ts @@ -0,0 +1,91 @@ +import { z } from 'zod'; + +import { + getClientConfig, + peekClientConfig, + resetClientConfigCache, + type ClientConfigFetchOptions, +} from '#/utils/client-configs'; + +const CONFIG_NAME = 'survey_popup'; + +/** The `survey_popup` wire shape; every layer below (process cache, disk + * cache, network failure, per-field parse failure) falls back to the + * built-in defaults. */ +export interface SurveyPopupConfig { + probability: number; + /** Model gate: exact match, `"*"` opens for every model, `[]` closes both arms. */ + on_for_models: string[]; + min_time_before_feedback_ms: number; + min_user_turns_before_feedback: number; + min_time_between_feedback_ms: number; + min_user_turns_between_feedback: number; + /** Cross-session cooldown, persisted as `last_shown_time`. */ + min_time_between_global_feedback_ms: number; + /** Token threshold for the long-context arm; invalid/non-positive disables that arm. */ + long_context_survey_threshold: number; + long_context_probability: number; + /** Which token counter the threshold compares against. */ + long_context_trigger_mode: 'cumulative' | 'virtual_context'; +} + +export const DEFAULT_SURVEY_POPUP_CONFIG: SurveyPopupConfig = { + probability: 0.005, + on_for_models: ['*'], + min_time_before_feedback_ms: 600_000, + min_user_turns_before_feedback: 5, + min_time_between_feedback_ms: 3_600_000, + min_user_turns_between_feedback: 10, + min_time_between_global_feedback_ms: 100_000_000, + long_context_survey_threshold: 200_000, + long_context_probability: 0.2, + long_context_trigger_mode: 'cumulative', +}; + +const FIELD_SCHEMAS = { + probability: z.number().min(0).max(1), + on_for_models: z.array(z.string()), + min_time_before_feedback_ms: z.number().min(0), + min_user_turns_before_feedback: z.number().int().min(0), + min_time_between_feedback_ms: z.number().min(0), + min_user_turns_between_feedback: z.number().int().min(0), + min_time_between_global_feedback_ms: z.number().min(0), + long_context_survey_threshold: z.number(), + long_context_probability: z.number().min(0).max(1), + long_context_trigger_mode: z.enum(['cumulative', 'virtual_context']), +} satisfies Record; + +const surveyPopupConfigSchema = z.unknown().transform((raw): Partial => { + if (typeof raw !== 'object' || raw === null) return {}; + const record = raw as Record; + const partial: Record = {}; + for (const [key, schema] of Object.entries(FIELD_SCHEMAS)) { + const value = record[key]; + if (value === undefined) continue; + const parsed = schema.safeParse(value); + if (parsed.success) partial[key] = parsed.data; + } + return partial as Partial; +}); + +function withDefaults(partial: Partial | undefined): SurveyPopupConfig { + return { ...DEFAULT_SURVEY_POPUP_CONFIG, ...partial }; +} + +export async function getSurveyPopupConfig( + options: ClientConfigFetchOptions = {}, +): Promise { + return withDefaults(await getClientConfig(CONFIG_NAME, surveyPopupConfigSchema, options)); +} + +export function peekSurveyPopupConfig(now?: number): SurveyPopupConfig { + return withDefaults(peekClientConfig(CONFIG_NAME, surveyPopupConfigSchema, now)); +} + +export function peekSurveyPopupConfigFresh(now?: number): boolean { + return peekClientConfig(CONFIG_NAME, surveyPopupConfigSchema, now) !== undefined; +} + +export function resetSurveyPopupConfigCache(): void { + resetClientConfigCache(CONFIG_NAME); +} diff --git a/apps/kimi-code/src/utils/survey-state-store.ts b/apps/kimi-code/src/utils/survey-state-store.ts new file mode 100644 index 00000000000..d6acf1c775a --- /dev/null +++ b/apps/kimi-code/src/utils/survey-state-store.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +import { getSurveyStateFile } from '#/utils/paths'; +import { readJsonFile, writeJsonFileSync } from '#/utils/persistence'; + +const SurveyStateSchema = z.object({ + version: z.literal(1), + last_shown_time: z.number(), +}); + +export async function readSurveyLastShownTime( + filePath: string = getSurveyStateFile(), +): Promise { + try { + const state = await readJsonFile(filePath, SurveyStateSchema, { + version: 1, + last_shown_time: Number.NaN, + }); + return Number.isFinite(state.last_shown_time) ? state.last_shown_time : undefined; + } catch { + return undefined; + } +} + +export function writeSurveyLastShownTime( + lastShownTime: number, + filePath: string = getSurveyStateFile(), +): void { + writeJsonFileSync(filePath, SurveyStateSchema, { + version: 1, + last_shown_time: lastShownTime, + }); +} diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index e572deb06f1..040276f3a5d 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -360,6 +360,7 @@ describe('runShell', () => { }, version: '1.2.3-test', workDir: process.cwd(), + telemetryDisabled: false, }); expect(mocks.tuiStart).toHaveBeenCalledOnce(); expect(mocks.withTelemetryContext).toHaveBeenCalledWith({ sessionId: 'ses-startup' }); @@ -422,6 +423,20 @@ describe('runShell', () => { expect(startupInput).toMatchObject({ agentProfile: 'reviewer' }); }); + it('forwards the telemetry opt-out from config to the TUI startup input', async () => { + stubTuiStartup(); + mocks.harnessGetConfig.mockResolvedValue({ + providers: {}, + defaultModel: 'k2', + telemetry: false, + }); + + await runShell(minimalCliOptions, '1.2.3-test'); + + const [, , startupInput] = mocks.kimiTuiConstructor.mock.calls[0]!; + expect(startupInput).toMatchObject({ telemetryDisabled: true }); + }); + it('forwards skillsDirs from CLI options to the harness', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', diff --git a/apps/kimi-code/test/tui/commands/survey-preferences.test.ts b/apps/kimi-code/test/tui/commands/survey-preferences.test.ts new file mode 100644 index 00000000000..b8b8073025b --- /dev/null +++ b/apps/kimi-code/test/tui/commands/survey-preferences.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { applySurveyPreferenceChoice } from '#/tui/commands/config'; +import { darkColors } from '#/tui/theme/colors'; + +const mocks = vi.hoisted(() => ({ + saveTuiConfig: vi.fn(), +})); + +vi.mock('../../../src/tui/config', async () => { + const actual = await vi.importActual( + '../../../src/tui/config.js', + ); + return { + ...actual, + saveTuiConfig: mocks.saveTuiConfig, + }; +}); + +function makeHost(disableFeedbackSurvey: boolean) { + return { + state: { + appState: { + theme: 'auto' as const, + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' as const }, + upgrade: { autoInstall: true }, + disableFeedbackSurvey, + }, + theme: { palette: darkColors }, + }, + setAppState: vi.fn(), + showStatus: vi.fn(), + }; +} + +describe('survey preference commands', () => { + it('saves the opt-out to tui.toml and mirrors it into appState', async () => { + mocks.saveTuiConfig.mockClear(); + const host = makeHost(false); + + await applySurveyPreferenceChoice(host, false); + + expect(mocks.saveTuiConfig).toHaveBeenCalledWith( + expect.objectContaining({ disableFeedbackSurvey: true }), + ); + expect(host.setAppState).toHaveBeenCalledWith({ disableFeedbackSurvey: true }); + expect(host.showStatus).toHaveBeenCalledWith('Feedback survey disabled.'); + }); + + it('re-enables the survey from an opt-out state', async () => { + mocks.saveTuiConfig.mockClear(); + const host = makeHost(true); + + await applySurveyPreferenceChoice(host, true); + + expect(mocks.saveTuiConfig).toHaveBeenCalledWith( + expect.objectContaining({ disableFeedbackSurvey: false }), + ); + expect(host.setAppState).toHaveBeenCalledWith({ disableFeedbackSurvey: false }); + expect(host.showStatus).toHaveBeenCalledWith('Feedback survey enabled.'); + }); + + it('does not rewrite the config when the value is unchanged', async () => { + mocks.saveTuiConfig.mockClear(); + const host = makeHost(false); + + await applySurveyPreferenceChoice(host, true); + + expect(mocks.saveTuiConfig).not.toHaveBeenCalled(); + expect(host.setAppState).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith('Feedback survey already enabled.'); + }); + + it('reports a save failure without touching appState', async () => { + mocks.saveTuiConfig.mockRejectedValueOnce(new Error('disk full')); + const host = makeHost(false); + + await applySurveyPreferenceChoice(host, false); + + expect(host.setAppState).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith( + 'Failed to save session rating setting: disk full', + 'error', + ); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/update-preferences.test.ts b/apps/kimi-code/test/tui/commands/update-preferences.test.ts index 8e79bfe9102..23e6a24fcb5 100644 --- a/apps/kimi-code/test/tui/commands/update-preferences.test.ts +++ b/apps/kimi-code/test/tui/commands/update-preferences.test.ts @@ -45,6 +45,7 @@ describe('update preference commands', () => { disablePasteBurst: false, renderLatex: true, cacheExpiryHint: true, + disableFeedbackSurvey: false, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: false }, statusLine: { items: null, command: null }, diff --git a/apps/kimi-code/test/tui/components/dialogs/survey-preference-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/survey-preference-selector.test.ts new file mode 100644 index 00000000000..6538d939092 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/survey-preference-selector.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { SurveyPreferenceSelectorComponent } from '#/tui/components/dialogs/survey-preference-selector'; + +const ANSI = /\[[0-9;]*m/g; +const strip = (s: string): string => s.replaceAll(ANSI, ''); + +describe('SurveyPreferenceSelectorComponent', () => { + it('maps the current preference onto the picker options', () => { + const selected: boolean[] = []; + const enabledPicker = new SurveyPreferenceSelectorComponent({ + currentValue: true, + onSelect: (value) => selected.push(value), + onCancel: () => {}, + }); + const disabledPicker = new SurveyPreferenceSelectorComponent({ + currentValue: false, + onSelect: (value) => selected.push(value), + onCancel: () => {}, + }); + + expect(strip(enabledPicker.render(60).join('\n'))).toContain('Feedback survey'); + expect(strip(disabledPicker.render(60).join('\n'))).toContain('Off'); + + enabledPicker.handleInput('\r'); + expect(selected).toEqual([true]); + disabledPicker.handleInput('\r'); + expect(selected).toEqual([true, false]); + }); +}); diff --git a/apps/kimi-code/test/tui/components/panes/survey-panel.test.ts b/apps/kimi-code/test/tui/components/panes/survey-panel.test.ts new file mode 100644 index 00000000000..177d87b9516 --- /dev/null +++ b/apps/kimi-code/test/tui/components/panes/survey-panel.test.ts @@ -0,0 +1,76 @@ +import chalk from 'chalk'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; + +import { + SurveyPanelComponent, + type SurveyPanelView, +} from '#/tui/components/panes/survey-panel'; + +function stripAnsi(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +function render(view: SurveyPanelView, width: number): string[] { + return new SurveyPanelComponent(view).render(width).map(stripAnsi); +} + +describe('SurveyPanelComponent', () => { + const previousChalkLevel = chalk.level; + + beforeEach(() => { + chalk.level = 3; + }); + + afterEach(() => { + chalk.level = previousChalkLevel; + }); + + it('renders the question and the four options on one line', () => { + const lines = render({ phase: 'open' }, 80); + expect(lines).toHaveLength(2); + expect(lines[0]).toContain('●'); + expect(lines[0]).toContain('How is Kimi doing this session? (optional)'); + expect(lines[1]).toBe(' 1: Bad 2: Fine 3: Good 0: Dismiss'); + }); + + it('folds the options onto their own lines when narrow', () => { + const lines = render({ phase: 'open' }, 30); + expect(lines[0]).toContain('How is Kimi doing this'); + expect(lines[1]).toContain('session? (optional)'); + expect(lines.slice(2)).toEqual([' 1: Bad', ' 2: Fine', ' 3: Good', ' 0: Dismiss']); + }); + + it('keeps only the title line at extreme widths, without hard-breaking', () => { + const width = 10; + const lines = render({ phase: 'open' }, width); + expect(lines.length).toBeGreaterThan(0); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + expect(lines.join('\n')).not.toContain('1: Bad'); + expect(lines[0]).toContain('●'); + }); + + it('renders the pending line with the chosen rating and the undo hint', () => { + const lines = render({ phase: 'pending', response: 'bad' }, 80); + expect(lines).toEqual(['● Feedback: Bad · [escape: undo]']); + }); + + it('renders the thanks line', () => { + const lines = render({ phase: 'thanks' }, 80); + expect(lines).toEqual(['● Thanks for your feedback!']); + }); + + it('highlights the hovered option', () => { + const plain = new SurveyPanelComponent({ phase: 'open' }).render(80)[1]!; + const hovered = new SurveyPanelComponent({ phase: 'open', hoverIndex: 2 }).render(80)[1]!; + expect(stripAnsi(hovered)).toBe(stripAnsi(plain)); + expect(hovered).not.toBe(plain); + expect(hovered).toContain('3: Good'); + }); + + it('renders a single blank line when the terminal cannot hold a column', () => { + expect(render({ phase: 'open' }, 0)).toEqual(['']); + }); +}); diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index 48df4730307..020bca5296d 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -35,6 +35,7 @@ describe('TUI config', () => { expect(text).toContain('Client preferences for kimi-code.'); expect(text).toContain('theme = "auto"'); expect(text).toContain('cache_expiry_hint = true'); + expect(text).toContain('disable_feedback_survey = false'); expect(text).toContain('command = ""'); expect(text).toContain('[upgrade]'); expect(text).toContain('auto_install = true'); @@ -63,6 +64,7 @@ auto_install = false renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, + disableFeedbackSurvey: false, editorCommand: 'code --wait', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, @@ -98,6 +100,16 @@ cache_expiry_hint = false expect(config.cacheExpiryHint).toBe(false); }); + it('defaults disable_feedback_survey to false and parses true', () => { + expect(parseTuiConfig('').disableFeedbackSurvey).toBe(false); + + const config = parseTuiConfig(` +disable_feedback_survey = true +`); + + expect(config.disableFeedbackSurvey).toBe(true); + }); + it('normalizes an empty editor command to auto-detect', () => { const config = parseTuiConfig(` [editor] @@ -109,6 +121,7 @@ command = " " renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, + disableFeedbackSurvey: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, @@ -156,6 +169,7 @@ command = " " renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, + disableFeedbackSurvey: false, editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, @@ -163,6 +177,16 @@ command = " " }); }); + it('round-trips a disable_feedback_survey opt-out', async () => { + await saveTuiConfig( + { ...DEFAULT_TUI_CONFIG, disableFeedbackSurvey: true }, + filePath, + ); + + expect(readFileSync(filePath, 'utf-8')).toContain('disable_feedback_survey = true'); + expect((await loadTuiConfig(filePath)).disableFeedbackSurvey).toBe(true); + }); + it('escapes special characters in a custom theme name so the TOML round-trips', async () => { const theme = 'weird"name\\with-quote'; await saveTuiConfig( diff --git a/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts index 626728e7812..d119bf24ac9 100644 --- a/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts @@ -15,6 +15,12 @@ interface Harness { readonly cancelCompaction: ReturnType; readonly btwCancelRunning: ReturnType; readonly btwCloseOrCancel: ReturnType; + readonly survey: { + readonly handlePreInput: ReturnType boolean>>; + readonly handleSubmit: ReturnType boolean>>; + readonly handleEditorChange: ReturnType void>>; + readonly closeSilently: ReturnType void>>; + }; } function createHarness(options: { streamingPhase?: string; isCompacting?: boolean } = {}): Harness { @@ -29,6 +35,12 @@ function createHarness(options: { streamingPhase?: string; isCompacting?: boolea const cancelCompaction = vi.fn(async () => {}); const btwCancelRunning = vi.fn(() => false); const btwCloseOrCancel = vi.fn(() => false); + const survey = { + handlePreInput: vi.fn<(data: string) => boolean>(() => false), + handleSubmit: vi.fn<(text: string) => boolean>(() => false), + handleEditorChange: vi.fn<(text: string) => void>(() => {}), + closeSilently: vi.fn<() => void>(() => {}), + }; const session = { cancel: vi.fn(async () => {}), cancelCompaction }; const host = { @@ -38,16 +50,23 @@ function createHarness(options: { streamingPhase?: string; isCompacting?: boolea appState: { streamingPhase: options.streamingPhase ?? 'idle', isCompacting: options.isCompacting ?? false, + editorCommand: null, }, footer: { setTransientHint: vi.fn() }, ui: { requestRender: vi.fn() }, }, session, btwPanelController: { cancelRunning: btwCancelRunning, closeOrCancel: btwCloseOrCancel }, + surveyController: survey, openUndoSelector, cancelRunningShellCommand, updateEditorBorderHighlight: vi.fn(), updateGoalLengthWarning: vi.fn(), + handleUserInput: vi.fn(), + track: vi.fn(), + openExternalEditor: vi.fn(), + showError: vi.fn(), + stop: vi.fn(), } as unknown as EditorKeyboardHost; const controller = new EditorKeyboardController( @@ -64,6 +83,7 @@ function createHarness(options: { streamingPhase?: string; isCompacting?: boolea cancelCompaction, btwCancelRunning, btwCloseOrCancel, + survey, }; } @@ -79,6 +99,12 @@ function pressCtrlC(editor: Harness['editor']): void { (handler as () => void)(); } +function pressCtrlD(editor: Harness['editor']): void { + const handler = editor['onCtrlD']; + if (handler === undefined) throw new Error('onCtrlD handler not installed'); + (handler as () => void)(); +} + function pressNonEscape(editor: Harness['editor']): void { const handler = editor['onNonEscapeInput']; if (handler === undefined) throw new Error('onNonEscapeInput handler not installed'); @@ -649,3 +675,88 @@ describe('EditorKeyboardController Ctrl-S steering', () => { expect(host.state.queuedMessages).toEqual([]); }); }); + +describe('EditorKeyboardController survey wiring', () => { + it('clears the pending undo-escape sequence only when the survey consumes Escape', () => { + const { editor, openUndoSelector, survey } = createHarness(); + const onPreInput = editor['onPreInput'] as unknown as (data: string) => boolean; + + pressEscape(editor); + survey.handlePreInput.mockReturnValueOnce(true); + onPreInput('\u001B'); + pressEscape(editor); + expect(openUndoSelector).not.toHaveBeenCalled(); + + pressEscape(editor); + pressEscape(editor); + expect(openUndoSelector).toHaveBeenCalledOnce(); + }); + + it('clears a pending exit when Escape arrives through the survey pre-input hook', () => { + const { host, editor } = createHarness(); + const onPreInput = editor['onPreInput'] as unknown as (data: string) => boolean; + + pressCtrlD(editor); + onPreInput('\u001B'); + pressCtrlD(editor); + + expect(host.stop).not.toHaveBeenCalled(); + }); + + it('routes raw keys to the survey pre-input hook first', () => { + const { editor, survey } = createHarness(); + const onPreInput = editor['onPreInput'] as unknown as (data: string) => boolean; + + survey.handlePreInput.mockReturnValueOnce(true); + expect(onPreInput('\u001B[D')).toBe(true); + expect(survey.handlePreInput).toHaveBeenCalledWith('\u001B[D'); + + survey.handlePreInput.mockReturnValueOnce(false); + expect(onPreInput('x')).toBe(false); + }); + + it('forwards editor text changes to the survey', () => { + const { editor, survey } = createHarness(); + const onChange = editor['onChange'] as unknown as (text: string) => void; + + onChange('1'); + + expect(survey.handleEditorChange).toHaveBeenCalledWith('1'); + }); + + it('lets the survey intercept a submit instead of sending it', () => { + const { host, editor, survey } = createHarness(); + const onSubmit = editor['onSubmit'] as unknown as (text: string) => void; + + survey.handleSubmit.mockReturnValueOnce(true); + onSubmit('1'); + + expect(survey.handleSubmit).toHaveBeenCalledWith('1'); + expect(host.handleUserInput).not.toHaveBeenCalled(); + }); + + it('sends the submit when the survey passes it through', () => { + const { host, editor, survey } = createHarness(); + const onSubmit = editor['onSubmit'] as unknown as (text: string) => void; + + survey.handleSubmit.mockReturnValueOnce(false); + onSubmit('hello'); + + expect(host.handleUserInput).toHaveBeenCalledWith('hello'); + }); + + it('closes the survey silently when the external editor opens', () => { + vi.stubEnv('VISUAL', ''); + vi.stubEnv('EDITOR', ''); + try { + const { editor, survey } = createHarness(); + const onOpenExternalEditor = editor['onOpenExternalEditor'] as unknown as () => void; + + onOpenExternalEditor(); + + expect(survey.closeSilently).toHaveBeenCalled(); + } finally { + vi.unstubAllEnvs(); + } + }); +}); diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-compaction.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-compaction.test.ts index 86531df8f86..1fa9e857fdb 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-compaction.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-compaction.test.ts @@ -59,6 +59,7 @@ function makeHost() { sendQueuedMessage: vi.fn(), shiftQueuedMessage: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, + surveyController: { notifyCompactionFinished: vi.fn() }, tasksBrowserController: {}, }; return { host: host as any }; diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts index 6a0bcdd33ec..d519c4b3d9a 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts @@ -97,6 +97,7 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) { sendQueuedMessage: vi.fn(), shiftQueuedMessage: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, + surveyController: { notifyCompactionFinished: vi.fn() }, tasksBrowserController: {}, }; host.setAppState.mockImplementation((patch: Record) => { diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts index 882d79e4ed8..e23726fe27f 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts @@ -59,6 +59,7 @@ function makeHost() { shiftQueuedMessage: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, tasksBrowserController: {}, + surveyController: { notifyToolCallStarted: vi.fn() }, }; return { host: host as never, streamingUI }; } diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-todo.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-todo.test.ts index c28e2672703..331c1bf8d05 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-todo.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-todo.test.ts @@ -33,6 +33,7 @@ function makeHarness() { patchLivePane: vi.fn(), setAppState: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, + surveyController: { notifyToolCallStarted: vi.fn() }, updateActivityPane: vi.fn(), showStatus: vi.fn(), }; diff --git a/apps/kimi-code/test/tui/controllers/survey-controller.test.ts b/apps/kimi-code/test/tui/controllers/survey-controller.test.ts new file mode 100644 index 00000000000..b99c22b54be --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/survey-controller.test.ts @@ -0,0 +1,1884 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { GutterContainer } from '#/tui/components/chrome/gutter-container'; +import { + SurveyController, + type SurveyControllerDeps, + type SurveyHost, +} from '#/tui/controllers/survey-controller'; +import type { TranscriptEntry } from '#/tui/types'; +import { DEFAULT_SURVEY_POPUP_CONFIG } from '#/utils/survey-popup-config'; + +const mocks = vi.hoisted(() => ({ + getSurveyPopupConfig: vi.fn(() => Promise.resolve(undefined)), +})); + +vi.mock('#/utils/survey-popup-config', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getSurveyPopupConfig: mocks.getSurveyPopupConfig, + }; +}); + +const ESC = '\u001B'; +const CSI_LEFT = '\u001B[D'; +const CSI_RIGHT = '\u001B[C'; +const CSI_UP = '\u001B[A'; + +interface TimerDriver { + readonly setTimer: (fn: () => void, ms: number) => unknown; + readonly clearTimer: (handle: unknown) => void; + fire(ms: number): void; + pending(): number[]; +} + +function createTimerDriver(): TimerDriver { + interface Entry { + readonly id: number; + readonly fn: () => void; + readonly ms: number; + cleared: boolean; + } + const entries: Entry[] = []; + let nextId = 0; + return { + setTimer: (fn, ms) => { + nextId += 1; + entries.push({ id: nextId, fn, ms, cleared: false }); + return nextId; + }, + clearTimer: (handle) => { + const entry = entries.find((candidate) => candidate.id === handle); + if (entry !== undefined) entry.cleared = true; + }, + fire: (ms) => { + for (const entry of entries.splice(0)) { + if (!entry.cleared && entry.ms === ms) entry.fn(); + } + }, + pending: () => entries.filter((entry) => !entry.cleared).map((entry) => entry.ms), + }; +} + +interface Harness { + readonly controller: SurveyController; + readonly host: SurveyHost; + readonly container: GutterContainer; + readonly timers: TimerDriver; + readonly clock: { mono: number; wall: number }; + elapse(ms: number): void; + readonly track: ReturnType; + readonly editor: { + inputMode: 'prompt' | 'bash'; + autocompleteActive: boolean; + getText(): string; + setText(text: string): void; + }; + typeDigit(digit: string): void; + readonly state: SurveyHost['state']; + readonly writes: number[]; + readonly randomCalls: () => number; + flush(): Promise; + runTurns(count: number): void; + appear(): void; + renderSurvey(): string; +} + +function createHarness(deps: Partial = {}): Harness { + const clock = { mono: 0, wall: 1_700_000_000_000 }; + const timers = createTimerDriver(); + const track = vi.fn(); + const writes: number[] = []; + const container = new GutterContainer(1, 1); + let editorText = ''; + const editor = { + inputMode: 'prompt' as 'prompt' | 'bash', + autocompleteActive: false, + getText: () => editorText, + setText: (text: string) => { + editorText = text; + }, + hasAutocompleteActivity: () => editor.autocompleteActive, + }; + const state = { + surveyContainer: container, + transcriptEntries: [] as TranscriptEntry[], + editorReplacementMounted: false, + activeDialog: null, + livePane: { mode: 'idle', pendingApproval: null, pendingQuestion: null }, + externalEditorRunning: false, + tasksBrowser: undefined, + editor, + appState: { + model: 'k2', + streamingPhase: 'idle', + isCompacting: false, + contextTokens: 640, + cumulativeTokens: 1234, + permissionMode: 'manual', + thinkingEffort: 'high', + disableFeedbackSurvey: false, + }, + ui: { requestRender: vi.fn() }, + }; + let randomCallCount = 0; + let appearanceCounter = 0; + const { random: randomOverride, ...restDeps } = deps; + const host = { + state, + btwPanelController: { isActive: () => false }, + track, + } as unknown as SurveyHost; + const controller = new SurveyController(host, { + monotonicNow: () => clock.mono, + wallNow: () => clock.wall, + random: () => { + randomCallCount += 1; + return (randomOverride ?? (() => 0))(); + }, + appearanceId: () => { + appearanceCounter += 1; + return `appearance-${String(appearanceCounter)}`; + }, + setTimer: timers.setTimer, + clearTimer: timers.clearTimer, + terminalWidth: () => 120, + terminalHeight: () => 24, + configFresh: () => true, + readGlobalLastShown: async () => undefined, + writeGlobalLastShown: (wallTime) => { + writes.push(wallTime); + }, + ...restDeps, + }); + + const harness: Harness = { + controller, + host, + container, + timers, + clock, + track, + editor, + state: state as unknown as SurveyHost['state'], + writes, + randomCalls: () => randomCallCount, + elapse: (ms) => { + clock.mono += ms; + timers.fire(ms); + }, + flush: async () => { + await new Promise((resolve) => { + setImmediate(resolve); + }); + }, + runTurns: (count) => { + for (let turn = 1; turn <= count; turn++) { + controller.notifyTurnStarted(true); + controller.notifyTurnEnded(); + } + }, + appear: () => { + clock.mono += 600_000; + harness.runTurns(5); + harness.elapse(2000); + clock.mono += 600; + }, + typeDigit: (digit: string) => { + controller.handlePreInput(digit); + controller.handleEditorChange(digit); + }, + renderSurvey: () => + container + .render(120) + .join('\n') + .replaceAll(/\u001B\[[0-9;]*m/g, ''), + }; + return harness; +} + +function userEntry(content: string): TranscriptEntry { + return { id: content, kind: 'user', turnId: undefined, renderMode: 'plain', content }; +} + +const HARNESS_ENVIRONMENT = { + current_model: 'k2', + user_turn_count: 5, + cumulative_tokens: 1234, + virtual_context_tokens: 640, + tool_call_count: 0, + compaction_count: 0, + permission_mode: 'manual', + thinking_effort: 'high', +}; + +const DEFAULT_SNAPSHOT = { + config_probability: 0.005, + config_on_for_models: '*', + config_min_time_before_feedback_ms: 600_000, + config_min_user_turns_before_feedback: 5, + config_min_time_between_feedback_ms: 3_600_000, + config_min_user_turns_between_feedback: 10, + config_min_time_between_global_feedback_ms: 100_000_000, + config_long_context_survey_threshold: 200_000, + config_long_context_probability: 0.2, + config_long_context_trigger_mode: 'cumulative', +}; + +describe('SurveyController gating', () => { + it('appears once the session clears warmup and reports appeared', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + + expect(harness.renderSurvey()).toContain('How is Kimi doing this session? (optional)'); + expect(harness.renderSurvey()).toContain('1: Bad'); + expect(harness.track).toHaveBeenCalledTimes(1); + expect(harness.track).toHaveBeenCalledWith('feedback_survey', { + event_type: 'appeared', + appearance_id: 'appearance-1', + appearance_index: 1, + response: undefined, + ...HARNESS_ENVIRONMENT, + ...DEFAULT_SNAPSHOT, + }); + expect(harness.writes).toEqual([1_700_000_000_000]); + }); + + it('stays hidden during warmup and appears once it completes', async () => { + const harness = createHarness(); + await harness.flush(); + harness.clock.mono += 597_999; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('stays hidden without enough user turns', async () => { + const harness = createHarness(); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(4); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + }); + + it('stays hidden when sampled out', async () => { + const harness = createHarness({ random: () => 0.9 }); + await harness.flush(); + harness.appear(); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + }); + + it('stays hidden when telemetry is disabled', async () => { + const harness = createHarness({ telemetryDisabled: () => true }); + await harness.flush(); + harness.appear(); + expect(harness.container.children).toHaveLength(0); + }); + + it('stays hidden when the user disabled the survey', async () => { + const harness = createHarness({ feedbackSurveyDisabled: () => true }); + await harness.flush(); + harness.appear(); + expect(harness.container.children).toHaveLength(0); + }); + + it('stays hidden inside the persisted global cooldown', async () => { + const harness = createHarness({ + readGlobalLastShown: async () => 1_700_000_000_000 - 1000, + }); + await harness.flush(); + harness.appear(); + expect(harness.container.children).toHaveLength(0); + }); + + it('stays hidden while the latest user message opens an ordered list', async () => { + const harness = createHarness(); + await harness.flush(); + harness.state.transcriptEntries.push(userEntry('1. first item')); + harness.appear(); + expect(harness.container.children).toHaveLength(0); + }); + + it('stays hidden while a btw panel is active', async () => { + const harness = createHarness(); + (harness.host.btwPanelController as { isActive: () => boolean }).isActive = () => true; + await harness.flush(); + harness.appear(); + expect(harness.container.children).toHaveLength(0); + }); + + it('stays hidden while an editor replacement is mounted', async () => { + const harness = createHarness(); + harness.state.editorReplacementMounted = true; + await harness.flush(); + harness.appear(); + expect(harness.container.children).toHaveLength(0); + }); + + it('cancels a pending evaluation when a new turn starts', async () => { + const harness = createHarness(); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(4); + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.controller.notifyTurnStarted(true); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + }); + + it('does not arm the idle evaluation when a cron turn ends on its own', async () => { + const harness = createHarness(); + await harness.flush(); + harness.clock.mono += 600_000; + harness.controller.notifyTurnStarted(false); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'appeared' }), + ); + }); + + it('re-arms the pending evaluation after non-user continuation turns end', async () => { + const harness = createHarness(); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(4); + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + + harness.controller.notifyTurnStarted(false); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('keeps one sample per user turn across evaluations', async () => { + const harness = createHarness({ random: () => 0.9 }); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(5); + harness.elapse(2000); + harness.controller.notifyTurnStarted(false); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + expect(harness.randomCalls()).toBe(1); + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + expect(harness.randomCalls()).toBe(2); + }); + + it('paces the second appearance by time and turns, and bumps appearance_index', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.controller.handlePreInput(ESC); + expect(harness.container.children).toHaveLength(0); + + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + + harness.clock.mono += 3_600_000; + harness.clock.wall += 100_000_000; + harness.runTurns(10); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + expect(harness.track).toHaveBeenLastCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'appeared', appearance_index: 2 }), + ); + }); +}); + +describe('SurveyController long-context arm', () => { + it('shows the long-context survey over the cumulative threshold without any warmup', async () => { + const harness = createHarness(); + harness.state.appState.cumulativeTokens = 250_000; + await harness.flush(); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + + expect(harness.renderSurvey()).toContain('How is Kimi doing this session? (optional)'); + expect(harness.track).toHaveBeenCalledTimes(1); + expect(harness.track).toHaveBeenCalledWith('long_context_survey', { + event_type: 'appeared', + appearance_id: 'appearance-1', + appearance_index: 1, + response: undefined, + current_model: 'k2', + user_turn_count: 1, + cumulative_tokens: 250_000, + virtual_context_tokens: 640, + tool_call_count: 0, + compaction_count: 0, + permission_mode: 'manual', + thinking_effort: 'high', + ...DEFAULT_SNAPSHOT, + }); + expect(harness.writes).toEqual([]); + }); + + it('reports the responded and abandoned states under the long_context_survey name', async () => { + const responded = createHarness(); + responded.state.appState.cumulativeTokens = 250_000; + await responded.flush(); + responded.controller.notifyTurnStarted(true); + responded.controller.notifyTurnEnded(); + responded.elapse(2000); + responded.clock.mono += 600; + + responded.typeDigit('2'); + responded.elapse(400); + responded.elapse(3000); + expect(responded.track).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ + event_type: 'responded', + response: 'fine', + appearance_id: 'appearance-1', + }), + ); + + const abandoned = createHarness(); + abandoned.state.appState.cumulativeTokens = 250_000; + await abandoned.flush(); + abandoned.controller.notifyTurnStarted(true); + abandoned.controller.notifyTurnEnded(); + abandoned.elapse(2000); + abandoned.clock.mono += 600; + + abandoned.controller.handleEditorChange('hello'); + expect(abandoned.track).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ event_type: 'abandoned', appearance_id: 'appearance-1' }), + ); + }); + + it('prefers the long-context survey when the session arm is also eligible', async () => { + const harness = createHarness(); + harness.state.appState.cumulativeTokens = 250_000; + await harness.flush(); + + harness.appear(); + + expect(harness.track).toHaveBeenCalledTimes(1); + expect(harness.track).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ event_type: 'appeared', appearance_index: 1 }), + ); + }); + + it('compares the cumulative counter by default and ignores the window occupancy', async () => { + const harness = createHarness(); + harness.state.appState.contextTokens = 500_000; + await harness.flush(); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + }); + + it('compares the window occupancy when the trigger mode is virtual_context', async () => { + const harness = createHarness({ + config: () => ({ + ...DEFAULT_SURVEY_POPUP_CONFIG, + long_context_trigger_mode: 'virtual_context', + }), + }); + harness.state.appState.contextTokens = 250_000; + await harness.flush(); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + + expect(harness.track).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ + event_type: 'appeared', + cumulative_tokens: 1234, + virtual_context_tokens: 250_000, + config_long_context_trigger_mode: 'virtual_context', + }), + ); + }); + + it('closes the arm on a non-positive effective threshold and produces no events', async () => { + const harness = createHarness({ + config: () => ({ ...DEFAULT_SURVEY_POPUP_CONFIG, long_context_survey_threshold: 0 }), + }); + harness.state.appState.cumulativeTokens = 500_000; + await harness.flush(); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + }); + + it('leaves the session arm running when the threshold closes the long-context arm', async () => { + const harness = createHarness({ + config: () => ({ ...DEFAULT_SURVEY_POPUP_CONFIG, long_context_survey_threshold: 0 }), + }); + harness.state.appState.cumulativeTokens = 500_000; + await harness.flush(); + + harness.appear(); + + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'appeared', config_long_context_survey_threshold: 0 }), + ); + }); + + it('stays hidden when the long-context roll misses, and never rolls again this mount', async () => { + const harness = createHarness({ random: () => 0.9 }); + harness.state.appState.cumulativeTokens = 250_000; + await harness.flush(); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + expect(harness.randomCalls()).toBe(2); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + expect(harness.randomCalls()).toBe(3); + }); + + it('shows at most once per mount even after the first appearance closes', async () => { + const harness = createHarness(); + harness.state.appState.cumulativeTokens = 250_000; + await harness.flush(); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + expect(harness.track).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ event_type: 'appeared' }), + ); + + harness.clock.mono += 600; + harness.controller.handlePreInput(ESC); + expect(harness.container.children).toHaveLength(0); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + + const appeared = harness.track.mock.calls.filter( + (call) => (call[1] as { event_type?: string }).event_type === 'appeared', + ); + expect(appeared).toHaveLength(1); + }); + + it('regains its one chance after a session reset', async () => { + let roll = 0.9; + const harness = createHarness({ random: () => roll }); + harness.state.appState.cumulativeTokens = 250_000; + await harness.flush(); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + + roll = 0.1; + harness.controller.reset(); + await harness.flush(); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + expect(harness.track).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ event_type: 'appeared', appearance_index: 1 }), + ); + expect(harness.writes).toEqual([]); + }); + + it('does not spend the roll while an active prompt suppresses the evaluation', async () => { + const harness = createHarness(); + harness.state.appState.cumulativeTokens = 250_000; + (harness.host.btwPanelController as { isActive: () => boolean }).isActive = () => true; + await harness.flush(); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.randomCalls()).toBe(1); + + (harness.host.btwPanelController as { isActive: () => boolean }).isActive = () => false; + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + expect(harness.track).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ event_type: 'appeared' }), + ); + }); + + it('evaluates only after the mount produced a user turn, even with tokens past the threshold', async () => { + const harness = createHarness(); + harness.state.appState.cumulativeTokens = 250_000; + await harness.flush(); + + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + + harness.controller.notifyTurnStarted(false); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + expect(harness.track).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ event_type: 'appeared' }), + ); + }); + + it('ignores the persisted global cooldown that gates the session arm', async () => { + const harness = createHarness({ + readGlobalLastShown: async () => 1_700_000_000_000 - 1000, + }); + harness.state.appState.cumulativeTokens = 250_000; + await harness.flush(); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + + expect(harness.track).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ event_type: 'appeared' }), + ); + expect(harness.writes).toEqual([]); + }); + + it('does not suppress the session arm through the persisted cooldown after a long-context appearance', async () => { + const harness = createHarness(); + harness.state.appState.cumulativeTokens = 250_000; + await harness.flush(); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + expect(harness.track).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ event_type: 'appeared' }), + ); + harness.clock.mono += 600; + harness.controller.handlePreInput(ESC); + expect(harness.writes).toEqual([]); + + harness.clock.mono += 3_600_000; + harness.runTurns(10); + harness.elapse(2000); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'appeared' }), + ); + expect(harness.writes).toEqual([1_700_000_000_000]); + }); +}); + +describe('SurveyController interaction', () => { + it('selects a rating after the digit debounce and walks pending → thanks → closed', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.track.mockClear(); + + harness.editor.setText('2'); + harness.typeDigit('2'); + harness.typeDigit('2'); + harness.elapse(400); + + expect(harness.track).not.toHaveBeenCalled(); + expect(harness.editor.getText()).toBe(''); + expect(harness.renderSurvey()).toContain('Feedback: Fine · [escape: undo]'); + + harness.elapse(3000); + expect(harness.track).toHaveBeenCalledWith('feedback_survey', { + event_type: 'responded', + appearance_id: 'appearance-1', + appearance_index: 1, + response: 'fine', + ...HARNESS_ENVIRONMENT, + ...DEFAULT_SNAPSHOT, + }); + expect(harness.renderSurvey()).toContain('Thanks for your feedback!'); + + harness.elapse(5000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledTimes(1); + }); + + it('cancels a pending digit selection when the edit continues', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.typeDigit('1'); + harness.controller.handleEditorChange(''); + harness.elapse(400); + expect(harness.container.children).not.toHaveLength(0); + expect(harness.track).toHaveBeenCalledTimes(1); + }); + + it('undo cancels the pending report, and a re-choice reports only the final rating', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.track.mockClear(); + + harness.typeDigit('1'); + harness.elapse(400); + expect(harness.controller.handlePreInput(ESC)).toBe(true); + expect(harness.renderSurvey()).toContain('How is Kimi doing this session? (optional)'); + harness.elapse(3000); + expect(harness.track).not.toHaveBeenCalled(); + + harness.typeDigit('3'); + harness.elapse(400); + harness.elapse(3000); + + const responses = harness.track.mock.calls.map( + (call) => (call[1] as { response?: string }).response, + ); + expect(responses).toEqual(['good']); + expect(harness.track.mock.calls[0]![1]).toMatchObject({ appearance_id: 'appearance-1' }); + }); + + it('Esc dismisses without thanks', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.track.mockClear(); + + expect(harness.controller.handlePreInput(ESC)).toBe(true); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledTimes(1); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'responded', response: 'dismissed' }), + ); + expect(harness.timers.pending()).toHaveLength(0); + }); + + it('digit 0 dismisses without thanks and clears the editor', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.editor.setText('0'); + harness.typeDigit('0'); + harness.elapse(400); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'responded', response: 'dismissed' }), + ); + expect(harness.editor.getText()).toBe(''); + expect(harness.container.children).toHaveLength(0); + }); + + it('abandons on non-option input', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.track.mockClear(); + + harness.controller.handleEditorChange('hello'); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'abandoned' }), + ); + }); + + it('preserves the pre-existing draft snapshot through the editor pre-submit clear', async () => { + const harness = createHarness(); + await harness.flush(); + harness.editor.setText('2'); + harness.appear(); + harness.track.mockClear(); + + harness.controller.handleEditorChange(''); + expect(harness.controller.handleSubmit('2')).toBe(false); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'abandoned' }), + ); + }); + + it('treats a digit retyped after deleting the snapshot draft as fresh input', async () => { + const harness = createHarness(); + await harness.flush(); + harness.editor.setText('2'); + harness.appear(); + + harness.controller.handleEditorChange(''); + harness.controller.handlePreInput('2'); + harness.editor.setText('2'); + harness.controller.handleEditorChange('2'); + expect(harness.timers.pending()).toHaveLength(1); + }); + + it('lets a pre-existing digit draft submit instead of treating it as a rating', async () => { + const harness = createHarness(); + await harness.flush(); + harness.editor.setText('2'); + harness.appear(); + harness.track.mockClear(); + + harness.controller.handleEditorChange('2'); + harness.elapse(400); + expect(harness.track).not.toHaveBeenCalled(); + + expect(harness.controller.handleSubmit('2')).toBe(false); + expect(harness.editor.getText()).toBe('2'); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'abandoned' }), + ); + }); + + it('intercepts a lone digit submit as a selection instead of sending', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.editor.setText('1'); + + expect(harness.controller.handleSubmit('1')).toBe(true); + harness.elapse(3000); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'responded', response: 'bad' }), + ); + expect(harness.editor.getText()).toBe(''); + }); + + it('lets other submissions through and abandons the survey', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + + expect(harness.controller.handleSubmit('hello')).toBe(false); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'abandoned' }), + ); + }); + + it('ignores all input during the mount protection window', async () => { + const harness = createHarness(); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(5); + harness.elapse(2000); + + expect(harness.controller.handlePreInput('1')).toBe(false); + harness.editor.setText('1'); + harness.controller.handleEditorChange('1'); + expect(harness.timers.pending()).toHaveLength(0); + expect(harness.container.children).not.toHaveLength(0); + expect(harness.controller.handlePreInput(ESC)).toBe(false); + expect(harness.controller.handleSubmit('1')).toBe(false); + + harness.clock.mono += 600; + harness.typeDigit('1'); + harness.elapse(400); + harness.elapse(3000); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'responded', response: 'bad' }), + ); + }); + + it('abandons when a yank injects a lone digit during the mount window', async () => { + const harness = createHarness(); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(5); + harness.elapse(2000); + harness.track.mockClear(); + + expect(harness.controller.handlePreInput('\u0019')).toBe(false); + harness.editor.setText('2'); + harness.controller.handleEditorChange('2'); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'abandoned' }), + ); + }); + + it('abandons when history recall injects a digit during the mount window', async () => { + const harness = createHarness(); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(5); + harness.elapse(2000); + harness.track.mockClear(); + + expect(harness.controller.handlePreInput(CSI_UP)).toBe(false); + harness.editor.setText('2'); + harness.controller.handleEditorChange('2'); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'abandoned' }), + ); + }); + + it('moves the hover with arrow keys and confirms with Enter', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + + expect(harness.controller.handlePreInput(CSI_RIGHT)).toBe(true); + expect(harness.controller.handlePreInput(CSI_RIGHT)).toBe(true); + expect(harness.controller.handleSubmit('')).toBe(true); + harness.elapse(3000); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'responded', response: 'fine' }), + ); + }); + + it('wraps the hover backwards from nothing to Dismiss', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + + expect(harness.controller.handlePreInput(CSI_LEFT)).toBe(true); + expect(harness.controller.handleSubmit('')).toBe(true); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'responded', response: 'dismissed' }), + ); + }); + + it('abandons instead of rating when history recall replaces a draft at column zero', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.editor.setText('draft'); + harness.track.mockClear(); + + expect(harness.controller.handlePreInput(CSI_UP)).toBe(false); + harness.editor.setText('2'); + harness.controller.handleEditorChange('2'); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'abandoned' }), + ); + }); + + it('debounces a digit that arrives as a CSI-u sequence', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + + expect(harness.controller.handlePreInput('\u001B[49u')).toBe(false); + harness.editor.setText('1'); + harness.controller.handleEditorChange('1'); + expect(harness.timers.pending()).toHaveLength(1); + harness.elapse(400); + harness.elapse(3000); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'responded', response: 'bad' }), + ); + }); + + it('debounces a typed digit even right after an Up cursor move', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.editor.setText('draft'); + + expect(harness.controller.handlePreInput(CSI_UP)).toBe(false); + expect(harness.controller.handlePreInput('2')).toBe(false); + harness.editor.setText('2'); + harness.controller.handleEditorChange('2'); + expect(harness.timers.pending()).toHaveLength(1); + harness.elapse(400); + harness.elapse(3000); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'responded', response: 'fine' }), + ); + }); + + it('abandons instead of rating when history recall injects a lone digit', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.track.mockClear(); + + expect(harness.controller.handlePreInput(CSI_UP)).toBe(false); + harness.editor.setText('2'); + harness.controller.handleEditorChange('2'); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'abandoned' }), + ); + harness.elapse(400); + expect(harness.track).toHaveBeenCalledTimes(1); + }); + + it('abandons instead of rating when a kill-ring yank injects a lone digit', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.track.mockClear(); + + expect(harness.controller.handlePreInput('\u0019')).toBe(false); + harness.editor.setText('2'); + harness.controller.handleEditorChange('2'); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'abandoned' }), + ); + }); + + it('abandons instead of rating when a paste injects a lone digit', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.track.mockClear(); + + expect(harness.controller.handlePreInput('\u001B[200~2\u001B[201~')).toBe(false); + harness.editor.setText('2'); + harness.controller.handleEditorChange('2'); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'abandoned' }), + ); + harness.elapse(400); + expect(harness.track).toHaveBeenCalledTimes(1); + }); + + it('lets arrow keys through to the editor when a draft exists', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.editor.setText('draft'); + + expect(harness.controller.handlePreInput(CSI_RIGHT)).toBe(false); + expect(harness.controller.handlePreInput(CSI_LEFT)).toBe(false); + expect(harness.controller.handleSubmit('')).toBe(false); + expect(harness.track).toHaveBeenCalledTimes(1); + }); + + it('clears the hover when the survey closes so a later appearance starts clean', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + expect(harness.controller.handlePreInput(CSI_RIGHT)).toBe(true); + harness.controller.handlePreInput(ESC); + expect(harness.container.children).toHaveLength(0); + + harness.clock.mono += 3_600_000; + harness.clock.wall += 100_000_000; + harness.runTurns(10); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + + harness.track.mockClear(); + expect(harness.controller.handleSubmit('')).toBe(false); + expect(harness.track).not.toHaveBeenCalled(); + }); + + it('reports the settled rating with the turn count of the turn it was made in', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.typeDigit('1'); + harness.elapse(400); + harness.track.mockClear(); + + harness.controller.notifyTurnStarted(true); + + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'responded', user_turn_count: 5 }), + ); + }); + + it('settles the pending rating when a turn starts inside the undo window', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.typeDigit('1'); + harness.elapse(400); + harness.track.mockClear(); + + harness.controller.notifyTurnStarted(true); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'responded', response: 'bad' }), + ); + expect(harness.timers.pending()).toHaveLength(0); + }); + + it('closes silently when a turn starts while open', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.track.mockClear(); + + harness.controller.notifyTurnStarted(true); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + }); + + it('closes silently on a gate flip (editor replacement) without reporting', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.track.mockClear(); + + harness.controller.closeSilently(); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + }); + + it('treats bash-mode input as non-option input', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.editor.inputMode = 'bash'; + + harness.typeDigit('1'); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'abandoned' }), + ); + }); + + it('sweeps the survey away as abandoned when the editor enters bash mode', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.track.mockClear(); + + harness.editor.inputMode = 'bash'; + harness.controller.notifyInputModeChanged('bash'); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'abandoned' }), + ); + expect(harness.controller.handlePreInput(ESC)).toBe(false); + }); + + it('does not open while the external editor is running, on either arm', async () => { + const harness = createHarness(); + harness.state.appState.cumulativeTokens = 250_000; + await harness.flush(); + harness.clock.mono += 600_000; + harness.state.externalEditorRunning = true; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + + harness.state.externalEditorRunning = false; + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ event_type: 'appeared' }), + ); + }); + + it('does not open while the tasks browser takeover is active', async () => { + const harness = createHarness(); + await harness.flush(); + harness.clock.mono += 600_000; + harness.state.tasksBrowser = {} as never; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + + harness.state.tasksBrowser = undefined; + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('does not select a hovered option with Enter after the terminal narrows', async () => { + let width = 120; + const harness = createHarness({ terminalWidth: () => width }); + await harness.flush(); + harness.appear(); + expect(harness.controller.handlePreInput(CSI_RIGHT)).toBe(true); + harness.track.mockClear(); + + width = 8; + expect(harness.controller.handleSubmit('')).toBe(false); + expect(harness.track).not.toHaveBeenCalled(); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('does not fire a pending digit selection after the terminal narrows', async () => { + let width = 120; + const harness = createHarness({ terminalWidth: () => width }); + await harness.flush(); + harness.appear(); + harness.editor.setText('2'); + harness.typeDigit('2'); + harness.track.mockClear(); + + width = 8; + harness.elapse(400); + expect(harness.track).not.toHaveBeenCalled(); + expect(harness.editor.getText()).toBe('2'); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('lets Escape undo a pending rating even after the terminal narrows', async () => { + let width = 120; + const harness = createHarness({ terminalWidth: () => width }); + await harness.flush(); + harness.appear(); + harness.typeDigit('2'); + harness.elapse(400); + harness.track.mockClear(); + + width = 8; + expect(harness.controller.handlePreInput(ESC)).toBe(true); + harness.elapse(3000); + expect(harness.track).not.toHaveBeenCalled(); + expect(harness.renderSurvey()).toContain('How is Kimi doing this session? (optional)'); + }); + + it('does not open when the terminal is shorter than the survey needs', async () => { + let height = 24; + const harness = createHarness({ terminalHeight: () => height }); + await harness.flush(); + harness.clock.mono += 600_000; + height = 7; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + + height = 8; + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('requires more height when a narrow terminal wraps the survey title', async () => { + let height = 24; + const harness = createHarness({ terminalWidth: () => 14, terminalHeight: () => height }); + await harness.flush(); + harness.clock.mono += 600_000; + height = 14; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + + height = 15; + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('counts word-wrapped title rows instead of dividing characters', async () => { + let height = 24; + const harness = createHarness({ terminalWidth: () => 18, terminalHeight: () => height }); + await harness.flush(); + harness.clock.mono += 600_000; + height = 13; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + + height = 14; + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('passes keys through when the terminal shrinks in height while open', async () => { + let height = 24; + const harness = createHarness({ terminalHeight: () => height }); + await harness.flush(); + harness.appear(); + harness.track.mockClear(); + + height = 6; + expect(harness.controller.handlePreInput(ESC)).toBe(false); + expect(harness.track).not.toHaveBeenCalled(); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('does not open when the terminal is narrower than the option legend', async () => { + let width = 120; + const harness = createHarness({ terminalWidth: () => width }); + await harness.flush(); + harness.clock.mono += 600_000; + width = 13; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + + width = 14; + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('passes keys through when the terminal is narrowed below the legend width while open', async () => { + let width = 120; + const harness = createHarness({ terminalWidth: () => width }); + await harness.flush(); + harness.appear(); + harness.track.mockClear(); + + width = 8; + expect(harness.controller.handlePreInput(ESC)).toBe(false); + expect(harness.track).not.toHaveBeenCalled(); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('does not open while autocomplete is active, and Esc passes through to it', async () => { + const harness = createHarness(); + await harness.flush(); + harness.clock.mono += 600_000; + harness.editor.autocompleteActive = true; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + + harness.editor.autocompleteActive = false; + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + + harness.editor.autocompleteActive = true; + harness.track.mockClear(); + expect(harness.controller.handlePreInput(ESC)).toBe(false); + expect(harness.container.children).not.toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + }); + + it('does not open while the editor is in bash mode', async () => { + const harness = createHarness(); + await harness.flush(); + harness.clock.mono += 600_000; + harness.editor.inputMode = 'bash'; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + + harness.editor.inputMode = 'prompt'; + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('settles and closes a pending rating when the editor enters bash mode', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.typeDigit('2'); + harness.elapse(400); + harness.track.mockClear(); + + harness.editor.inputMode = 'bash'; + harness.controller.notifyInputModeChanged('bash'); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'responded', response: 'fine' }), + ); + expect(harness.controller.handlePreInput(ESC)).toBe(false); + }); + + it('closes the thanks state silently when the editor enters bash mode', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.typeDigit('2'); + harness.elapse(400); + harness.elapse(3000); + expect(harness.renderSurvey()).toContain('Thanks for your feedback!'); + harness.track.mockClear(); + + harness.editor.inputMode = 'bash'; + harness.controller.notifyInputModeChanged('bash'); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + expect(harness.controller.handlePreInput(ESC)).toBe(false); + }); + + it('defers evaluation until the persisted cooldown read settles, then honors it', async () => { + let settleRead!: () => void; + const harness = createHarness({ + readGlobalLastShown: () => + new Promise((resolve) => { + settleRead = () => { + resolve(1_700_000_000_000); + }; + }), + }); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + + settleRead(); + await harness.flush(); + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + }); + + it('keeps the just-written global cooldown across a reset', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + expect(harness.writes).toEqual([1_700_000_000_000]); + harness.track.mockClear(); + + harness.controller.reset(); + await harness.flush(); + + harness.clock.wall += 1; + harness.clock.mono += 600_000; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + }); + + it('takes the newer of the in-memory and reloaded cooldown timestamps', async () => { + let diskValue: number | undefined = 1_600_000_000_000; + const harness = createHarness({ readGlobalLastShown: async () => diskValue }); + await harness.flush(); + harness.appear(); + expect(harness.writes).toEqual([1_700_000_000_000]); + harness.track.mockClear(); + + diskValue = 1_650_000_000_000; + harness.controller.reset(); + await harness.flush(); + + harness.clock.wall += 1; + harness.clock.mono += 600_000; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + }); + + it('resets in-session pacing on session reset', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.controller.reset(); + await harness.flush(); + + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + }); + + it('stays hidden when the appState mirror disables the survey', async () => { + const harness = createHarness(); + harness.state.appState.disableFeedbackSurvey = true; + await harness.flush(); + harness.appear(); + expect(harness.container.children).toHaveLength(0); + }); +}); + +describe('SurveyController event payload', () => { + it('reports the live session statistics and the current model', async () => { + const harness = createHarness(); + await harness.flush(); + harness.state.appState.model = 'k3'; + harness.clock.mono += 600_000; + for (let turn = 1; turn <= 5; turn++) { + harness.controller.notifyTurnStarted(true); + harness.controller.notifyToolCallStarted(); + harness.controller.notifyToolCallStarted(); + harness.controller.notifyTurnEnded(); + } + harness.controller.notifyToolCallStarted(); + harness.elapse(2000); + + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ + current_model: 'k3', + user_turn_count: 5, + cumulative_tokens: 1234, + virtual_context_tokens: 640, + tool_call_count: 11, + permission_mode: 'manual', + }), + ); + }); + + it('counts finished compactions since mount and resets on remount', async () => { + const harness = createHarness(); + await harness.flush(); + harness.controller.notifyCompactionFinished(); + harness.controller.notifyCompactionFinished(); + harness.appear(); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ compaction_count: 2, thinking_effort: 'high' }), + ); + + harness.controller.reset(); + await harness.flush(); + harness.clock.wall += 100_000_000; + harness.appear(); + expect(harness.track).toHaveBeenLastCalledWith( + 'feedback_survey', + expect.objectContaining({ compaction_count: 0 }), + ); + }); + + it('snapshots the config that produced the appearance, not a later refresh', async () => { + let cloudConfig = { ...DEFAULT_SURVEY_POPUP_CONFIG, probability: 0.5 }; + const harness = createHarness({ config: () => cloudConfig }); + await harness.flush(); + harness.appear(); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'appeared', config_probability: 0.5 }), + ); + + cloudConfig = { ...cloudConfig, probability: 0.9 }; + harness.typeDigit('1'); + harness.elapse(400); + harness.elapse(3000); + expect(harness.track).toHaveBeenLastCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'responded', config_probability: 0.5 }), + ); + }); + + it('cancels the pending digit selection when a cursor key passes through to the draft', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.editor.setText('2'); + harness.typeDigit('2'); + + expect(harness.controller.handlePreInput(CSI_LEFT)).toBe(false); + harness.elapse(400); + expect(harness.track).toHaveBeenCalledTimes(1); + expect(harness.editor.getText()).toBe('2'); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('keeps deferring while a region-change refresh is still in flight', async () => { + let region = 'region-a'; + let settle!: () => void; + let first = true; + const harness = createHarness({ + configRegion: () => region, + refreshConfig: () => { + if (first) { + first = false; + return undefined; + } + return new Promise((resolve) => { + settle = resolve; + }); + }, + }); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(4); + + region = 'region-b'; + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + + settle(); + await harness.flush(); + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('defers evaluation until the startup refresh settles', async () => { + let settle!: () => void; + const harness = createHarness({ + refreshConfig: () => + new Promise((resolve) => { + settle = resolve; + }), + }); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + + settle(); + await harness.flush(); + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('refreshes and defers when the config cache has gone stale', async () => { + let fresh = true; + const refreshConfig = vi.fn(() => { + fresh = true; + }); + const harness = createHarness({ configFresh: () => fresh, refreshConfig }); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(4); + expect(harness.container.children).toHaveLength(0); + + fresh = false; + harness.clock.mono += 3_600_000; + harness.runTurns(1); + harness.elapse(2000); + expect(refreshConfig).toHaveBeenCalledTimes(2); + expect(harness.container.children).toHaveLength(0); + + await harness.flush(); + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('defers the evaluation that triggers a refresh so a stale policy cannot open the survey', async () => { + let region = 'region-a'; + let config = { ...DEFAULT_SURVEY_POPUP_CONFIG, probability: 1 }; + const refreshConfig = vi.fn(() => { + config = { ...config, probability: 0 }; + }); + const harness = createHarness({ + configRegion: () => region, + config: () => config, + refreshConfig, + random: () => 0.9, + }); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(4); + + region = 'region-b'; + harness.runTurns(1); + harness.elapse(2000); + expect(refreshConfig).toHaveBeenCalledTimes(2); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + }); + + it('evaluates with the refreshed policy on the next turn after the deferred evaluation', async () => { + let region = 'region-a'; + let config = { ...DEFAULT_SURVEY_POPUP_CONFIG, probability: 1, on_for_models: ['other-model'] }; + const refreshConfig = vi.fn(() => { + config = { ...config, on_for_models: ['*'] }; + }); + const harness = createHarness({ + configRegion: () => region, + config: () => config, + refreshConfig, + }); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(4); + + region = 'region-b'; + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + + await harness.flush(); + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'appeared', config_on_for_models: '*' }), + ); + }); + + it('re-refreshes the cloud config when the region changes', async () => { + let region = 'region-a'; + const refreshConfig = vi.fn(); + const harness = createHarness({ refreshConfig, configRegion: () => region }); + await harness.flush(); + expect(refreshConfig).toHaveBeenCalledTimes(1); + + region = 'region-b'; + harness.runTurns(1); + harness.elapse(2000); + expect(refreshConfig).toHaveBeenCalledTimes(2); + }); + + it('re-refreshes the cloud config once the previous refresh is over an hour old', async () => { + const refreshConfig = vi.fn(); + const harness = createHarness({ refreshConfig }); + await harness.flush(); + expect(refreshConfig).toHaveBeenCalledTimes(1); + + harness.clock.mono += 3_597_000; + harness.runTurns(1); + harness.elapse(2000); + expect(refreshConfig).toHaveBeenCalledTimes(1); + + harness.clock.mono += 2_000; + harness.runTurns(1); + harness.elapse(2000); + expect(refreshConfig).toHaveBeenCalledTimes(2); + }); + + it('applies the cloud model gate at evaluation time', async () => { + const harness = createHarness({ + config: () => ({ ...DEFAULT_SURVEY_POPUP_CONFIG, on_for_models: ['other-model'] }), + }); + await harness.flush(); + harness.appear(); + expect(harness.container.children).toHaveLength(0); + }); +}); + +describe('SurveyController cloud config refresh', () => { + it('fires the injected refresh once at mount and not on session reset', async () => { + const refreshConfig = vi.fn(); + const harness = createHarness({ refreshConfig }); + await harness.flush(); + expect(refreshConfig).toHaveBeenCalledTimes(1); + + harness.controller.reset(); + expect(refreshConfig).toHaveBeenCalledTimes(1); + }); + + it('resolves the access token before refreshing the named config', async () => { + mocks.getSurveyPopupConfig.mockClear(); + const harness = createHarness({ accessToken: async () => 'tok' }); + await harness.flush(); + expect(mocks.getSurveyPopupConfig).toHaveBeenCalledWith({ + accessToken: 'tok', + }); + }); + + it('refreshes anonymously when no token is cached', async () => { + mocks.getSurveyPopupConfig.mockClear(); + const harness = createHarness({ accessToken: async () => undefined }); + await harness.flush(); + expect(mocks.getSurveyPopupConfig).toHaveBeenCalledWith({ + accessToken: undefined, + }); + }); + + it.each([ + [ + 'rejects', + async (): Promise => { + throw new Error('no facade'); + }, + ], + [ + 'throws synchronously', + () => { + throw new Error('no facade'); + }, + ], + ])('skips the fetch when the token provider %s', async (_kind, accessToken) => { + mocks.getSurveyPopupConfig.mockClear(); + const harness = createHarness({ + accessToken: accessToken as () => Promise, + }); + await harness.flush(); + expect(mocks.getSurveyPopupConfig).not.toHaveBeenCalled(); + }); + + it('does not fetch without a token provider', async () => { + mocks.getSurveyPopupConfig.mockClear(); + const harness = createHarness(); + await harness.flush(); + expect(mocks.getSurveyPopupConfig).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index 67227c561a4..9738e7e5969 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -58,6 +58,7 @@ describe('createTUIState', () => { expect(state.activityContainer).toBeDefined(); expect(state.todoPanelContainer).toBeDefined(); expect(state.queueContainer).toBeDefined(); + expect(state.surveyContainer).toBeDefined(); expect(state.editorContainer).toBeDefined(); expect(state.editor).toBeDefined(); expect(state.footer).toBeDefined(); @@ -129,6 +130,7 @@ describe('createTUIState', () => { state.todoPanelContainer, state.queueContainer, state.btwPanelContainer, + state.surveyContainer, state.editorContainer, ]); 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 dcc5c9414bc..aaa76f4302d 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 @@ -1,6 +1,6 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { existsSync } from 'node:fs'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; @@ -50,6 +50,7 @@ import { import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; import type { SessionReplayRenderer } from '#/tui/controllers/session-replay'; import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; +import type { SurveyController } from '#/tui/controllers/survey-controller'; import { handleFeedbackCommand } from '#/tui/commands/info'; import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; import { openUrl } from '#/utils/open-url'; @@ -95,19 +96,12 @@ vi.mock('../../src/feedback/archive', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - // Wrap the real implementation so archive packaging keeps working in the - // other tests; individual tests can reject it to simulate an unwritable - // cache dir. createFeedbackArchivePath: vi.fn(actual.createFeedbackArchivePath), }; }); -// /feedback opens GitHub Issues in a browser when submission fails — stub it -// out so the test suite never spawns a browser window. vi.mock('#/utils/open-url', () => ({ openUrl: vi.fn() })); -// Clipboard access spawns platform tools (pbcopy/wl-copy …) and emits OSC 52 — -// stub it out so the suite never touches the real clipboard or stdout. vi.mock('#/utils/clipboard/clipboard-text', () => ({ copyTextToClipboard: vi.fn(async () => 'native'), })); @@ -123,6 +117,7 @@ function stripSgr(text: string): string { interface MessageDriver { state: TUIState; + surveyController: SurveyController; streamingUI: StreamingUIController; sessionReplay: SessionReplayRenderer; pluginCommandMap: Map; @@ -321,8 +316,6 @@ function makeHarness(session = makeSession(), overrides: Record }), getExperimentalFeatures: vi.fn(async () => []), auth: { - // /feedback gates on the OAuth token rather than the active model, so - // the default mock is a signed-in user; signed-out cases override this. status: vi.fn(async () => ({ providers: [{ providerName: 'managed:kimi-code', hasToken: true }], })), @@ -340,8 +333,6 @@ function makeHarness(session = makeSession(), overrides: Record }, ...overrides, }; - // The TUI lists sessions through keyset pages; derive the page mock from - // the (possibly overridden) full-list mock unless a test overrides paging. if (!('listSessionsPage' in harness)) { const listSessions = harness.listSessions as (input?: { workDir?: string; @@ -551,7 +542,6 @@ describe('KimiTUI message flow', () => { }; const { driver, harness } = await makeDriver(session, {}, startupInput); - // Startup stays session-less on the v2 engine. expect(harness.createSession).not.toHaveBeenCalled(); expect(driver.state.appState.sessionId).toBe(''); expect(driver.state.appState.model).toBe('k2'); @@ -614,13 +604,10 @@ describe('KimiTUI message flow', () => { }, startupInput, ); - // `makeDriver` stops after init(); the skill command list is refreshed in - // finishStartup, so resolve it here to exercise the workspace-level path. await ( driver as unknown as { refreshSkillCommands(): Promise } ).refreshSkillCommands(); - // Startup resolves skill commands from the workspace, no session needed. expect(harness.createSession).not.toHaveBeenCalled(); driver.handleUserInput('/skill:my-skill'); @@ -876,9 +863,6 @@ describe('KimiTUI message flow', () => { await ( driver as unknown as { refreshSkillCommands(): Promise } ).refreshSkillCommands(); - // Materialize the lazy session first: an active goal only exists inside a - // live session, and lazy creation would refresh (and clear) the goal - // snapshot set up below. await (driver as unknown as { ensureSession(): Promise }).ensureSession(); driver.state.appState.goal = makeActiveGoalSnapshot(); @@ -965,7 +949,6 @@ describe('KimiTUI message flow', () => { await vi.waitFor(() => { expect(driver.state.appState.streamingPhase).toBe('idle'); }); - // A rejected group leaves no local undo anchor the engine never recorded. expect(driver.state.transcriptEntries.filter((entry) => entry.kind === 'user')).toHaveLength(0); }); @@ -1073,8 +1056,6 @@ describe('KimiTUI message flow', () => { const turns = groupTurns(driver.state.transcriptEntries); expect(turns).toHaveLength(3); - // The hook result is projected inside the bundle's window (after the - // skill cards, before the prompt), matching the live event order. expect(turns[1]!.entries.map((entry) => entry.kind)).toEqual([ 'skill_activation', 'skill_activation', @@ -1083,8 +1064,6 @@ describe('KimiTUI message flow', () => { 'assistant', ]); expect(turns[1]!.entries[2]!.hookResult).toBe(true); - // The user entry shows only the caller's own text — the rendered skill - // blocks the engine prepended to the content are stripped. expect(turns[1]!.entries[3]!.content).toBe('please /skill:review and /skill:security'); expect( turns[1]!.entries.slice(0, 2).map((entry) => entry.bundledWithPrompt), @@ -1215,8 +1194,6 @@ describe('KimiTUI message flow', () => { driver as unknown as { refreshSkillCommands(): Promise } ).refreshSkillCommands(); - // Hold the RPC open so the skill.activated event can land mid-flight, - // exactly how the in-process wiring delivers it during the call. let release!: () => void; const heldPrompt = new Promise((resolve) => { release = resolve; @@ -1259,8 +1236,6 @@ describe('KimiTUI message flow', () => { }; const { driver, harness } = await makeDriver(session, {}, startupInput); - // Hold the first createSession open so both triggers land inside the - // in-flight window. let resolveCreate!: (s: ReturnType) => void; harness.createSession.mockImplementationOnce( () => new Promise((resolve) => { resolveCreate = resolve; }), @@ -1286,8 +1261,6 @@ describe('KimiTUI message flow', () => { }; const { driver, harness } = await makeDriver(lazySession, {}, startupInput); - // Hold the lazy createSession open so it is still in flight when /new - // arrives (triggered directly, without a prompt starting a turn). let resolveCreate!: (s: ReturnType) => void; harness.createSession .mockImplementationOnce( @@ -1301,13 +1274,11 @@ describe('KimiTUI message flow', () => { }); driver.handleUserInput('/new'); - // /new must not race a second createSession while the lazy one is held. await new Promise((resolve) => setImmediate(resolve)); expect(harness.createSession).toHaveBeenCalledTimes(1); resolveCreate(lazySession); await pending; - // No turn started, so /new proceeds after the wait. await vi.waitFor(() => { expect(harness.createSession).toHaveBeenCalledTimes(2); expect(driver.getCurrentSessionId()).toBe('ses-new'); @@ -1323,8 +1294,6 @@ describe('KimiTUI message flow', () => { }; const { driver, harness } = await makeDriver(lazySession, {}, startupInput); - // Hold the lazy createSession open so the first prompt is still pending - // when /new arrives. let resolveCreate!: (s: ReturnType) => void; harness.createSession.mockImplementationOnce( () => new Promise((resolve) => { resolveCreate = resolve; }), @@ -1337,8 +1306,6 @@ describe('KimiTUI message flow', () => { driver.handleUserInput('/new'); resolveCreate(lazySession); - // The prompt continuation starts its turn first; /new (idle-only) must - // then be blocked instead of switching away from the active session. await vi.waitFor(() => { expect(lazySession.prompt).toHaveBeenCalledWith('hello', { promptId: undefined }); expect(stripSgr(renderTranscript(driver))).toContain('Cannot /new while streaming'); @@ -1375,8 +1342,6 @@ describe('KimiTUI message flow', () => { startupInput, ); - // Hold the lazy createSession open so the first prompt is still pending - // when the effort switch arrives. let resolveCreate!: (s: ReturnType) => void; harness.createSession.mockImplementationOnce( () => new Promise((resolve) => { resolveCreate = resolve; }), @@ -1389,8 +1354,6 @@ describe('KimiTUI message flow', () => { driver.handleUserInput('/effort low'); resolveCreate(lazySession); - // The prompt starts its turn first; the switch must then be rejected - // instead of being silently overwritten by the session assembly. await vi.waitFor(() => { expect(lazySession.prompt).toHaveBeenCalledWith('hello', { promptId: undefined }); expect(stripSgr(renderTranscript(driver))).toContain('Cannot switch models while streaming'); @@ -1414,7 +1377,6 @@ describe('KimiTUI message flow', () => { startupInput, ); - // Trigger the lazy creation directly, without a prompt starting a turn. let resolveCreate!: (s: ReturnType) => void; harness.createSession.mockImplementationOnce( () => new Promise((resolve) => { resolveCreate = resolve; }), @@ -1426,8 +1388,6 @@ describe('KimiTUI message flow', () => { }); driver.handleUserInput('/effort low'); - // While the creation is held the switch must wait, not write pending - // state that the assembly would overwrite. await new Promise((resolve) => setImmediate(resolve)); expect(driver.state.appState.thinkingEffort).toBe('high'); @@ -1455,8 +1415,6 @@ describe('KimiTUI message flow', () => { startupInput, ); - // Hold the lazy createSession open so the first prompt is still pending - // when the picker selection arrives. let resolveCreate!: (s: ReturnType) => void; harness.createSession.mockImplementationOnce( () => new Promise((resolve) => { resolveCreate = resolve; }), @@ -1472,8 +1430,6 @@ describe('KimiTUI message flow', () => { picker.handleInput('\r'); resolveCreate(lazySession); - // The prompt starts its turn first; the switch must then be rejected - // instead of being overwritten when the lazy creation completes. await vi.waitFor(() => { expect(lazySession.prompt).toHaveBeenCalledWith('hello', { promptId: undefined }); expect(stripSgr(renderTranscript(driver))).toContain('Cannot switch sessions while streaming'); @@ -1491,7 +1447,6 @@ describe('KimiTUI message flow', () => { }; const { driver, harness } = await makeDriver(session, {}, startupInput); - // Alt+S session-only thinking before any session exists. await ( driver as unknown as { authFlow: { activateModelAfterLogin(model: string, effort?: string): Promise }; @@ -1528,11 +1483,8 @@ describe('KimiTUI message flow', () => { startupInput, ); - // The footer shows the config default… expect(driver.state.appState.planMode).toBe(true); - // …but the create call must not repeat it: the v2 engine applies - // defaultPlanMode at create time, and re-entering plan mode throws. driver.handleUserInput('hello'); await vi.waitFor(() => { @@ -1572,7 +1524,6 @@ describe('KimiTUI message flow', () => { }; const { driver } = await makeDriver(session, {}, startupInput); - // A prompt and a bash command both trigger the same in-flight creation. driver.handleUserInput('hello'); driver.state.appState.inputMode = 'bash'; driver.state.editor.inputMode = 'bash'; @@ -1581,7 +1532,6 @@ describe('KimiTUI message flow', () => { await vi.waitFor(() => { expect(session.prompt).toHaveBeenCalledWith('hello', { promptId: undefined }); }); - // The shell command must be queued, not run concurrently with the prompt. expect(runShellCommand).not.toHaveBeenCalled(); expect(driver.state.queuedMessages).toEqual([ { text: 'ls', agentId: 'main', mode: 'bash' }, @@ -1593,8 +1543,6 @@ describe('KimiTUI message flow', () => { const startupInput: KimiTUIStartupInput = { ...makeStartupInput(), engineV2: true, - // No model configured: /settings must still open so the user can fix - // local editor/theme/update settings before picking a model. cliOptions: { ...makeStartupInput().cliOptions }, }; const { driver, harness } = await makeDriver(session, {}, startupInput); @@ -1631,15 +1579,12 @@ describe('KimiTUI message flow', () => { driver as unknown as { refreshSkillCommands(): Promise } ).refreshSkillCommands(); - // A prompt and a skill command both trigger the same in-flight creation. driver.handleUserInput('hello'); driver.handleUserInput('/skill:my-skill'); await vi.waitFor(() => { expect(session.prompt).toHaveBeenCalledWith('hello', { promptId: undefined }); }); - // The skill activation must be blocked, not run concurrently with the - // prompt's turn. expect(session.activateSkill).not.toHaveBeenCalled(); expect(harness.createSession).toHaveBeenCalledTimes(1); }); @@ -1650,7 +1595,6 @@ describe('KimiTUI message flow', () => { const startupInput: KimiTUIStartupInput = { ...makeStartupInput(), engineV2: true, - // No model configured: /plugins must still work via the app-global API. cliOptions: { ...makeStartupInput().cliOptions }, }; const { driver, harness } = await makeDriver(session, { listPlugins }, startupInput); @@ -1669,7 +1613,6 @@ describe('KimiTUI message flow', () => { const startupInput: KimiTUIStartupInput = { ...makeStartupInput(), engineV2: true, - // No model configured: the read-only form must still work. cliOptions: { ...makeStartupInput().cliOptions }, }; const { driver, harness } = await makeDriver(session, {}, startupInput); @@ -1758,7 +1701,6 @@ describe('KimiTUI message flow', () => { const getConfig = vi.fn( async (): Promise<{ models: Record; defaultModel?: string }> => ({ models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, - // Initially no default model configured. }), ); const startupInput: KimiTUIStartupInput = { @@ -1769,8 +1711,6 @@ describe('KimiTUI message flow', () => { const { driver, harness } = await makeDriver(session, { getConfig }, startupInput); expect(driver.state.appState.model).toBe(''); - // A default model is added externally, then /reload runs before the first - // prompt — the lazy defaults must be refreshed, not left stale. getConfig.mockResolvedValue({ models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, defaultModel: 'k2', @@ -1802,8 +1742,6 @@ describe('KimiTUI message flow', () => { expect(driver.state.appState.model).toBe('k2'); expect(driver.state.appState.maxContextTokens).toBe(100); - // The default model is removed externally, then /reload runs — the - // hydrated value must not survive as a stale explicit model. getConfig.mockResolvedValue({ models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, }); @@ -1850,8 +1788,6 @@ describe('KimiTUI message flow', () => { await vi.waitFor(() => { expect(harness.createSession).toHaveBeenCalledTimes(1); }); - // The engine already applied defaultPlanMode at create; the command must - // notice the active plan mode instead of re-entering (which would throw). expect(session.setPlanMode).not.toHaveBeenCalled(); expect(driver.state.appState.planMode).toBe(true); }); @@ -1879,8 +1815,6 @@ describe('KimiTUI message flow', () => { const { driver } = await makeDriver(session, { getConfig }, startupInput); expect(driver.state.appState.permissionMode).toBe('auto'); - // The elevated default is removed externally, then /reload runs — a stale - // elevated mode must not reach the first lazy-created session. getConfig.mockResolvedValue({ models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, defaultModel: 'k2', @@ -1895,7 +1829,6 @@ describe('KimiTUI message flow', () => { it('does not pass --plan when config already applies default plan mode (v2 engine)', async () => { const session = makeSession({ id: 'ses-lazy', - // The engine applied the config default at create. getStatus: vi.fn(async () => ({ model: 'k2', thinkingEffort: 'off', @@ -1928,8 +1861,6 @@ describe('KimiTUI message flow', () => { await vi.waitFor(() => { expect(session.prompt).toHaveBeenCalledWith('hello', { promptId: undefined }); }); - // The engine applies the config default at create; repeating --plan would - // re-enter plan mode and throw, so it must not be passed again. expect(harness.createSession).toHaveBeenCalledWith( expect.objectContaining({ planMode: undefined }), ); @@ -1941,7 +1872,6 @@ describe('KimiTUI message flow', () => { const startupInput: KimiTUIStartupInput = { ...makeStartupInput(), engineV2: true, - // No model configured: read-only views must still open. cliOptions: { ...makeStartupInput().cliOptions }, }; const { driver, harness } = await makeDriver(session, {}, startupInput); @@ -1991,8 +1921,6 @@ describe('KimiTUI message flow', () => { }; const { driver, harness } = await makeDriver(session, {}, startupInput); - // Hold the post-create assembly open inside setPermission: the session is - // assigned but setup is not finished yet. let resolvePermission!: () => void; session.setPermission.mockImplementationOnce( () => new Promise((resolve) => { resolvePermission = resolve; }), @@ -2004,8 +1932,6 @@ describe('KimiTUI message flow', () => { expect(session.setPermission).toHaveBeenCalled(); }); - // A second trigger must wait for the assembly instead of dispatching - // against the half-initialized session. const second = ensure.call(driver); let secondResolved = false; void second.then(() => { @@ -3174,8 +3100,6 @@ command = "vim" const attachment = imageStore.addVideo('video/mp4', '/tmp/clip.mp4'); imageStore.completeVideo(attachment, { fileId: 'file-v1' }); - // The paste was uploaded to the daemon file store, so the submission - // carries a bare `kimi-file://` reference — no local cache copy. driver.handleUserInput(`watch ${attachment.placeholder}`); const parts = vi.mocked(session.prompt).mock.calls[0]?.[0] as @@ -3191,8 +3115,6 @@ command = "vim" emitTurn(driver, 1); - // The engine materialized its own session copy at intake, so the staged - // upload is garbage once the consuming turn ends. await vi.waitFor(() => { expect(harness.deleteFile).toHaveBeenCalledWith('file-v1'); }); @@ -3261,8 +3183,6 @@ command = "vim" { type: 'text', text: 'describe ' }, { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, ], - // Staged media rides with a client-chosen prompt id so the consuming - // turn's `turn.started` can bind the lease exactly. { promptId: expect.any(String) }, ); expect(driver.state.transcriptEntries).toEqual([ @@ -3305,10 +3225,6 @@ command = "vim" driver.handleUserInput(attachment.placeholder); - // The lease is created at extraction, before the session exists: lazy - // creation runs setSession mid-dispatch, and the first prompt's lease - // must survive it — the engine's intake only reads the upload once the - // prompt lands. await vi.waitFor(() => { expect(session.prompt).toHaveBeenCalledWith( [{ type: 'image_url', imageUrl: { url: 'kimi-file://file-lazy' } }], @@ -3330,15 +3246,9 @@ command = "vim" const attachment = stagedImage(imageStore, 'file-dismissed'); const text = `describe ${attachment.placeholder}`; - // Simulate a cache-hint interception dismissed back into the editor: the - // submit's extraction is stashed, then restored with recall semantics - // (retain consumed, staged upload kept for the restored draft). const extraction = extractMediaAttachments(text, imageStore); driver.recallStashedMedia(extraction); - // The restored draft resubmits and re-retains; the consuming turn must - // still delete the daemon upload — a retain leaked by the dismissal would - // keep the count above zero and pin the upload until its TTL. driver.handleUserInput(text); expect(session.prompt).toHaveBeenCalledOnce(); @@ -3355,9 +3265,6 @@ command = "vim" const { driver, session } = await makeDriver(); const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; const attachment = imageStore.addImage(new Uint8Array([0xaa, 0xbb]), 'image/png', 1, 1); - // Simulate a paste whose background ingestion is still uploading when the - // user hits Enter: the send path waits for it instead of dispatching the - // inline fallback. let finishIngestion!: () => void; attachment.pending = new Promise((resolve) => { finishIngestion = () => { @@ -3401,8 +3308,6 @@ command = "vim" expect(stripSgr(renderTranscript(driver))).toContain('Failed to send: session closed'); expect(harness.deleteFile).toHaveBeenCalledWith('file-reject'); - // The released lease must not be claimed or deleted again by later turn - // events or by session close. emitTurn(driver, 1); await driver.closeSession('test'); expect(harness.deleteFile).toHaveBeenCalledTimes(1); @@ -3412,9 +3317,6 @@ command = "vim" const { driver, session, harness } = await makeDriver(); const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; const attachment = stagedImage(imageStore, 'file-goal'); - // The goal driver's continuation turn (origin system_trigger — it never - // claims leases through handleTurnStarted) is streaming when the queued - // steer dispatch lands. driver.state.appState.goal = makeActiveGoalSnapshot(); driver.state.appState.streamingPhase = 'waiting'; driver.streamingUI.setTurnId('7'); @@ -3532,7 +3434,6 @@ command = "vim" ]); expect(harness.track).toHaveBeenCalledWith('input_queue', undefined); - // Turn ends: the drain re-enters sendSkillActivation, which now fires. driver.state.appState.streamingPhase = 'idle'; const queued = driver.state.queuedMessages[0]!; driver.state.queuedMessages = []; @@ -3937,8 +3838,6 @@ command = "vim" { type: 'text', text: 'describe ' }, { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, ], - // Staged media rides with a client-chosen prompt id so the consuming - // turn's `turn.started` can bind the lease exactly. { promptId: expect.any(String) }, ); }); @@ -4025,8 +3924,6 @@ command = "vim" const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; const attachment = stagedImage(imageStore, 'file-shared'); - // One message referencing the same image twice retains it once; a second - // queued message retains it again — two retains total. driver.handleUserInput(`compare ${attachment.placeholder} with ${attachment.placeholder}`); driver.handleUserInput(`and ${attachment.placeholder}`); const [first, second] = driver.state.queuedMessages; @@ -4034,8 +3931,6 @@ command = "vim" driver.sendQueuedMessage(session, first!); emitTurn(driver, 1); await new Promise((resolve) => setTimeout(resolve, 0)); - // The first turn consumed the only retain its submission held; the second - // queued message's retain keeps the upload alive. expect(harness.deleteFile).not.toHaveBeenCalled(); driver.sendQueuedMessage(session, second!); @@ -4060,13 +3955,9 @@ command = "vim" const recalled = driver.recallLastQueued(); expect(recalled?.text).toContain(attachment.placeholder); await new Promise((resolve) => setTimeout(resolve, 0)); - // Recalled, not discarded: the daemon upload stays staged for the - // restored draft. expect(harness.deleteFile).not.toHaveBeenCalled(); expect(attachment.fileId).toBe('file-recall'); - // Re-queueing the restored draft reuses the daemon-ref form, and the - // consuming turn's end releases the upload exactly once. driver.handleUserInput(recalled!.text); const requeued = driver.state.queuedMessages[0]!; expect(requeued.parts).toContainEqual({ @@ -4095,9 +3986,6 @@ command = "vim" const recalled = driver.recallLastQueued(); expect(recalled?.text).toContain(attachment.placeholder); - // The recall consumed the retain but kept the upload, so resubmitting - // the restored draft re-extracts the same daemon reference — a vanished - // original source cannot lose the media. expect(attachment.fileId).toBe('file-v1'); expect(harness.deleteFile).not.toHaveBeenCalled(); @@ -4138,9 +4026,6 @@ command = "vim" driver.state.editor.onCtrlS?.(); - // normalizePromptInput rejects whitespace-only text parts, so the - // item separator must not become a standalone `{type:'text',text:'\n\n'}` - // between two image parts. expect(session.steer).toHaveBeenCalledWith([imagePart(first.bytes), imagePart(second.bytes)]); }); @@ -4167,9 +4052,6 @@ command = "vim" driver.state.editor.onCtrlS?.(); - // The historical '\n\n' item separator merges into the following text - // part (legal for normalizePromptInput) instead of vanishing after a - // media part. expect(session.steer).toHaveBeenCalledWith([ { type: 'text', text: 'look ' }, { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, @@ -4207,8 +4089,6 @@ command = "vim" const session = makeSession(); const { driver } = await makeDriver(session); const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; - // The pasted video's source file vanished before submit — the cache copy - // throws, and it must surface as a TUI error, not an unhandled rejection. const missing = imageStore.addVideo('video/quicktime', '/tmp/kimi-missing-source.mov'); ( @@ -4260,7 +4140,6 @@ command = "vim" const { driver } = await makeDriver(); driver.state.appState.streamingPhase = 'waiting'; driver.state.queuedMessages = [{ text: 'ls', agentId: 'main', mode: 'bash' }]; - // After a bash command is queued the editor is reset to prompt mode. driver.state.editor.inputMode = 'prompt'; driver.state.appState.inputMode = 'prompt'; @@ -5144,9 +5023,12 @@ command = "vim" const transcript = stripSgr(renderTranscript(driver)); const panel = stripSgr(renderBtwPanel(driver)); const rootChildren = driver.state.ui.children; - expect(rootChildren.indexOf(driver.state.btwPanelContainer)).toBe( + expect(rootChildren.indexOf(driver.state.surveyContainer)).toBe( rootChildren.indexOf(driver.state.editorContainer) - 1, ); + expect(rootChildren.indexOf(driver.state.btwPanelContainer)).toBe( + rootChildren.indexOf(driver.state.surveyContainer) - 1, + ); expect(transcript).toContain('main answer after btw'); expect(transcript).not.toContain('side answer'); expect(panel).toContain('BTW'); @@ -5613,8 +5495,6 @@ command = "vim" driver.state.appState.maxContextTokens = 1_000_000; driver.state.appState.contextUsage = 0.74; - // v2 token-counting events carry contextTokens only; the ratio must be - // recomputed or the footer and /usage bar keep showing the stale value. driver.sessionEventHandler.handleEvent( { type: 'agent.status.updated', @@ -5635,7 +5515,6 @@ command = "vim" driver.state.appState.maxContextTokens = 256_000; driver.state.appState.contextUsage = 180_000 / 256_000; - // v2 profile events carry maxContextTokens only (e.g. a model switch). driver.sessionEventHandler.handleEvent( { type: 'agent.status.updated', @@ -6343,7 +6222,6 @@ command = "vim" const sendQueued = vi.fn(); driver.state.appState.thinkingEffort = 'high'; - // Same level as the main session — still shown (level info is level info). driver.sessionEventHandler.handleEvent( { type: 'subagent.spawned', @@ -6952,7 +6830,6 @@ command = "vim" }); const { driver } = await makeDriver(session); - // Official sources skip the trust prompt, so the install runs immediately. driver.handleUserInput( '/plugins install https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', ); @@ -6992,8 +6869,6 @@ command = "vim" confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install" confirm.handleInput('\r'); - // The manifest id matches a billed plugin, but a local-path install is - // not the official quota-consuming build. await vi.waitFor(() => { const transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('Installed Kimi Datasource'); @@ -7051,12 +6926,9 @@ command = "vim" expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); }); const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; - // Official loads its catalog lazily; wait for the entry to render before install. await vi.waitFor(() => { expect(stripSgr(panel.render(120).join('\n'))).toContain('Kimi Datasource'); }); - // The pinned Kimi WebBridge row leads the Official tab, so move down to - // the Kimi Datasource entry before installing. panel.handleInput('\u001B[B'); panel.handleInput('\r'); @@ -7071,7 +6943,6 @@ command = "vim" expect(transcript).toContain('Run /new or /reload to apply plugin changes.'); expect(transcript).not.toContain('Note: This plugin consumes your quota.'); }); - // Installing closes the panel so the success notice / reload tip is visible. await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBe(driver.state.editor); }); @@ -7112,8 +6983,6 @@ command = "vim" }); panel.handleInput('\r'); - // The panel must not get stuck on the one-way "Installing…" view; it should - // return to the list so the user can retry. await vi.waitFor(() => { const rendered = stripSgr(panel.render(120).join('\n')); expect(rendered).toContain('Kimi Datasource'); @@ -7142,7 +7011,6 @@ command = "vim" const session = makeSession(); const { driver } = await makeDriver(session); - // Passing the marketplace path opens the panel directly on the Third-party tab. driver.handleUserInput(`/plugins marketplace ${marketplacePath}`); await vi.waitFor(() => { @@ -7211,8 +7079,6 @@ command = "vim" confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install" confirm.handleInput('\r'); - // The failed install must return the user to the marketplace panel so they - // can retry, rather than dropping them back at the editor. await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBe(panel); }); @@ -7265,8 +7131,6 @@ command = "vim" await vi.waitFor(() => { expect(stripSgr(panel.render(120).join('\n'))).toContain('Kimi Datasource'); }); - // The pinned Kimi WebBridge row leads the Official tab, so move down to - // the Kimi Datasource entry before installing. panel.handleInput('\u001B[B'); panel.handleInput('\r'); @@ -7296,7 +7160,6 @@ command = "vim" try { driver.handleUserInput('/plugins'); - // The panel opens immediately on the Installed tab — no marketplace fetch. await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); }); @@ -7308,7 +7171,6 @@ command = "vim" 'Marketplace unavailable: fetch failed', ); }); - // The panel stays mounted; the failure does not close /plugins. expect(driver.state.editorContainer.children[0]).toBe(panel); } finally { vi.stubGlobal('fetch', originalFetch); @@ -7346,8 +7208,6 @@ command = "vim" const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; panel.handleInput(' '); - // Toggling refreshes the panel in place: it must not flash back to the - // editor between the keypress and the refreshed panel mounting. expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); await vi.waitFor(() => { @@ -7544,8 +7404,6 @@ command = "vim" expect(filteredOutput).toContain('Search: tu'); expect(filteredOutput).toContain('Kimi Turbo'); expect(filteredOutput).not.toContain('Kimi K2'); - // Turbo is a thinking-capable model that is not the active one, so it - // defaults to thinking on — selecting it applies thinking without a toggle. (picker as TabbedModelSelectorComponent).handleInput('\r'); await vi.waitFor(() => { @@ -7593,7 +7451,6 @@ command = "vim" expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); }); const picker = driver.state.editorContainer.children[0]; - // /model turbo preselects turbo; Alt+S applies it to the current session only. (picker as TabbedModelSelectorComponent).handleInput(`${ESC}s`); await vi.waitFor(() => { @@ -7731,8 +7588,6 @@ command = "vim" }, }, defaultModel: 'k2', - // No persisted effort: re-confirming the shown level must not turn the - // runtime default into a stored preference. thinking: { enabled: true }, })), setConfig, @@ -7804,8 +7659,6 @@ command = "vim" }); (driver.state.editorContainer.children[0] as TabbedModelSelectorComponent).handleInput('\r'); - // The effort matches the value shown when the picker opened, so the patch - // carries no effort key; the stored preference stays as-is via the merge. await vi.waitFor(() => { expect(setConfig).toHaveBeenCalledWith({ defaultModel: 'turbo', @@ -7845,8 +7698,6 @@ command = "vim" }, }, defaultModel: 'k2', - // A previously stored effort keeps the runtime below the delivered - // max default, so picking max is an explicit change. thinking: { enabled: true, effort: 'high' }, })), setConfig, @@ -7867,9 +7718,6 @@ command = "vim" }); it('keeps an xhigh pick session-only for a Claude model via the profile inference', async () => { - // claude-opus-4-7 declares no efforts; the Anthropic profile inference - // supplies [low, medium, high, xhigh, max] and resolves the default to - // 'high', so an xhigh pick ranks above the persistence ceiling. let switched = false; const session = makeSession({ getStatus: vi.fn(async () => ({ @@ -8184,7 +8032,6 @@ command = "vim" driver.handleUserInput('/fork'); - // cmd.exe's `cd` does not switch drives; pushd works in cmd + PowerShell. await vi.waitFor(() => { expect(copyTextToClipboard).toHaveBeenCalledWith( 'pushd "D:\\proj" && kimi --resume "ses-fork"', @@ -8270,11 +8117,9 @@ command = "vim" ); driver.streamingUI.flushNow(); - // Nothing to render: no component, and the phase is not hijacked into thinking. expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(false); expect(driver.state.appState.streamingPhase).toBe('waiting'); - // Real thinking text after the whitespace still starts thinking normally. driver.sessionEventHandler.handleEvent( { type: 'thinking.delta', @@ -8294,9 +8139,6 @@ command = "vim" it('does not create a thinking component for whitespace-only thinking on session replay', async () => { const { driver } = await makeDriver(); - // Session replay flushes stored thinking verbatim through onThinkingUpdate - // (see SessionReplayRenderer.flushAssistant), so a persisted whitespace-only - // think part must not become a bare bullet line. driver.streamingUI.onThinkingUpdate(' '); driver.streamingUI.onThinkingEnd(); @@ -8307,7 +8149,6 @@ command = "vim" ), ).toHaveLength(0); - // Real stored thinking still replays normally. driver.streamingUI.onThinkingUpdate('visible reasoning'); driver.streamingUI.onThinkingEnd(); @@ -8317,7 +8158,6 @@ command = "vim" it('keeps the waiting moon spinner while reasoning streams only empty (encrypted) thinking deltas', async () => { const { driver } = await makeDriver(); - // Turn begins -> waiting mode shows the moon spinner. driver.sessionEventHandler.handleEvent( { type: 'turn.started', @@ -8330,7 +8170,6 @@ command = "vim" expect(driver.state.appState.streamingPhase).toBe('waiting'); expect(driver.state.livePane.mode).toBe('waiting'); - // Encrypted reasoning: thinking.delta events whose visible text is empty. for (let i = 0; i < 3; i++) { driver.sessionEventHandler.handleEvent( { @@ -8343,15 +8182,12 @@ command = "vim" ); } - // The moon must stay up: still waiting, no orphan thinking component, and - // the activity pane still renders a moon frame (no blank, spinner-less gap). expect(driver.state.appState.streamingPhase).toBe('waiting'); expect(driver.state.livePane.mode).toBe('waiting'); expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(false); const activity = stripSgr(renderActivity(driver)); expect(MOON_SPINNER_FRAMES.some((frame) => activity.includes(frame))).toBe(true); - // Real thinking text finally arrives -> transition into thinking mode. driver.sessionEventHandler.handleEvent( { type: 'thinking.delta', @@ -8642,7 +8478,6 @@ describe('/effort support_efforts override', () => { getConfig: vi.fn(async () => ({ providers: {}, models: { - // v2 flat model shape: no named provider, inline endpoint + protocol. k2: { model: 'compatible-claude-model', baseUrl: 'https://anthropic.example.test', @@ -8756,7 +8591,6 @@ describe('transcript step and assistant folding', () => { expect(summaryText).toContain(`call ${cycles - TRANSCRIPT_KEEP_RECENT_STEPS} tools`); expect(summaryText).toContain(`${cycles - TRANSCRIPT_KEEP_RECENT_ASSISTANT} messages`); - // Folding drops mounted components only; every transcript entry is kept. const assistantEntries = driver.state.transcriptEntries.filter( (entry) => entry.kind === 'assistant', ); @@ -8780,7 +8614,6 @@ describe('transcript step and assistant folding', () => { const cycles = 10; driveSteps(driver, cycles); - // Below the active-turn caps, nothing folds while the turn is live. let children = driver.state.transcriptContainer.children; expect( children.filter((child) => child instanceof AssistantMessageComponent), @@ -8806,11 +8639,335 @@ describe('transcript step and assistant folding', () => { const summaryText = stripSgr(summaries[0]!.render(120).join('\n')); expect(summaryText).toContain(`${cycles - TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED} messages`); - // Steps below the step cap are untouched by the completed-turn fold. expect(children.filter((child) => child instanceof ToolCallComponent)).toHaveLength(cycles); - // The conclusion stays mounted. const lastAssistant = assistants.at(-1)!; expect(stripSgr(lastAssistant.render(120).join('\n'))).toContain(`msg-${cycles - 1}`); }); }); + +describe('KimiTUI session rating survey', () => { + it('runs the end-to-end rating flow after five user turns', async () => { + vi.useFakeTimers(); + const homeDir = await makeTempHome(); + process.env['KIMI_CODE_HOME'] = homeDir; + vi.spyOn(Math, 'random').mockReturnValue(0); + try { + const { driver, harness } = await makeDriver(); + vi.useRealTimers(); + await vi.waitFor(() => { + expect((driver.surveyController as unknown as { cooldownReady: boolean }).cooldownReady).toBe(true); + }); + vi.useFakeTimers(); + harness.track.mockClear(); + + for (let turn = 1; turn <= 4; turn++) emitTurn(driver, turn); + vi.advanceTimersByTime(600_000); + vi.advanceTimersByTime(2_000); + expect(driver.state.surveyContainer.children).toHaveLength(0); + + emitTurn(driver, 5, () => { + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 5, + toolCallId: 'call_1', + name: 'Read', + args: { path: 'a.ts' }, + } as Event, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + contextTokens: 4321, + usage: { + total: { inputOther: 100, output: 20, inputCacheRead: 30, inputCacheCreation: 10 }, + }, + } as Event, + () => {}, + ); + }); + vi.advanceTimersByTime(2_000); + const docked = stripSgr(driver.state.surveyContainer.render(120).join('\n')); + expect(docked).toContain('How is Kimi doing this session? (optional)'); + expect(docked).toContain('1: Bad 2: Fine 3: Good 0: Dismiss'); + expect(harness.track).toHaveBeenCalledTimes(1); + expect(harness.track).toHaveBeenCalledWith('feedback_survey', { + event_type: 'appeared', + appearance_id: expect.any(String), + appearance_index: 1, + response: undefined, + current_model: 'k2', + user_turn_count: 5, + cumulative_tokens: 160, + virtual_context_tokens: 4321, + tool_call_count: 1, + compaction_count: 0, + permission_mode: 'manual', + thinking_effort: 'off', + config_probability: 0.005, + config_on_for_models: '*', + config_min_time_before_feedback_ms: 600_000, + config_min_user_turns_before_feedback: 5, + config_min_time_between_feedback_ms: 3_600_000, + config_min_user_turns_between_feedback: 10, + config_min_time_between_global_feedback_ms: 100_000_000, + config_long_context_survey_threshold: 200_000, + config_long_context_probability: 0.2, + config_long_context_trigger_mode: 'cumulative', + }); + const appearanceId = ( + harness.track.mock.calls[0]![1] as { appearance_id: string } + ).appearance_id; + + driver.state.editor.handleInput('1'); + vi.advanceTimersByTime(400); + expect(harness.track).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(600); + driver.state.editor.setText(''); + driver.state.editor.handleInput('1'); + vi.advanceTimersByTime(400); + expect(driver.state.editor.getText()).toBe(''); + expect(stripSgr(driver.state.surveyContainer.render(120).join('\n'))).toContain( + 'Feedback: Bad · [escape: undo]', + ); + expect(harness.track).toHaveBeenCalledTimes(1); + + driver.state.editor.handleInput(''); + vi.advanceTimersByTime(3_000); + expect(harness.track).toHaveBeenCalledTimes(1); + expect(stripSgr(driver.state.surveyContainer.render(120).join('\n'))).toContain( + 'How is Kimi doing this session? (optional)', + ); + + driver.state.editor.setText(''); + driver.state.editor.handleInput('3'); + vi.advanceTimersByTime(400); + vi.advanceTimersByTime(3_000); + const responded = harness.track.mock.calls + .filter( + (call) => + call[0] === 'feedback_survey' && + (call[1] as { event_type?: string }).event_type === 'responded', + ) + .map((call) => call[1] as { response?: string; appearance_id: string }); + expect(responded.map((call) => call.response)).toEqual(['good']); + expect(responded.map((call) => call.appearance_id)).toEqual([appearanceId]); + + expect(stripSgr(driver.state.surveyContainer.render(120).join('\n'))).toContain( + 'Thanks for your feedback!', + ); + vi.advanceTimersByTime(5_000); + expect(driver.state.surveyContainer.children).toHaveLength(0); + + vi.useRealTimers(); + const stateFile = join(homeDir, 'feedback-survey-state.json'); + await vi.waitFor(() => { + expect(existsSync(stateFile)).toBe(true); + }); + const persisted = JSON.parse(await readFile(stateFile, 'utf-8')) as { + version: number; + last_shown_time: number; + }; + expect(persisted.version).toBe(1); + expect(typeof persisted.last_shown_time).toBe('number'); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } + }); + + it('shows the long-context survey once cumulative tokens cross the threshold', async () => { + vi.useFakeTimers(); + const homeDir = await makeTempHome(); + process.env['KIMI_CODE_HOME'] = homeDir; + vi.spyOn(Math, 'random').mockReturnValue(0); + try { + const { driver, harness } = await makeDriver(); + vi.useRealTimers(); + await vi.waitFor(() => { + expect((driver.surveyController as unknown as { cooldownReady: boolean }).cooldownReady).toBe(true); + }); + vi.useFakeTimers(); + harness.track.mockClear(); + + emitTurn(driver, 1, () => { + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + contextTokens: 1500, + usage: { + total: { + inputOther: 150_000, + output: 20_000, + inputCacheRead: 25_000, + inputCacheCreation: 10_000, + }, + }, + } as Event, + () => {}, + ); + }); + vi.advanceTimersByTime(2_000); + + expect(stripSgr(driver.state.surveyContainer.render(120).join('\n'))).toContain( + 'How is Kimi doing this session? (optional)', + ); + expect(harness.track).toHaveBeenCalledTimes(1); + expect(harness.track).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ + event_type: 'appeared', + appearance_index: 1, + user_turn_count: 1, + cumulative_tokens: 205_000, + virtual_context_tokens: 1500, + config_long_context_survey_threshold: 200_000, + config_long_context_probability: 0.2, + config_long_context_trigger_mode: 'cumulative', + }), + ); + + vi.useRealTimers(); + await new Promise((resolve) => { + setImmediate(resolve); + }); + expect(existsSync(join(homeDir, 'feedback-survey-state.json'))).toBe(false); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } + }); + + it('ignores non-user turns for the survey warmup', async () => { + vi.useFakeTimers(); + process.env['KIMI_CODE_HOME'] = await makeTempHome(); + vi.spyOn(Math, 'random').mockReturnValue(0); + try { + const { driver } = await makeDriver(); + vi.useRealTimers(); + await vi.waitFor(() => { + expect((driver.surveyController as unknown as { cooldownReady: boolean }).cooldownReady).toBe(true); + }); + vi.useFakeTimers(); + const emit = (event: Event) => { + driver.sessionEventHandler.handleEvent(event, () => {}); + }; + const cronOrigin = { + kind: 'cron_job', + jobId: 'job-42', + cron: '*/5 * * * *', + recurring: true, + coalescedCount: 1, + stale: false, + }; + + vi.advanceTimersByTime(600_000); + for (let turn = 1; turn <= 5; turn++) { + emit({ type: 'turn.started', agentId: 'main', turnId: turn, origin: cronOrigin } as Event); + emit({ type: 'turn.ended', agentId: 'main', turnId: turn, reason: 'completed' } as Event); + } + vi.advanceTimersByTime(2_000); + expect(driver.state.surveyContainer.children).toHaveLength(0); + + for (let turn = 6; turn <= 10; turn++) emitTurn(driver, turn); + vi.advanceTimersByTime(2_000); + expect(driver.state.surveyContainer.children).not.toHaveLength(0); + + vi.useRealTimers(); + await vi.waitFor(() => { + expect(existsSync(join(process.env['KIMI_CODE_HOME']!, 'feedback-survey-state.json'))).toBe( + true, + ); + }); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } + }); + + it('counts user-slash skill and plugin command turns toward the survey warmup', async () => { + vi.useFakeTimers(); + process.env['KIMI_CODE_HOME'] = await makeTempHome(); + vi.spyOn(Math, 'random').mockReturnValue(0); + try { + const { driver } = await makeDriver(); + vi.useRealTimers(); + await vi.waitFor(() => { + expect((driver.surveyController as unknown as { cooldownReady: boolean }).cooldownReady).toBe(true); + }); + vi.useFakeTimers(); + const emit = (event: Event) => { + driver.sessionEventHandler.handleEvent(event, () => {}); + }; + + vi.advanceTimersByTime(600_000); + for (let turn = 1; turn <= 5; turn++) { + emit({ + type: 'turn.started', + agentId: 'main', + turnId: turn, + origin: { + kind: 'skill_activation', + activationId: `a${turn}`, + skillName: 'review', + trigger: 'model-tool', + }, + } as Event); + emit({ type: 'turn.ended', agentId: 'main', turnId: turn, reason: 'completed' } as Event); + } + vi.advanceTimersByTime(2_000); + expect(driver.state.surveyContainer.children).toHaveLength(0); + + for (let turn = 6; turn <= 8; turn++) { + emit({ + type: 'turn.started', + agentId: 'main', + turnId: turn, + origin: { + kind: 'skill_activation', + activationId: `a${turn}`, + skillName: 'review', + trigger: 'user-slash', + }, + } as Event); + emit({ type: 'turn.ended', agentId: 'main', turnId: turn, reason: 'completed' } as Event); + } + for (let turn = 9; turn <= 10; turn++) { + emit({ + type: 'turn.started', + agentId: 'main', + turnId: turn, + origin: { + kind: 'plugin_command', + activationId: `p${turn}`, + pluginId: 'fmt', + commandName: 'fmt', + trigger: 'user-slash', + }, + } as Event); + emit({ type: 'turn.ended', agentId: 'main', turnId: turn, reason: 'completed' } as Event); + } + vi.advanceTimersByTime(2_000); + expect(driver.state.surveyContainer.children).not.toHaveLength(0); + + vi.useRealTimers(); + await vi.waitFor(() => { + expect(existsSync(join(process.env['KIMI_CODE_HOME']!, 'feedback-survey-state.json'))).toBe( + true, + ); + }); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } + }); +}); 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 d53ffeeca89..4f9a7b14e96 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -217,8 +217,6 @@ function makeHarness(session = makeSession(), overrides: Record }, ...overrides, }; - // The TUI lists sessions through keyset pages; derive the page mock from - // the (possibly overridden) full-list mock unless a test overrides paging. if (!('listSessionsPage' in harness)) { const listSessions = harness.listSessions as (input?: { workDir?: string; @@ -308,7 +306,6 @@ describe('KimiTUI startup', () => { k2: { model: 'moonshot-v1', maxContextSize: 200 }, }, defaultModel: 'k2', - // CLI --yolo must win over the config default. defaultPermissionMode: 'auto', })), }); @@ -334,16 +331,13 @@ describe('KimiTUI startup', () => { const driver = makeDriver(harness, { ...makeStartupInput(), engineV2: true }); vi.unstubAllEnvs(); - // buildLayout() runs in the constructor: fullscreen keeps the root - // children list empty and mounts the layout root instead. expect(driver.state.ui.mode).toBe('fullscreen'); expect(driver.state.ui.children).toHaveLength(0); await expect(driver.init()).resolves.toBe(false); (driver as unknown as { mountFooter(): void }).mountFooter(); - // Dock = 5 chrome containers + footer wrap, below the transcript viewport. - expect(driver.state.dockContainer?.children).toHaveLength(6); + expect(driver.state.dockContainer?.children).toHaveLength(7); }); it('shows a session-less notice on v2 startup', async () => { @@ -466,8 +460,6 @@ describe('KimiTUI startup', () => { vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); await handleLoginCommand(driver as any); - // Login must not create a session on v2, but the refreshed config - // defaults must reach the first lazy-created session. expect(harness.createSession).not.toHaveBeenCalled(); expect(driver.state.appState).toMatchObject({ sessionId: '', @@ -1170,7 +1162,6 @@ describe('KimiTUI startup', () => { return Promise.resolve({ items: firstPage, nextCursor: 'ses-page1-49' }); } if (input.before === 'ses-page1-49') { - // The scroll-triggered page fetch stays pending until the test resolves it. return new Promise<{ items: unknown[]; nextCursor?: string }>((resolve) => { resolveScrollPage = resolve; }); @@ -1186,7 +1177,6 @@ describe('KimiTUI startup', () => { await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; - // Reach the fetched end: the scroll-triggered fetch for page 2 starts. for (let i = 0; i < 49; i++) { picker.handleInput('\u001B[B'); } @@ -1198,8 +1188,6 @@ describe('KimiTUI startup', () => { }); }); - // Typing a query while that fetch is in flight must join it, not stop the - // drain: the remaining pages arrive after the in-flight one settles. picker.handleInput('x'); resolveScrollPage({ items: [{ id: 'ses-page2-0', workDir: '/tmp/proj-a', updatedAt: 1 }], @@ -1560,9 +1548,6 @@ describe('KimiTUI startup', () => { expect(result.failed).toEqual([]); expect(result.changed).toContainEqual({ providerId: "b", providerName: "b", added: 0, removed: 1 }); - // The removal was staged in memory: no destructive pre-write, exactly - // one atomic section replace carrying the complete records — with the - // dangling default model / thinking expressed as cleared sections. expect(removeProvider).not.toHaveBeenCalled(); expect(setConfig).not.toHaveBeenCalled(); expect(replaceConfigSections).toHaveBeenCalledTimes(1); @@ -1812,8 +1797,6 @@ describe('KimiTUI startup', () => { await handleLoginCommand(driver as any); expect(session.setModel).toHaveBeenCalledWith('k2'); - // `thinking.enabled === true` means "leave the session's current thinking - // level alone" — only an explicit `enabled === false` forces `'off'`. expect(session.setThinking).not.toHaveBeenCalled(); expect(driver.state.appState).toMatchObject({ model: 'k2', @@ -2058,8 +2041,6 @@ describe('KimiTUI startup', () => { providers: { 'managed:kimi-code': { type: 'kimi' } }, })), auth: { - // Token gone (e.g. credentials file deleted) but the managed entry - // is still sitting in config.providers. status: vi.fn(async () => ({ providers: [{ providerName: 'managed:kimi-code', hasToken: false }], })), @@ -2124,20 +2105,15 @@ describe('KimiTUI startup', () => { migrationPlan: MIGRATION_PLAN, migrateOnly: true, }) as unknown as MigrateExitDriver; - // pi-tui start/stop and focus tracking touch the real TTY — stub the I/O. vi.spyOn(driver.state.ui, 'start').mockImplementation(() => {}); vi.spyOn(driver.state.ui, 'stop').mockImplementation(() => {}); vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); - // The migration screen would await user input; resolve it immediately. vi.spyOn(driver, 'runMigrationScreen').mockResolvedValue({ decision: 'later' }); const onExit = vi.fn(async () => {}); driver.onExit = onExit; await driver.start(); - // `kimi migrate` exits via process.exit; startEventLoop() installed focus - // tracking, so the exit path must dispose it — otherwise the terminal - // keeps emitting focus/OSC sequences after the command finishes. expect(driver.terminalFocusTrackingDispose).toBeUndefined(); expect(onExit).toHaveBeenCalledWith(0); }); @@ -2152,22 +2128,15 @@ describe('KimiTUI startup', () => { vi.spyOn(driver.state.ui, 'start').mockImplementation(() => {}); vi.spyOn(driver.state.ui, 'stop').mockImplementation(() => {}); vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); - // The migration screen resolves "later"; startup then continues into - // initMainTui(), which fails (e.g. a session-resume error). vi.spyOn(driver, 'runMigrationScreen').mockResolvedValue({ decision: 'later' }); vi.spyOn(driver, 'initMainTui').mockRejectedValue(new Error('resume boom')); await expect(driver.start()).rejects.toThrow('resume boom'); - // The focus tracking installed by startEventLoop() must be torn down - // before the error propagates — not left active after the process exits. expect(driver.terminalFocusTrackingDispose).toBeUndefined(); }); it('checks workspace trust before entering the migration screen', async () => { - // The migration branch used to skip the trust gate entirely: a workspace - // with legacy ~/.kimi data went straight to the migration screen, and - // later startup steps spawned child processes in an untrusted directory. const getWorkspaceTrustInfo = vi.fn(async () => ({ trusted: true, gatedMcpServers: [], @@ -2226,7 +2195,6 @@ describe('KimiTUI startup', () => { await vi.waitFor(() => { expect(mountSpy).toHaveBeenCalled(); }); - // Move from the safe default to the explicit trust choice, then confirm. mountSpy.mock.calls[0]![0].handleInput('\u001B[A'); mountSpy.mock.calls[0]![0].handleInput('\r'); await startPromise; @@ -2250,10 +2218,6 @@ describe('KimiTUI startup', () => { }); it('does not mount the footer when resuming a missing session fails', async () => { - // Regression: a stray pre-startEventLoop render used to paint the footer - // (cwd/git + "context:" statusline) to the terminal before the fatal - // error, leaving it stranded above the error message. The footer must not - // be in the layout tree when initMainTui() throws. const harness = makeHarness(makeSession(), { listSessions: vi.fn(async () => []), }); @@ -2276,7 +2240,6 @@ describe('KimiTUI startup', () => { makeStartupInput({ session: 'ses-target' }), ) as unknown as MigrateExitDriver; - // Not mounted until init() succeeds. expect(uiContainsFooter(driver)).toBe(false); await driver.initMainTui(); @@ -2310,8 +2273,6 @@ describe('KimiTUI startup', () => { ).toBe(true); }); - // The banner is rendered directly below the welcome panel so it appears - // above later status messages such as MCP server connection summaries. const welcomeIndex = driver.state.transcriptContainer.children.findIndex( (child) => child instanceof WelcomeComponent, ); @@ -2355,9 +2316,6 @@ describe('KimiTUI startup', () => { ).toBe(true); }); - // writeBannerDisplayState runs after renderBanner; on Windows the atomic - // write can lag behind the render, so wait for the state to land before - // asserting it. await vi.waitFor( async () => { const state = await readBannerDisplayState(); @@ -2457,3 +2415,34 @@ function uiContainsFooter(driver: StartupDriver): boolean { }; return visit(driver.state.ui); } + +describe('survey telemetry gate wiring', () => { + function surveyGateTelemetryDisabled(input: KimiTUIStartupInput): boolean { + const driver = new KimiTUI(makeHarness() as never, input); + const controller = driver.surveyController as unknown as { + deps: { telemetryDisabled?: () => boolean }; + }; + return controller.deps.telemetryDisabled?.() ?? false; + } + + it('treats the runtime config opt-out as telemetry-disabled', () => { + vi.stubEnv('KIMI_DISABLE_TELEMETRY', ''); + try { + expect( + surveyGateTelemetryDisabled({ ...makeStartupInput(), telemetryDisabled: true }), + ).toBe(true); + expect(surveyGateTelemetryDisabled(makeStartupInput())).toBe(false); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('treats the env kill switch as telemetry-disabled', () => { + vi.stubEnv('KIMI_DISABLE_TELEMETRY', '1'); + try { + expect(surveyGateTelemetryDisabled(makeStartupInput())).toBe(true); + } finally { + vi.unstubAllEnvs(); + } + }); +}); diff --git a/apps/kimi-code/test/tui/utils/survey-policy.test.ts b/apps/kimi-code/test/tui/utils/survey-policy.test.ts new file mode 100644 index 00000000000..b86ef3f42f9 --- /dev/null +++ b/apps/kimi-code/test/tui/utils/survey-policy.test.ts @@ -0,0 +1,663 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + DEFAULT_SURVEY_POPUP_CONFIG, + type SurveyPopupConfig, +} from '#/utils/survey-popup-config'; +import { + buildSurveyEventProperties, + evaluateLongContextArm, + evaluateSurveyGate, + SURVEY_EVENT_NAMES, + SURVEY_MACHINE_CLOSED, + surveyMachineReduce, + type LongContextArmGateInput, + type SessionArmGateInput, + type SurveyEventEnvironmentFields, + type SurveyGateInput, + type SurveyMachineState, +} from '#/tui/utils/survey-policy'; + +const CONFIG = DEFAULT_SURVEY_POPUP_CONFIG; + +function passingSession(overrides: Partial = {}): SessionArmGateInput { + return { + phase: 'closed', + turnInProgress: false, + idleForMs: 2000, + externalEditorActive: false, + terminalWidth: 120, + terminalHeight: 24, + promptActive: false, + editorBashActive: false, + editorAutocompleteActive: false, + feedbackSurveyDisabled: false, + telemetryDisabled: false, + currentModel: 'k2', + lastUserMessageStartsOrderedList: false, + mountedForMs: 600_000, + userTurnsSinceMount: 5, + msSinceLastShown: undefined, + userTurnsSinceLastShown: undefined, + sample: 0, + msSinceGlobalLastShown: undefined, + ...overrides, + }; +} + +function passingLongContext( + overrides: Partial = {}, +): LongContextArmGateInput { + return { + phase: 'closed', + turnInProgress: false, + idleForMs: 2000, + promptActive: false, + editorBashActive: false, + editorAutocompleteActive: false, + externalEditorActive: false, + terminalWidth: 120, + terminalHeight: 24, + feedbackSurveyDisabled: false, + telemetryDisabled: false, + currentModel: 'k2', + lastUserMessageStartsOrderedList: false, + cumulativeTokens: 0, + virtualContextTokens: 0, + mountRollConsumed: false, + drawMountRoll: () => 0, + ...overrides, + }; +} + +function gate( + sessionOverrides: Partial = {}, + config: SurveyPopupConfig = CONFIG, + longContextOverrides: Partial = {}, +): SurveyGateInput { + return { + session: passingSession(sessionOverrides), + longContext: passingLongContext(longContextOverrides), + config, + }; +} + +describe('evaluateSurveyGate (session arm)', () => { + it('shows the session survey when every gate passes', () => { + expect(evaluateSurveyGate(gate())).toEqual({ show: true, survey: 'session' }); + }); + + it.each<[Partial, string]>([ + [{ phase: 'open' }, 'survey-active'], + [{ phase: 'pending' }, 'survey-active'], + [{ phase: 'thanks' }, 'survey-active'], + [{ turnInProgress: true }, 'turn-in-progress'], + [{ idleForMs: 1999 }, 'idle-too-short'], + [{ lastUserMessageStartsOrderedList: true }, 'ordered-list-ambiguity'], + [{ promptActive: true }, 'prompt-active'], + [{ editorBashActive: true }, 'editor-bash-active'], + [{ editorAutocompleteActive: true }, 'editor-autocomplete-active'], + [{ externalEditorActive: true }, 'external-editor-active'], + [{ terminalWidth: 10 }, 'terminal-too-narrow'], + [{ terminalHeight: 7 }, 'terminal-too-short'], + [{ terminalWidth: 12, terminalHeight: 14 }, 'terminal-too-short'], + [{ terminalWidth: 16, terminalHeight: 13 }, 'terminal-too-short'], + [{ feedbackSurveyDisabled: true }, 'feature-disabled'], + [{ telemetryDisabled: true }, 'telemetry-disabled'], + [{ mountedForMs: 599_999 }, 'warmup'], + [{ userTurnsSinceMount: 4 }, 'warmup'], + [{ sample: 0.006 }, 'sampled-out'], + [{ msSinceGlobalLastShown: 99_999_999 }, 'global-cooldown'], + ])('skips with %j → %s', (overrides, reason) => { + expect(evaluateSurveyGate(gate(overrides))).toEqual({ show: false, reason }); + }); + + it('applies the chain in order: an open survey reports survey-active, not later reasons', () => { + expect( + evaluateSurveyGate(gate({ phase: 'open', turnInProgress: true, telemetryDisabled: true })), + ).toEqual({ show: false, reason: 'survey-active' }); + }); + + it('samples with a half-open test: sample below probability shows, at-or-above is out', () => { + expect(evaluateSurveyGate(gate({ sample: 0.004_999 }))).toEqual({ + show: true, + survey: 'session', + }); + expect(evaluateSurveyGate(gate({ sample: 0.005 }))).toEqual({ + show: false, + reason: 'sampled-out', + }); + expect(evaluateSurveyGate(gate({ sample: 0 }, { ...CONFIG, probability: 0 })).show).toBe( + false, + ); + }); + + describe('model gate', () => { + it('opens for every model with "*"', () => { + expect(evaluateSurveyGate(gate({ currentModel: 'anything' })).show).toBe(true); + }); + + it('closes both arms with an empty list', () => { + expect(evaluateSurveyGate(gate({}, { ...CONFIG, on_for_models: [] }))).toEqual({ + show: false, + reason: 'model-gated', + }); + }); + + it('requires an exact match otherwise', () => { + const config = { ...CONFIG, on_for_models: ['k3'] }; + expect(evaluateSurveyGate(gate({ currentModel: 'k3' }, config)).show).toBe(true); + expect(evaluateSurveyGate(gate({ currentModel: 'k2' }, config))).toEqual({ + show: false, + reason: 'model-gated', + }); + expect(evaluateSurveyGate(gate({ currentModel: 'k3-fictional' }, config))).toEqual({ + show: false, + reason: 'model-gated', + }); + }); + }); + + describe('in-session pacing', () => { + const shown = { + msSinceLastShown: 3_600_000, + userTurnsSinceLastShown: 10, + } as const; + + it('requires the gap and the new turns once shown before', () => { + expect(evaluateSurveyGate(gate(shown)).show).toBe(true); + }); + + it('skips inside the time gap', () => { + expect( + evaluateSurveyGate(gate({ ...shown, msSinceLastShown: 3_599_999 })), + ).toEqual({ show: false, reason: 'pacing' }); + }); + + it('skips without enough new turns', () => { + expect(evaluateSurveyGate(gate({ ...shown, userTurnsSinceLastShown: 9 }))).toEqual({ + show: false, + reason: 'pacing', + }); + }); + }); + + it('honours the global cooldown exactly at the boundary', () => { + expect(evaluateSurveyGate(gate({ msSinceGlobalLastShown: 100_000_000 })).show).toBe(true); + }); +}); + +describe('evaluateLongContextArm', () => { + const ELIGIBLE: Partial = { cumulativeTokens: 250_000 }; + + function arm( + overrides: Partial = {}, + config: SurveyPopupConfig = CONFIG, + ): ReturnType { + return evaluateLongContextArm(gate({}, config, { ...ELIGIBLE, ...overrides })); + } + + it('shows the long-context survey when the whole chain passes', () => { + expect(arm()).toEqual({ + show: true, + survey: 'long_context', + longContextRollConsumed: true, + }); + }); + + it.each<[Partial, string]>([ + [{ mountRollConsumed: true }, 'mount-roll-consumed'], + [{ phase: 'open' }, 'survey-active'], + [{ phase: 'pending' }, 'survey-active'], + [{ phase: 'thanks' }, 'survey-active'], + [{ turnInProgress: true }, 'turn-in-progress'], + [{ idleForMs: 1999 }, 'idle-too-short'], + [{ promptActive: true }, 'prompt-active'], + [{ editorBashActive: true }, 'editor-bash-active'], + [{ editorAutocompleteActive: true }, 'editor-autocomplete-active'], + [{ externalEditorActive: true }, 'external-editor-active'], + [{ terminalWidth: 10 }, 'terminal-too-narrow'], + [{ terminalHeight: 7 }, 'terminal-too-short'], + [{ terminalWidth: 12, terminalHeight: 14 }, 'terminal-too-short'], + [{ terminalWidth: 16, terminalHeight: 13 }, 'terminal-too-short'], + [{ feedbackSurveyDisabled: true }, 'feature-disabled'], + [{ telemetryDisabled: true }, 'telemetry-disabled'], + [{ lastUserMessageStartsOrderedList: true }, 'ordered-list-ambiguity'], + [{ cumulativeTokens: 199_999 }, 'below-threshold'], + ])('skips with %j → %s', (overrides, reason) => { + expect(arm(overrides)).toEqual({ show: false, reason }); + }); + + it('checks the mount latch first: a spent roll reports mount-roll-consumed, not later reasons', () => { + expect(arm({ mountRollConsumed: true, phase: 'open', telemetryDisabled: true })).toEqual({ + show: false, + reason: 'mount-roll-consumed', + }); + }); + + it('applies the chain in order: an open survey reports survey-active, not later reasons', () => { + expect(arm({ phase: 'open', telemetryDisabled: true })).toEqual({ + show: false, + reason: 'survey-active', + }); + }); + + it('applies the model gate before the threshold', () => { + expect(arm({}, { ...CONFIG, on_for_models: [], long_context_survey_threshold: 0 })).toEqual({ + show: false, + reason: 'model-gated', + }); + expect(arm({}, { ...CONFIG, on_for_models: ['k3'] })).toEqual({ + show: false, + reason: 'model-gated', + }); + }); + + it('shows at the counter boundary (counter == threshold)', () => { + expect(arm({ cumulativeTokens: 200_000 })).toEqual({ + show: true, + survey: 'long_context', + longContextRollConsumed: true, + }); + }); + + it('samples with a half-open test: roll below long_context_probability shows, at-or-above is out', () => { + expect(arm({ drawMountRoll: () => 0.199_999 })).toEqual({ + show: true, + survey: 'long_context', + longContextRollConsumed: true, + }); + expect(arm({ drawMountRoll: () => 0.2 })).toEqual({ + show: false, + reason: 'sampled-out', + longContextRollConsumed: true, + }); + }); + + describe('mount one-shot', () => { + it('does not draw the dice below the threshold, so the roll stays unspent', () => { + const drawMountRoll = vi.fn(() => 0); + expect(arm({ cumulativeTokens: 199_999, drawMountRoll })).toEqual({ + show: false, + reason: 'below-threshold', + }); + expect(drawMountRoll).not.toHaveBeenCalled(); + }); + + it('does not draw the dice while transiently suppressed by an active prompt', () => { + const drawMountRoll = vi.fn(() => 0); + expect(arm({ promptActive: true, drawMountRoll })).toEqual({ + show: false, + reason: 'prompt-active', + }); + expect(drawMountRoll).not.toHaveBeenCalled(); + }); + + it('spends the roll on a miss: the arm stays silent for the rest of the mount', () => { + expect(arm({ drawMountRoll: () => 0.9 })).toEqual({ + show: false, + reason: 'sampled-out', + longContextRollConsumed: true, + }); + }); + + it('spends the roll on a hit and shows once', () => { + expect(arm({ drawMountRoll: () => 0.1 })).toEqual({ + show: true, + survey: 'long_context', + longContextRollConsumed: true, + }); + }); + + it('never draws again once the roll is spent', () => { + const drawMountRoll = vi.fn(() => 0); + expect(arm({ mountRollConsumed: true, drawMountRoll })).toEqual({ + show: false, + reason: 'mount-roll-consumed', + }); + expect(drawMountRoll).not.toHaveBeenCalled(); + }); + }); + + describe('threshold validity', () => { + it.each([0, -1, Number.NaN])('closes the arm on a non-positive threshold (%s)', (threshold) => { + expect(arm({}, { ...CONFIG, long_context_survey_threshold: threshold })).toEqual({ + show: false, + reason: 'threshold-invalid', + }); + }); + + it('runs on the built-in 200k default when the field never took a cloud value', () => { + expect(arm({ cumulativeTokens: 199_999 })).toEqual({ show: false, reason: 'below-threshold' }); + expect(arm({ cumulativeTokens: 200_000 })).toEqual({ + show: true, + survey: 'long_context', + longContextRollConsumed: true, + }); + }); + }); + + describe('counter mode', () => { + it('compares the cumulative counter by default and ignores the window occupancy', () => { + expect(arm({ cumulativeTokens: 199_999, virtualContextTokens: 500_000 })).toEqual({ + show: false, + reason: 'below-threshold', + }); + }); + + it('compares the window occupancy when the trigger mode is virtual_context', () => { + const config = { ...CONFIG, long_context_trigger_mode: 'virtual_context' as const }; + expect(arm({ cumulativeTokens: 0, virtualContextTokens: 250_000 }, config)).toEqual({ + show: true, + survey: 'long_context', + longContextRollConsumed: true, + }); + expect(arm({ cumulativeTokens: 500_000, virtualContextTokens: 100 }, config)).toEqual({ + show: false, + reason: 'below-threshold', + }); + }); + }); +}); + +describe('evaluateSurveyGate (arbitration)', () => { + const ELIGIBLE: Partial = { cumulativeTokens: 250_000 }; + + it('prefers the long-context survey when both arms pass', () => { + expect(evaluateSurveyGate(gate({}, CONFIG, ELIGIBLE))).toEqual({ + show: true, + survey: 'long_context', + longContextRollConsumed: true, + }); + }); + + it('falls back to the session arm when the long-context arm is below the threshold', () => { + expect(evaluateSurveyGate(gate())).toEqual({ show: true, survey: 'session' }); + }); + + it('falls back to the session arm when the long-context arm misses its roll, latching it spent', () => { + expect( + evaluateSurveyGate(gate({ sample: 0 }, CONFIG, { ...ELIGIBLE, drawMountRoll: () => 0.9 })), + ).toEqual({ + show: true, + survey: 'session', + longContextRollConsumed: true, + }); + }); + + it('falls back to the session arm when the threshold closes the long-context arm', () => { + expect( + evaluateSurveyGate(gate({}, { ...CONFIG, long_context_survey_threshold: 0 }, ELIGIBLE)), + ).toEqual({ show: true, survey: 'session' }); + }); + + it('shows the long-context survey even when the session arm is sampled out', () => { + expect(evaluateSurveyGate(gate({ sample: 0.9 }, CONFIG, ELIGIBLE))).toEqual({ + show: true, + survey: 'long_context', + longContextRollConsumed: true, + }); + }); + + it('shows the long-context survey inside the persisted cooldown that still gates the session arm', () => { + expect( + evaluateSurveyGate(gate({ msSinceGlobalLastShown: 1000 }, CONFIG, ELIGIBLE)), + ).toEqual({ + show: true, + survey: 'long_context', + longContextRollConsumed: true, + }); + }); + + it('shows nothing when the long-context arm is ineligible and the session arm is sampled out', () => { + expect(evaluateSurveyGate(gate({ sample: 0.9 }))).toEqual({ + show: false, + reason: 'sampled-out', + }); + }); + + it('reports the spent roll even when both arms lose', () => { + expect( + evaluateSurveyGate( + gate({ sample: 0.9 }, CONFIG, { ...ELIGIBLE, drawMountRoll: () => 0.9 }), + ), + ).toEqual({ + show: false, + reason: 'sampled-out', + longContextRollConsumed: true, + }); + }); +}); + +describe('surveyMachineReduce', () => { + const appearance = { survey: 'session' as const, appearanceId: 'a1', appearanceIndex: 1 }; + const openState: SurveyMachineState = { phase: 'open', appearance, response: undefined }; + + it('walks the happy path: closed → open → pending → thanks → closed', () => { + const opened = surveyMachineReduce(SURVEY_MACHINE_CLOSED, { type: 'open', appearance }); + expect(opened.state.phase).toBe('open'); + expect(opened.effects).toEqual([{ type: 'report', eventType: 'appeared' }]); + + const selected = surveyMachineReduce(opened.state, { type: 'select', response: 'bad' }); + expect(selected.state).toEqual({ phase: 'pending', appearance, response: 'bad' }); + expect(selected.effects).toEqual([{ type: 'schedule', timer: 'pending-settle' }]); + + const settled = surveyMachineReduce(selected.state, { type: 'settle' }); + expect(settled.state.phase).toBe('thanks'); + expect(settled.effects).toEqual([ + { type: 'report', eventType: 'responded', response: 'bad' }, + { type: 'schedule', timer: 'thanks-close' }, + ]); + + const closed = surveyMachineReduce(settled.state, { type: 'thanks-elapsed' }); + expect(closed.state).toEqual(SURVEY_MACHINE_CLOSED); + expect(closed.effects).toEqual([]); + }); + + it('undo returns pending to open without reporting, and settle after an undo reports only the final choice', () => { + const selected = surveyMachineReduce(openState, { type: 'select', response: 'fine' }); + const undone = surveyMachineReduce(selected.state, { type: 'undo' }); + expect(undone.state).toEqual(openState); + expect(undone.effects).toEqual([]); + + const reselected = surveyMachineReduce(undone.state, { type: 'select', response: 'good' }); + const settled = surveyMachineReduce(reselected.state, { type: 'settle' }); + expect(settled.effects).toEqual([ + { type: 'report', eventType: 'responded', response: 'good' }, + { type: 'schedule', timer: 'thanks-close' }, + ]); + }); + + it('dismiss reports responded dismissed and closes without thanks', () => { + const dismissed = surveyMachineReduce(openState, { type: 'dismiss' }); + expect(dismissed.state).toEqual(SURVEY_MACHINE_CLOSED); + expect(dismissed.effects).toEqual([ + { type: 'report', eventType: 'responded', response: 'dismissed' }, + ]); + }); + + it('abandon reports abandoned and closes', () => { + const abandoned = surveyMachineReduce(openState, { type: 'abandon' }); + expect(abandoned.state).toEqual(SURVEY_MACHINE_CLOSED); + expect(abandoned.effects).toEqual([{ type: 'report', eventType: 'abandoned' }]); + }); + + it.each(['open', 'thanks'] as const)('close-silently from %s reports nothing', (phase) => { + const state: SurveyMachineState = { phase, appearance, response: 'good' }; + const closed = surveyMachineReduce(state, { type: 'close-silently' }); + expect(closed.state).toEqual(SURVEY_MACHINE_CLOSED); + expect(closed.effects).toEqual([]); + }); + + it('close-silently from pending settles the un-undone choice before closing', () => { + const state: SurveyMachineState = { phase: 'pending', appearance, response: 'bad' }; + const closed = surveyMachineReduce(state, { type: 'close-silently' }); + expect(closed.state).toEqual(SURVEY_MACHINE_CLOSED); + expect(closed.effects).toEqual([ + { type: 'report', eventType: 'responded', response: 'bad' }, + ]); + }); + + it.each([ + [{ type: 'select', response: 'good' } as const], + [{ type: 'dismiss' } as const], + [{ type: 'abandon' } as const], + [{ type: 'undo' } as const], + [{ type: 'settle' } as const], + [{ type: 'thanks-elapsed' } as const], + ])('ignores %s while closed', (action) => { + const result = surveyMachineReduce(SURVEY_MACHINE_CLOSED, action); + expect(result.state).toEqual(SURVEY_MACHINE_CLOSED); + expect(result.effects).toEqual([]); + }); + + describe('takeover', () => { + const longContextAppearance = { + survey: 'long_context' as const, + appearanceId: 'a2', + appearanceIndex: 2, + }; + + it('a long-context survey takes over an open session survey silently', () => { + const result = surveyMachineReduce(openState, { + type: 'open', + appearance: longContextAppearance, + }); + expect(result.state).toEqual({ + phase: 'open', + appearance: longContextAppearance, + response: undefined, + }); + expect(result.effects).toEqual([{ type: 'report', eventType: 'appeared' }]); + }); + + it('a session survey never takes over an open long-context survey', () => { + const longOpen: SurveyMachineState = { + phase: 'open', + appearance: longContextAppearance, + response: undefined, + }; + const result = surveyMachineReduce(longOpen, { type: 'open', appearance }); + expect(result.state).toEqual(longOpen); + expect(result.effects).toEqual([]); + }); + + it('ignores a re-open of the same survey while open (no double appearance)', () => { + const other = { survey: 'session' as const, appearanceId: 'a9', appearanceIndex: 3 }; + const result = surveyMachineReduce(openState, { type: 'open', appearance: other }); + expect(result.state).toEqual(openState); + expect(result.effects).toEqual([]); + }); + + it.each(['pending', 'thanks'] as const)('ignores a takeover while %s', (phase) => { + const state: SurveyMachineState = { phase, appearance, response: 'good' }; + const result = surveyMachineReduce(state, { + type: 'open', + appearance: longContextAppearance, + }); + expect(result.state).toEqual(state); + expect(result.effects).toEqual([]); + }); + }); +}); + +describe('buildSurveyEventProperties', () => { + it('maps survey kinds to the wire event names', () => { + expect(SURVEY_EVENT_NAMES).toEqual({ + session: 'feedback_survey', + long_context: 'long_context_survey', + }); + }); + + const ENVIRONMENT: SurveyEventEnvironmentFields = { + current_model: 'k2', + user_turn_count: 9, + cumulative_tokens: 123, + virtual_context_tokens: 45, + tool_call_count: 6, + compaction_count: 2, + permission_mode: 'manual', + thinking_effort: 'high', + }; + + it('builds the core three-state fields', () => { + expect( + buildSurveyEventProperties( + { + event_type: 'responded', + appearance_id: 'a1', + appearance_index: 2, + response: 'fine', + }, + ENVIRONMENT, + CONFIG, + ), + ).toEqual({ + event_type: 'responded', + appearance_id: 'a1', + appearance_index: 2, + response: 'fine', + ...ENVIRONMENT, + config_probability: 0.005, + config_on_for_models: '*', + config_min_time_before_feedback_ms: 600_000, + config_min_user_turns_before_feedback: 5, + config_min_time_between_feedback_ms: 3_600_000, + config_min_user_turns_between_feedback: 10, + config_min_time_between_global_feedback_ms: 100_000_000, + config_long_context_survey_threshold: 200_000, + config_long_context_probability: 0.2, + config_long_context_trigger_mode: 'cumulative', + }); + }); + + it('leaves response undefined for appeared / abandoned', () => { + const properties = buildSurveyEventProperties( + { + event_type: 'appeared', + appearance_id: 'a1', + appearance_index: 1, + }, + ENVIRONMENT, + CONFIG, + ); + expect(properties['response']).toBeUndefined(); + }); + + it('flattens the effective config into primitive config_* properties', () => { + const properties = buildSurveyEventProperties( + { + event_type: 'appeared', + appearance_id: 'a1', + appearance_index: 1, + }, + ENVIRONMENT, + { ...CONFIG, probability: 0.5, on_for_models: ['k3', 'k2'] }, + ); + expect(properties['config_probability']).toBe(0.5); + expect(properties['config_on_for_models']).toBe('k3,k2'); + }); + + it('keeps every property a telemetry primitive so sanitize drops nothing', () => { + const properties = buildSurveyEventProperties( + { + event_type: 'responded', + appearance_id: 'a1', + appearance_index: 1, + response: 'bad', + }, + ENVIRONMENT, + { ...CONFIG, on_for_models: [] }, + ); + for (const [key, value] of Object.entries(properties)) { + const isPrimitive = + value === undefined || + value === null || + typeof value === 'boolean' || + typeof value === 'number' || + typeof value === 'string'; + expect(isPrimitive, `property ${key} is not a primitive`).toBe(true); + } + expect(properties['config_on_for_models']).toBe(''); + }); +}); diff --git a/apps/kimi-code/test/utils/survey-popup-config.test.ts b/apps/kimi-code/test/utils/survey-popup-config.test.ts new file mode 100644 index 00000000000..e0f4195ec28 --- /dev/null +++ b/apps/kimi-code/test/utils/survey-popup-config.test.ts @@ -0,0 +1,319 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + DEFAULT_SURVEY_POPUP_CONFIG, + getSurveyPopupConfig, + peekSurveyPopupConfig, + resetSurveyPopupConfigCache, +} from '#/utils/survey-popup-config'; + +const CLOUD_CONFIG = { + probability: 0.5, + on_for_models: ['k3', 'k2'], + min_time_before_feedback_ms: 60_000, + min_user_turns_before_feedback: 2, + min_time_between_feedback_ms: 120_000, + min_user_turns_between_feedback: 3, + min_time_between_global_feedback_ms: 240_000, + long_context_survey_threshold: 100_000, + long_context_probability: 0.9, + long_context_trigger_mode: 'virtual_context', +}; + +const ENVELOPE = { name: 'survey_popup', config: CLOUD_CONFIG }; + +const tempDirs: string[] = []; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +async function makeCacheFile(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'survey-popup-config-')); + tempDirs.push(dir); + return join(dir, 'cache.json'); +} + +afterEach(async () => { + resetSurveyPopupConfigCache(); + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('DEFAULT_SURVEY_POPUP_CONFIG', () => { + it('matches the contract defaults', () => { + expect(DEFAULT_SURVEY_POPUP_CONFIG).toEqual({ + probability: 0.005, + on_for_models: ['*'], + min_time_before_feedback_ms: 600_000, + min_user_turns_before_feedback: 5, + min_time_between_feedback_ms: 3_600_000, + min_user_turns_between_feedback: 10, + min_time_between_global_feedback_ms: 100_000_000, + long_context_survey_threshold: 200_000, + long_context_probability: 0.2, + long_context_trigger_mode: 'cumulative', + }); + }); +}); + +describe('getSurveyPopupConfig', () => { + it('POSTs the survey_popup name and returns the cloud config over the defaults', async () => { + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + + const result = await getSurveyPopupConfig({ + fetchImpl: fetchImpl as typeof fetch, + cacheFile: null, + }); + + expect(result).toEqual(CLOUD_CONFIG); + expect(fetchImpl).toHaveBeenCalledWith( + expect.stringContaining('/client_configs'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ name: 'survey_popup' }), + }), + ); + }); + + it('fills fields the cloud payload omits from the built-in defaults', async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ name: 'survey_popup', config: { probability: 0.5 } }), + ); + + const result = await getSurveyPopupConfig({ + fetchImpl: fetchImpl as typeof fetch, + cacheFile: null, + }); + + expect(result).toEqual({ ...DEFAULT_SURVEY_POPUP_CONFIG, probability: 0.5 }); + }); + + it('drops only the invalid field and keeps the rest', async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ + name: 'survey_popup', + config: { + ...CLOUD_CONFIG, + probability: 'often', + on_for_models: ['k3', 42], + long_context_trigger_mode: 'sometimes', + }, + }), + ); + + const result = await getSurveyPopupConfig({ + fetchImpl: fetchImpl as typeof fetch, + cacheFile: null, + }); + + expect(result).toEqual({ + ...CLOUD_CONFIG, + probability: DEFAULT_SURVEY_POPUP_CONFIG.probability, + on_for_models: DEFAULT_SURVEY_POPUP_CONFIG.on_for_models, + long_context_trigger_mode: DEFAULT_SURVEY_POPUP_CONFIG.long_context_trigger_mode, + }); + }); + + it('drops negative pacing values and fractional turn counts back to defaults', async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ + name: 'survey_popup', + config: { + min_time_before_feedback_ms: -1, + min_user_turns_before_feedback: 2.5, + min_time_between_feedback_ms: -100, + min_user_turns_between_feedback: -3, + min_time_between_global_feedback_ms: -1, + }, + }), + ); + + const result = await getSurveyPopupConfig({ + fetchImpl: fetchImpl as typeof fetch, + cacheFile: null, + }); + + expect(result).toEqual(DEFAULT_SURVEY_POPUP_CONFIG); + }); + + it('accepts zero pacing values as the documented no-limit semantics', async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ + name: 'survey_popup', + config: { + min_time_before_feedback_ms: 0, + min_user_turns_before_feedback: 0, + min_time_between_feedback_ms: 0, + min_user_turns_between_feedback: 0, + min_time_between_global_feedback_ms: 0, + }, + }), + ); + + const result = await getSurveyPopupConfig({ + fetchImpl: fetchImpl as typeof fetch, + cacheFile: null, + }); + + expect(result).toEqual({ + ...DEFAULT_SURVEY_POPUP_CONFIG, + min_time_before_feedback_ms: 0, + min_user_turns_before_feedback: 0, + min_time_between_feedback_ms: 0, + min_user_turns_between_feedback: 0, + min_time_between_global_feedback_ms: 0, + }); + }); + + it('drops out-of-range probabilities back to the built-in defaults', async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ + name: 'survey_popup', + config: { probability: 2, long_context_probability: -0.5 }, + }), + ); + + const result = await getSurveyPopupConfig({ + fetchImpl: fetchImpl as typeof fetch, + cacheFile: null, + }); + + expect(result.probability).toBe(DEFAULT_SURVEY_POPUP_CONFIG.probability); + expect(result.long_context_probability).toBe( + DEFAULT_SURVEY_POPUP_CONFIG.long_context_probability, + ); + }); + + it('drops a non-numeric long-context threshold back to the built-in default', async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ name: 'survey_popup', config: { long_context_survey_threshold: 'never' } }), + ); + + const result = await getSurveyPopupConfig({ + fetchImpl: fetchImpl as typeof fetch, + cacheFile: null, + }); + + expect(result.long_context_survey_threshold).toBe( + DEFAULT_SURVEY_POPUP_CONFIG.long_context_survey_threshold, + ); + }); + + it('keeps a non-positive threshold so the policy layer can close the long-context arm', async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ name: 'survey_popup', config: { long_context_survey_threshold: 0 } }), + ); + + const result = await getSurveyPopupConfig({ + fetchImpl: fetchImpl as typeof fetch, + cacheFile: null, + }); + + expect(result.long_context_survey_threshold).toBe(0); + }); + + it('falls back to the defaults when the payload is not an object', async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ name: 'survey_popup', config: 'nope' })); + + const result = await getSurveyPopupConfig({ + fetchImpl: fetchImpl as typeof fetch, + cacheFile: null, + }); + + expect(result).toEqual(DEFAULT_SURVEY_POPUP_CONFIG); + }); + + it('falls back to the defaults when the fetch fails', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('offline'); + }); + + const result = await getSurveyPopupConfig({ + fetchImpl: fetchImpl as typeof fetch, + cacheFile: null, + }); + + expect(result).toEqual(DEFAULT_SURVEY_POPUP_CONFIG); + }); + + it('serves the in-process cache within a day and refetches after it', async () => { + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + const now = Date.now(); + + await getSurveyPopupConfig({ fetchImpl: fetchImpl as typeof fetch, now, cacheFile: null }); + const cached = await getSurveyPopupConfig({ + fetchImpl: fetchImpl as typeof fetch, + now: now + 60_000, + cacheFile: null, + }); + expect(cached).toEqual(CLOUD_CONFIG); + expect(fetchImpl).toHaveBeenCalledTimes(1); + + await getSurveyPopupConfig({ + fetchImpl: fetchImpl as typeof fetch, + now: now + 25 * 60 * 60 * 1000, + cacheFile: null, + }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it('persists the fetched config to the disk cache for the next process', async () => { + const cacheFile = await makeCacheFile(); + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + const now = Date.now(); + + await getSurveyPopupConfig({ fetchImpl: fetchImpl as typeof fetch, now, cacheFile }); + + const persisted = JSON.parse(await readFile(cacheFile, 'utf-8')) as { config: unknown }; + expect(persisted.config).toEqual(CLOUD_CONFIG); + + resetSurveyPopupConfigCache(); + const result = await getSurveyPopupConfig({ + fetchImpl: vi.fn(async () => { + throw new Error('must not fetch'); + }) as unknown as typeof fetch, + now: now + 60_000, + cacheFile, + }); + expect(result).toEqual(CLOUD_CONFIG); + }); + + it('ignores a stale disk cache and falls back to the defaults when the refetch fails', async () => { + const cacheFile = await makeCacheFile(); + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + const now = Date.now(); + + await getSurveyPopupConfig({ fetchImpl: fetchImpl as typeof fetch, now, cacheFile }); + resetSurveyPopupConfigCache(); + + const result = await getSurveyPopupConfig({ + fetchImpl: vi.fn(async () => jsonResponse('no', 503)) as unknown as typeof fetch, + now: now + 25 * 60 * 60 * 1000, + cacheFile, + }); + expect(result).toEqual(DEFAULT_SURVEY_POPUP_CONFIG); + }); +}); + +describe('peekSurveyPopupConfig', () => { + it('returns the defaults while the cache is cold', () => { + expect(peekSurveyPopupConfig()).toEqual(DEFAULT_SURVEY_POPUP_CONFIG); + }); + + it('sees the fetched config once the cache is warm', async () => { + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + const now = Date.now(); + + await getSurveyPopupConfig({ fetchImpl: fetchImpl as typeof fetch, now, cacheFile: null }); + + expect(peekSurveyPopupConfig(now + 60_000)).toEqual(CLOUD_CONFIG); + expect(peekSurveyPopupConfig(now + 25 * 60 * 60 * 1000)).toEqual(DEFAULT_SURVEY_POPUP_CONFIG); + }); +}); diff --git a/apps/kimi-code/test/utils/survey-state-store.test.ts b/apps/kimi-code/test/utils/survey-state-store.test.ts new file mode 100644 index 00000000000..b162b64ddeb --- /dev/null +++ b/apps/kimi-code/test/utils/survey-state-store.test.ts @@ -0,0 +1,49 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + readSurveyLastShownTime, + writeSurveyLastShownTime, +} from '#/utils/survey-state-store'; + +describe('survey-state-store', () => { + let dir: string; + let file: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'kimi-survey-state-')); + file = join(dir, 'feedback-survey-state.json'); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('returns undefined when the file is missing', async () => { + await expect(readSurveyLastShownTime(file)).resolves.toBeUndefined(); + }); + + it('round-trips last_shown_time', async () => { + writeSurveyLastShownTime(1_700_000_000_000, file); + await expect(readSurveyLastShownTime(file)).resolves.toBe(1_700_000_000_000); + }); + + it('returns undefined when the file is corrupt', async () => { + await writeFile(file, 'not json', 'utf-8'); + await expect(readSurveyLastShownTime(file)).resolves.toBeUndefined(); + }); + + it('returns undefined when the schema does not match', async () => { + await writeFile(file, JSON.stringify({ version: 2, last_shown_time: 1 }), 'utf-8'); + await expect(readSurveyLastShownTime(file)).resolves.toBeUndefined(); + }); + + it('overwrites a previous timestamp atomically', async () => { + writeSurveyLastShownTime(1, file); + writeSurveyLastShownTime(2, file); + await expect(readSurveyLastShownTime(file)).resolves.toBe(2); + }); +}); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index abbb4f4655a..4d1ab7a0e58 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -522,6 +522,7 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c | `render_latex` | `boolean` | `true` | Render LaTeX math expressions in Markdown messages as Unicode text; `false` keeps the raw source | | `disable_paste_burst` | `boolean` | `false` | Disable the non-bracketed paste-burst fallback that keeps rapid multi-line pastes from submitting line by line | | `cache_expiry_hint` | `boolean` | `true` | On resume or when submitting after a long idle stretch, warn that the context cache may have expired and offer to compact or start a new session (v2 engine only) | +| `disable_feedback_survey` | `boolean` | `false` | Disable the occasional session rating prompt above the input box | | `[editor].command` | `string` | `""` | External editor command for composing long input; empty falls back to `$VISUAL` / `$EDITOR` | | `[notifications].enabled` | `boolean` | `true` | Whether desktop notifications are sent | | `[notifications].notification_condition` | `string` | `unfocused` | When to notify: `unfocused` (only when the terminal is not focused) or `always` | @@ -542,6 +543,7 @@ theme = "auto" # "auto" | "dark" | "light" | custom theme name render_latex = true # false keeps LaTeX math in messages as raw source disable_paste_burst = false # true disables non-bracketed paste-burst fallback cache_expiry_hint = true # false disables the "cache expired" dialog on resume / idle submit +disable_feedback_survey = false # true hides the occasional session rating prompt [editor] command = "" # empty uses $VISUAL / $EDITOR diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 2f066b05174..14640b0ccbd 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -521,6 +521,7 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod | `render_latex` | `boolean` | `true` | 将 Markdown 中的 LaTeX 公式渲染为 Unicode 文本;`false` 保留原始源码 | | `disable_paste_burst` | `boolean` | `false` | 禁用非 bracketed paste 的粘贴突发兜底;默认开启,避免快速多行粘贴被逐行提交 | | `cache_expiry_hint` | `boolean` | `true` | resume 或长时间空闲后发消息时,若上下文缓存可能过期则提醒,可先压缩或新建会话(仅 v2 引擎) | +| `disable_feedback_survey` | `boolean` | `false` | 关闭输入框上方偶尔出现的会话评分提示 | | `[editor].command` | `string` | `""` | 编写长输入用的外部编辑器命令;留空则回退到 `$VISUAL` / `$EDITOR` | | `[notifications].enabled` | `boolean` | `true` | 是否发送桌面通知 | | `[notifications].notification_condition` | `string` | `unfocused` | 何时通知:`unfocused`(仅终端失去焦点时)或 `always`(总是) | @@ -541,6 +542,7 @@ theme = "auto" # "auto" | "dark" | "light" | 自定义主题名 render_latex = true # false 表示消息中的 LaTeX 公式保留原始源码 disable_paste_burst = false # true 表示禁用非 bracketed paste 的粘贴突发兜底 cache_expiry_hint = true # false 表示关闭 resume / 空闲提交时的"缓存已过期"提醒弹窗 +disable_feedback_survey = false # true 表示关闭偶发的会话评分提示 [editor] command = "" # 留空则使用 $VISUAL / $EDITOR diff --git a/packages/telemetry/src/index.ts b/packages/telemetry/src/index.ts index ef268c9785e..b87ab23773e 100644 --- a/packages/telemetry/src/index.ts +++ b/packages/telemetry/src/index.ts @@ -42,7 +42,7 @@ export async function shutdownTelemetry( await shutdown(options); } -export { initializeTelemetry, shouldEnableTelemetry } from './bootstrap'; +export { initializeTelemetry, isTelemetryDisabledByEnv, shouldEnableTelemetry } from './bootstrap'; export type { TelemetryBootstrapOptions } from './bootstrap'; export { installCrashHandlers, setCrashPhase } from './crash';