diff --git a/.changeset/cache-expiry-hint.md b/.changeset/cache-expiry-hint.md new file mode 100644 index 000000000..d318ac22e --- /dev/null +++ b/.changeset/cache-expiry-hint.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Show a cache-expiry reminder when resuming a long-idle session or submitting after a long idle stretch. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 221b39ecf..a3a0f9999 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -52,13 +52,15 @@ function hasConversationHistory(host: SlashCommandHost): boolean { ); } -function currentTuiConfig(host: SlashCommandHost): TuiConfig { +export function currentTuiConfig(host: Pick): TuiConfig { return { theme: host.state.appState.theme, editorCommand: host.state.appState.editorCommand, disablePasteBurst: host.state.appState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, + cacheExpiryHint: host.state.appState.cacheExpiryHint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint, notifications: host.state.appState.notifications, upgrade: host.state.appState.upgrade, + statusLine: host.state.appState.statusLine ?? DEFAULT_TUI_CONFIG.statusLine, }; } diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index a9eb40f05..b36951c78 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -163,6 +163,9 @@ export interface SlashCommandHost { failSessionRequest(message: string): void; sendQueuedMessage(session: Session, item: QueuedMessage): void; requestQueuedGoalPromotion?(): void; + /** Reset the client-side cache-break baseline after the context was cut + * (/undo): the next step's cache-read drop is expected, not a break. */ + noteContextCut?(): void; // UI showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle; diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index 15dc41165..482b852ff 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -63,6 +63,7 @@ export async function applyReloadedTuiConfig( host.setAppState({ editorCommand: config.editorCommand, disablePasteBurst: config.disablePasteBurst, + cacheExpiryHint: config.cacheExpiryHint, notifications: config.notifications, upgrade: config.upgrade, statusLine: config.statusLine, diff --git a/apps/kimi-code/src/tui/commands/undo.ts b/apps/kimi-code/src/tui/commands/undo.ts index 5db587174..23d5a673e 100644 --- a/apps/kimi-code/src/tui/commands/undo.ts +++ b/apps/kimi-code/src/tui/commands/undo.ts @@ -103,6 +103,7 @@ async function undoByCount(host: SlashCommandHost, count: number): Promise void; + readonly onCancel: () => void; +} + +export class CacheHintDialogComponent extends Container implements Focusable { + focused = false; + private readonly opts: CacheHintDialogOptions; + private readonly list: SearchableList; + + constructor(opts: CacheHintDialogOptions) { + super(); + this.opts = opts; + this.list = new SearchableList({ + items: OPTIONS, + toSearchText: (o) => o.label, + initialIndex: 0, + searchable: false, + }); + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + if (matchesKey(data, Key.enter)) { + const chosen = this.list.selected(); + if (chosen !== undefined) this.opts.onSelect(chosen.value); + return; + } + this.list.handleKey(data); + } + + override render(width: number): string[] { + const view = this.list.view(); + const title = `This session has been idle for ${formatIdleDuration(this.opts.idleSeconds)} and is ~${formatTokenCount(this.opts.totalTokens)} tokens.`; + const lines: string[] = [ + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ` ${title}`), + currentTheme.fg('textMuted', ' ↑↓ navigate · Enter select · Esc cancel'), + '', + currentTheme.fg( + 'text', + ' Cache expired — the next message re-sends the entire history at full price.', + ), + ]; + + const maxLabelWidth = Math.max(...OPTIONS.map((o) => visibleWidth(o.label))); + for (let i = view.page.start; i < view.page.end; i++) { + const opt = view.items[i]!; + const isSelected = i === view.selectedIndex; + const pointer = isSelected ? SELECT_POINTER : ' '; + let line = currentTheme.fg(isSelected ? 'primary' : 'textDim', ` ${pointer} `); + line += isSelected + ? currentTheme.boldFg('primary', opt.label) + : currentTheme.fg('text', opt.label); + if (opt.description !== undefined) { + const gap = maxLabelWidth - visibleWidth(opt.label) + 4; + line += ' '.repeat(gap) + currentTheme.fg('textMuted', opt.description); + } + lines.push(line); + } + + lines.push(''); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width)); + } +} diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index 36f43ba07..95f40d6bb 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -54,6 +54,7 @@ export const DEFAULT_STATUS_LINE_CONFIG: StatusLineConfig = { export const TuiConfigFileSchema = z.object({ theme: TuiThemeSchema.optional(), disable_paste_burst: z.boolean().optional(), + cache_expiry_hint: z.boolean().optional(), editor: z .object({ command: z.string().optional(), @@ -76,6 +77,9 @@ export const TuiConfigFileSchema = z.object({ export const TuiConfigSchema = z.object({ theme: TuiThemeSchema, disablePasteBurst: z.boolean(), + /** Present in every normalized config; optional only so hand-built test + * fixtures from before this field existed still typecheck. */ + cacheExpiryHint: z.boolean().optional(), editorCommand: z.string().nullable(), notifications: NotificationsConfigSchema, upgrade: UpgradePreferencesSchema, @@ -101,6 +105,7 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = { export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ theme: 'auto', disablePasteBurst: false, + cacheExpiryHint: true, editorCommand: null, notifications: DEFAULT_NOTIFICATIONS_CONFIG, upgrade: DEFAULT_UPGRADE_PREFERENCES, @@ -186,6 +191,7 @@ export function normalizeTuiConfig( return TuiConfigSchema.parse({ theme: config.theme ?? DEFAULT_TUI_CONFIG.theme, disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, + cacheExpiryHint: config.cache_expiry_hint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint, editorCommand: command === undefined || command.length === 0 ? null : command, notifications: { enabled: config.notifications?.enabled ?? DEFAULT_NOTIFICATIONS_CONFIG.enabled, @@ -234,6 +240,7 @@ export function renderTuiConfig(config: TuiConfig): string { theme = "${escapeTomlBasicString(config.theme)}" # "auto" | "dark" | "light" | custom theme name 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 [editor] command = "${escapeTomlBasicString(config.editorCommand ?? '')}" # Empty uses $VISUAL / $EDITOR diff --git a/apps/kimi-code/src/tui/controllers/cache-hint-controller.ts b/apps/kimi-code/src/tui/controllers/cache-hint-controller.ts new file mode 100644 index 000000000..4a1aa4626 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/cache-hint-controller.ts @@ -0,0 +1,487 @@ +/** + * CacheHintController — drives the "cache expired" dialog for the two trigger + * scenarios: resuming a long-idle session (fires right after the resume + * finishes loading) and submitting after an in-process idle stretch + * (intercepts the submit). Owns the frequency guards and the in-process + * activity baseline; the pure trigger rule lives in `../utils/cache-hint`. + */ + +import type { Component, Focusable } from '@moonshot-ai/pi-tui'; +import type { KimiHarness, Session, TokenUsage } from '@moonshot-ai/kimi-code-sdk'; + +import { getCacheHintConfig, peekCacheHintConfig } from '#/utils/cache-hint-config'; +import { currentTuiConfig } from '../commands/config'; +import { + CacheHintDialogComponent, + type CacheHintAction, +} from '../components/dialogs/cache-hint-dialog'; +import { saveTuiConfig } from '../config'; +import { MAIN_AGENT_ID } from '../constant/kimi-tui'; +import type { AppState } from '../types'; +import type { TUIState } from '../tui-state'; +import { evaluateCacheHint } from '../utils/cache-hint'; +import { formatErrorMessage } from '../utils/event-payload'; +import type { ExtractionResult } from '../utils/image-placeholder'; + +/** A swallowed submit: the raw text plus its media extraction (done before + * the dialog so pasted attachments survive a later store clear). */ +interface StashedSubmit { + readonly text: string; + readonly extraction?: ExtractionResult; +} + +export interface CacheHintHost { + readonly engineV2: boolean; + readonly harness: KimiHarness; + readonly session: Session | undefined; + readonly state: TUIState; + track(event: string, props?: Record): void; + setAppState(patch: Partial): void; + mountEditorReplacement(panel: Component & Focusable): void; + restoreEditor(): void; + restoreInputText(text: string): void; + showError(message: string): void; + createNewSession(): Promise; + sendNormalUserInput(text: string, preExtracted?: ExtractionResult): Promise; +} + +type HintDecision = { readonly idleSeconds: number; readonly totalTokens: number }; + +/** Cache-break detection: a step's cache read dropping under 95% of the + * previous step's by more than this many tokens counts as a break. */ +const CACHE_BREAK_MIN_DROP_TOKENS = 2000; +const CACHE_BREAK_DROP_RATIO = 0.95; + +interface CacheBreakBaseline { + readonly model: string; + readonly effort: string; + readonly usage: TokenUsage; + readonly time: number; +} + +export class CacheHintController { + /** Latest in-process LLM round-trip time (turn begin / turn end). */ + private lastActivityAt: number | undefined; + /** One prompt per idle cycle; reset when a real send starts a turn. */ + private idlePrompted = false; + /** Cold-cache trigger fetches at most once per idle cycle (loop guard for + * the release-and-resend path). */ + private triggerFetchAttempted = false; + /** Swallowed submits waiting on the cold-cache interception chain. */ + private pendingInterceptions = 0; + /** FIFO chain serializing swallowed submits so they keep submit order. */ + private interceptionTail: Promise = Promise.resolve(); + /** Set while a stashed message is being released back into the send path. */ + private releasingStashed = false; + /** Whether the idle dialog's triggering message was restored, not sent. */ + private lastDialogRestored = false; + /** Inputs restored this cycle — chained restores append (newline-joined) + * instead of overwriting the editor. */ + private restoredTexts: string[] = []; + /** Resume scenario fires at most once per session per TUI instance. */ + private readonly resumedSessions = new Set(); + /** Last measured main-loop step usage for cache-break detection. */ + private breakBaseline: CacheBreakBaseline | undefined; + + constructor(private readonly host: CacheHintHost) {} + + /** + * Cache-break detection (client-side, main loop): feed each completed + * step's usage. A step whose cache read drops sharply below the previous + * one is reported as `cache_break_detected` with both usages, both + * model/effort values, the drop ratio, and the interval — a mid-session + * model/effort switch busts the cache key, and that cause is exactly what + * the report should carry. Unmeasured (missing/all-zero) usage is skipped + * without touching the baseline; compaction resets it (the drop there is + * expected). + * + * Also doubles as the cache-activity signal: a completed step is a real + * provider round trip, so the server-side cache was just refreshed — + * unlike a bare turn begin, whose prompt may still fail before any model + * request. + */ + noteStepUsage(usage: TokenUsage | undefined): void { + this.recordActivity(); + if (usage === undefined) return; + if ( + usage.inputOther === 0 && + usage.output === 0 && + usage.inputCacheRead === 0 && + usage.inputCacheCreation === 0 + ) { + return; + } + const model = this.host.state.appState.model; + const effort = this.host.state.appState.thinkingEffort; + const now = Date.now(); + const prev = this.breakBaseline; + this.breakBaseline = { model, effort, usage, time: now }; + if (prev === undefined) return; + const prevRead = prev.usage.inputCacheRead; + const currRead = usage.inputCacheRead; + if (currRead >= prevRead * CACHE_BREAK_DROP_RATIO) return; + if (prevRead - currRead <= CACHE_BREAK_MIN_DROP_TOKENS) return; + this.host.track('cache_break_detected', { + prev_model: prev.model, + curr_model: model, + prev_effort: prev.effort, + curr_effort: effort, + prev_input_cache_read: prevRead, + curr_input_cache_read: currRead, + prev_input_other: prev.usage.inputOther, + curr_input_other: usage.inputOther, + prev_output: prev.usage.output, + curr_output: usage.output, + prev_input_cache_creation: prev.usage.inputCacheCreation, + curr_input_cache_creation: usage.inputCacheCreation, + cache_read_drop_ratio: (prevRead - currRead) / prevRead, + interval_ms: now - prev.time, + }); + } + + /** Compaction legitimately shrinks the cached prefix — reset the baseline. + * Also used when the context is cut by other means (e.g. /undo). */ + resetCacheBreakBaseline(): void { + this.breakBaseline = undefined; + } + + recordActivity(): void { + this.lastActivityAt = Date.now(); + } + + /** + * A real send starts a turn — open a fresh idle cycle. Cache activity is + * deliberately NOT recorded here: the prompt may still fail before any + * model request (rejected call, hook-blocked turn), and only a completed + * provider round trip refreshes the server-side cache. + */ + onTurnBegin(): void { + this.idlePrompted = false; + this.triggerFetchAttempted = false; + this.lastDialogRestored = false; + this.restoredTexts = []; + } + + /** Session switch / create: the new session has no in-process baseline. */ + resetRuntime(): void { + this.lastActivityAt = undefined; + this.idlePrompted = false; + this.triggerFetchAttempted = false; + this.lastDialogRestored = false; + this.restoredTexts = []; + this.breakBaseline = undefined; + } + + /** Background warm-up on session creation; never blocks, never throws. */ + refreshConfigInBackground(): void { + void this.resolveConfig(); + } + + /** Scenario 1: call right after a resume finishes loading. */ + async maybeShowOnResume(): Promise { + const { host } = this; + const session = host.session; + if (!host.engineV2 || session === undefined) return; + if (this.resumedSessions.has(session.id)) return; + const main = session.getResumeState()?.agents[MAIN_AGENT_ID]; + let lastActiveAt = 0; + for (const record of main?.replay ?? []) { + // Only message/compaction records correspond to LLM round-trips; state + // records (permission/plan/config updates, approval results) can be + // appended by slash commands without touching the cache. + if (record.type !== 'message' && record.type !== 'compaction') continue; + if (record.time > lastActiveAt) lastActiveAt = record.time; + } + // `summary.updatedAt` ≈ last user prompt — a coarser but valid fallback. + if (lastActiveAt === 0) lastActiveAt = session.summary?.updatedAt ?? 0; + if (lastActiveAt === 0) return; + const config = await this.resolveConfig(); + // The config fetch above can outlive the user's patience: if they switched + // sessions meanwhile, this dialog (and its actions) would target the wrong + // session — drop it. Likewise, if they already sent the first prompt and + // a turn is now running, don't mount over the active turn. + if (host.session !== session) return; + if (host.state.appState.streamingPhase !== 'idle' || host.state.appState.isCompacting) { + return; + } + // Fold in-process activity into the replay-derived baseline before + // judging: a turn may have completed during the fetch, and a completed + // turn refreshes the server-side cache — the stale replay timestamp would + // warn about an expiration the user just paid to fix. Seeding the + // baseline either way also lets a resume inside the cache window expire + // via the idle-submit path while the user idles in the TUI. + lastActiveAt = Math.max(lastActiveAt, this.lastActivityAt ?? 0); + this.lastActivityAt = lastActiveAt; + const decision = evaluateCacheHint({ + now: Date.now(), + lastActiveAt, + totalTokens: main?.context.tokenCount, + modelId: this.upstreamModelId(), + config, + dismissed: host.state.appState.cacheExpiryHint === false, + }); + if (decision.kind === 'skip') return; + this.resumedSessions.add(session.id); + // The resume dialog also covers this idle cycle: the first submit right + // after it must not be intercepted again. + this.idlePrompted = true; + await this.showDialog('resume', decision, undefined); + } + + /** + * Scenario 2: intercept an idle submit. Returns true when swallowed. + * Synchronous in every non-hint path — the send pipeline must stay + * await-free up to `sendMessage` (tests assert `prompt()` synchronously + * right after `handleUserInput`). When the config cache is cold the submit + * is swallowed while the config is fetched (spec: the trigger must reach + * the interface); the message is then either shown the dialog or released. + */ + maybeInterceptOnSubmit(text: string, extraction?: ExtractionResult): boolean { + const { host } = this; + if (!host.engineV2 || host.session === undefined) return false; + // A stashed message being released re-enters the send path here — never + // re-intercept it (that would start a second fetch loop). + if (this.releasingStashed) return false; + if (this.idlePrompted || this.lastActivityAt === undefined) return false; + if (host.state.appState.streamingPhase !== 'idle' || host.state.appState.isCompacting) { + return false; + } + if (host.state.appState.cacheExpiryHint === false) return false; + // Providers that can never match a cache rule (apiKey / self-hosted) must + // not pay the cold-fetch stall below — no hint can ever come of it. + if (this.upstreamModelId() === undefined) return false; + // Coarse floor: configured cache durations are 10min+, so anything + // fresher than a minute can never hint. + if (Date.now() - this.lastActivityAt < 60_000) return false; + const stash: StashedSubmit = { text, extraction }; + const cached = peekCacheHintConfig(); + if (cached !== undefined) { + const decision = evaluateCacheHint({ + now: Date.now(), + lastActiveAt: this.lastActivityAt, + totalTokens: host.state.appState.contextTokens, + modelId: this.upstreamModelId(), + config: cached, + dismissed: false, + }); + if (decision.kind === 'skip') return false; + this.idlePrompted = true; + // Mounts synchronously inside; the action resolution runs async. + void this.showDialog('idle', decision, stash); + return true; + } + // Config cache cold: fetch at trigger time. Submits arriving while the + // interception is in flight are swallowed too and replayed through a FIFO + // chain, so a later prompt can never overtake the stashed one. A fetch + // that already failed this cycle falls through to the normal send path. + if (this.triggerFetchAttempted && this.pendingInterceptions === 0) return false; + this.triggerFetchAttempted = true; + const sessionId = host.session.id; + this.pendingInterceptions += 1; + this.interceptionTail = this.interceptionTail + .then(() => this.interceptAfterFetch(stash, sessionId)) + .finally(() => { + this.pendingInterceptions -= 1; + }); + return true; + } + + /** Cold-cache path: fetch the config, then show the dialog or release. */ + private async interceptAfterFetch(stash: StashedSubmit, sessionId: string): Promise { + const { host } = this; + // A dialog already ran for this idle cycle: chained submits follow the + // fate of the message that opened it. If that message was restored + // (dismissed or its action failed), restore these too — sending them now + // would reorder the conversation. + if (this.idlePrompted) { + if (this.lastDialogRestored) { + this.restoreStashedInput(stash.text); + } else { + await this.releaseStashed(stash); + } + return; + } + const config = await this.resolveConfig(); + // The fetch window is unbounded for the user: if they switched sessions + // meanwhile, never send the stashed text into the wrong session — hand it + // back to the editor instead. + if (host.session?.id !== sessionId) { + this.restoreStashedInput(stash.text); + return; + } + // If a foreground operation (turn, /compact, …) started meanwhile, don't + // mount over it — release through the normal path, which queues behind + // the running operation. + if (host.state.appState.streamingPhase !== 'idle' || host.state.appState.isCompacting) { + await this.releaseStashed(stash); + return; + } + if (config !== undefined) { + const decision = evaluateCacheHint({ + now: Date.now(), + lastActiveAt: this.lastActivityAt ?? 0, + totalTokens: host.state.appState.contextTokens, + modelId: this.upstreamModelId(), + config, + dismissed: false, + }); + if (decision.kind === 'hint') { + this.idlePrompted = true; + await this.showDialog('idle', decision, stash); + return; + } + } + // No hint (fetch failed or rules don't match): release the message. The + // re-entry skips the fetch (fresh cache or triggerFetchAttempted) and + // flows straight to send. + await this.releaseStashed(stash); + } + + /** Release a stashed message through the normal send path, bypassing the + * interception gate so the re-entry cannot start a second fetch. */ + private async releaseStashed(stash: StashedSubmit): Promise { + this.releasingStashed = true; + try { + await this.host.sendNormalUserInput(stash.text, stash.extraction); + } finally { + this.releasingStashed = false; + } + } + + /** Restore a stashed input to the editor, appending to anything already + * restored this cycle so earlier text is not overwritten. */ + private restoreStashedInput(text: string | undefined): void { + if (text === undefined) return; + this.restoredTexts.push(text); + this.host.restoreInputText(this.restoredTexts.join('\n')); + } + + private upstreamModelId(): string | undefined { + const { model, availableModels, availableProviders } = this.host.state.appState; + const alias = availableModels[model]; + if (alias === undefined) return undefined; + // The cache rules describe the managed service's server-side cache, so + // they only apply to OAuth-managed providers — apiKey or self-hosted + // providers never hint. + if (availableProviders[alias.provider]?.oauth === undefined) return undefined; + return alias.model; + } + + private async resolveConfig() { + let accessToken: string | undefined; + try { + accessToken = await this.host.harness.auth.getCachedAccessToken(); + } catch { + // Facade unavailable (test doubles) — never fetch. + return undefined; + } + // The endpoint is public: apiKey-only users fetch anonymously. + return getCacheHintConfig({ accessToken }); + } + + private async showDialog( + scene: 'resume' | 'idle', + decision: HintDecision, + stashed: StashedSubmit | undefined, + ): Promise { + const { host } = this; + host.track('cache_hint_shown', { + scene, + model: host.state.appState.model, + idle_seconds: decision.idleSeconds, + total_tokens: decision.totalTokens, + }); + const action = await new Promise((resolve) => { + host.state.activeDialog = 'cache-hint'; + host.mountEditorReplacement( + new CacheHintDialogComponent({ + idleSeconds: decision.idleSeconds, + totalTokens: decision.totalTokens, + onSelect: (a) => { + resolve(a); + }, + onCancel: () => { + resolve('dismiss'); + }, + }), + ); + }); + host.state.activeDialog = null; + host.restoreEditor(); + host.track('cache_hint_action', { action, scene }); + await this.runAction(action, stashed); + } + + private async runAction( + action: CacheHintAction | 'dismiss', + stashed: StashedSubmit | undefined, + ): Promise { + const { host } = this; + const restoreInput = () => { + this.lastDialogRestored = true; + this.restoreStashedInput(stashed?.text); + }; + switch (action) { + case 'dismiss': + restoreInput(); + return; + case 'never': + host.setAppState({ cacheExpiryHint: false }); + try { + await saveTuiConfig({ ...currentTuiConfig(host), cacheExpiryHint: false }); + } catch { + host.showError('Failed to save the tui.toml preference.'); + } + break; + case 'compact': { + const session = host.session; + if (session !== undefined) { + try { + await session.compact({}); + } catch (error) { + host.showError(`Compact failed: ${formatErrorMessage(error)}`); + restoreInput(); + return; + } + if (stashed !== undefined) { + // compact() is trigger-only — the engine engages asynchronously. + // Wait for the engagement barrier so the resend lands in the + // queue and drains automatically when compaction finishes. + if (!(await this.waitForCompactionStart())) { + host.showError('Compact did not start; message not sent.'); + restoreInput(); + return; + } + } + } + break; + } + case 'new': { + const previousId = host.state.appState.sessionId; + await host.createNewSession(); + if (host.state.appState.sessionId === previousId) { + // Creation failed (error already surfaced); keep the input for retry. + restoreInput(); + return; + } + break; + } + case 'continue': + break; + } + this.lastDialogRestored = false; + if (stashed !== undefined) await host.sendNormalUserInput(stashed.text, stashed.extraction); + } + + /** Bounded wait for the engine to flip `isCompacting` after a compact RPC. */ + private async waitForCompactionStart(timeoutMs = 3000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (this.host.state.appState.isCompacting) return true; + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + } + return false; + } +} 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 b82f91961..89694c942 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -28,6 +28,7 @@ import type { TurnStepCompletedEvent, TurnStepInterruptedEvent, TurnStepStartedEvent, + TokenUsage, WarningEvent, } from '@moonshot-ai/kimi-code-sdk'; @@ -104,6 +105,9 @@ export interface SessionEventHost { showNotice(title: string, detail?: string): void; updateActivityPane(): void; track(event: string, props?: Record): void; + recordSessionActivity(): void; + noteStepUsage(usage: TokenUsage | undefined): void; + noteCompactionFinished(): void; mountEditorReplacement(panel: Component & Focusable): void; restoreEditor(): void; restoreInputText(text: string): void; @@ -365,6 +369,7 @@ export class SessionEventHandler { } this.host.streamingUI.resetToolUi(); this.host.streamingUI.finalizeTurn(sendQueued); + this.host.recordSessionActivity(); this.renderPendingModelBlockedFallback(); this.currentTurnHasAssistantText = false; this.goalCompletionTurnEnded = true; @@ -405,6 +410,7 @@ export class SessionEventHandler { private handleStepCompleted(event: TurnStepCompletedEvent): void { this.host.streamingUI.flushNow(); + this.host.noteStepUsage(event.usage); this.maybeShowDebugTiming(event); if (event.providerFinishReason === 'filtered') { @@ -1041,6 +1047,12 @@ export class SessionEventHandler { event.result.tokensAfter, event.result.summary, ); + // A completed compaction just refreshed and shrank the cached context — + // count it as activity so the next submit isn't judged against the + // pre-compaction timestamp, and reset the cache-break baseline (the drop + // is expected). Cancellations do neither: the context was not cut. + this.host.recordSessionActivity(); + this.host.noteCompactionFinished(); this.finishCompaction(sendQueued); } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 845c48bf7..7c118e57a 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -14,6 +14,7 @@ import type { PromptPart, Session, SkillSummary, + TokenUsage, WorkspaceTrustInfo, } from '@moonshot-ai/kimi-code-sdk'; import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; @@ -49,6 +50,7 @@ import { type SkillListSession, } from './commands'; import * as slashCommands from './commands/dispatch'; +import { CacheHintController } from './controllers/cache-hint-controller'; import { BannerComponent } from './components/chrome/banner'; import { DeviceCodeBoxComponent } from './components/chrome/device-code-box'; import { GutterContainer } from './components/chrome/gutter-container'; @@ -144,6 +146,7 @@ import { formatErrorMessage } from './utils/event-payload'; import { pickForegroundTasks } from './utils/foreground-task'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; import { extractMediaAttachments, rewriteMediaPlaceholders } from './utils/image-placeholder'; +import type { ExtractionResult } from './utils/image-placeholder'; import { installInputLatencyProbe } from './utils/input-latency'; import { startupTrace } from '#/utils/startup-trace'; import { REPLAY_TURN_LIMIT } from './utils/message-replay'; @@ -242,6 +245,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { version: input.version, editorCommand: input.tuiConfig.editorCommand, disablePasteBurst: input.tuiConfig.disablePasteBurst, + cacheExpiryHint: input.tuiConfig.cacheExpiryHint, notifications: input.tuiConfig.notifications, upgrade: input.tuiConfig.upgrade, statusLine: input.tuiConfig.statusLine, @@ -314,6 +318,7 @@ export class KimiTUI { state: TUIState; /** In-flight lazy session creation (v2 engine), shared by concurrent first-use triggers. */ private ensureSessionPromise: Promise | null = null; + private readonly cacheHint = new CacheHintController(this); private readonly approvalController = new ApprovalController(); private readonly questionController = new QuestionController(); private readonly reverseRpcDisposers: Array<() => void> = []; @@ -774,6 +779,9 @@ export class KimiTUI { this.sessionEventHandler.startSubscription(); void this.showSessionWarnings(this.session); } + if (shouldReplayHistory) { + void this.cacheHint.maybeShowOnResume(); + } void this.fetchSessions(); if (this.session !== undefined) { this.updateTerminalTitle(); @@ -1240,7 +1248,7 @@ export class KimiTUI { this.updateQueueDisplay(); } - async sendNormalUserInput(text: string): Promise { + async sendNormalUserInput(text: string, preExtracted?: ExtractionResult): Promise { if (this.btwPanelController.sendUserInput(text)) return; if (this.state.appState.model.trim().length === 0) { this.showError(LLM_NOT_SET_MESSAGE); @@ -1251,7 +1259,11 @@ export class KimiTUI { // Pasted videos are copied into the cache and expand to a `file://` // `video_url` part; the engine resolves (uploads or degrades) them // inside the turn, so submission stays fully synchronous. - extraction = extractMediaAttachments(text, this.imageStore); + // + // A cache-hint-swallowed resend passes its pre-dialog extraction back + // in: the image store may already be cleared (e.g. after "Start a new + // session"), so re-extracting from the text would lose the media. + extraction = preExtracted ?? extractMediaAttachments(text, this.imageStore); } catch (error) { // A video cache copy failed (unwritable cache dir, vanished source…); // nothing was dispatched. @@ -1259,6 +1271,10 @@ export class KimiTUI { return; } if (!this.validateMediaCapabilities(extraction)) return; + // Idle cache-hint interception sits before session creation; it is + // synchronous unless a hint actually fires, keeping the send path + // await-free up to sendMessage. + if (this.cacheHint.maybeInterceptOnSubmit(text, extraction)) return; let session = this.session; if (session === undefined) { if (!this.engineV2) { @@ -1368,6 +1384,7 @@ export class KimiTUI { } beginSessionRequest(): void { + this.cacheHint.onTurnBegin(); this.streamingUI.setTurnId(undefined); this.streamingUI.resetLiveText(); this.streamingUI.resetToolUi(); @@ -1716,6 +1733,8 @@ export class KimiTUI { } private async createSessionFromCurrentState(bindStartupAgent = false): Promise { + // Background warm-up of the cache-hint config on every new session. + this.cacheHint.refreshConfigInBackground(); const model = this.state.appState.model.trim(); if (model.length === 0) { throw new Error(LLM_NOT_SET_MESSAGE); @@ -1965,6 +1984,7 @@ export class KimiTUI { resetSessionRuntime(): void { this.aborted = false; + this.cacheHint.resetRuntime(); this.streamingUI.discardPending(); this.state.queuedMessages = []; this.state.swarmModeEntry = undefined; @@ -2054,6 +2074,7 @@ export class KimiTUI { } this.showStatus(statusMessage); void this.showSessionWarnings(session); + void this.cacheHint.maybeShowOnResume(); } async reloadCurrentSessionView(session: Session, statusMessage: string): Promise { @@ -3069,6 +3090,26 @@ export class KimiTUI { this.state.ui.requestRender(); } + /** Latest in-process LLM round-trip; feeds the idle cache-hint scenario. */ + recordSessionActivity(): void { + this.cacheHint.recordActivity(); + } + + /** Per-step usage for the client-side cache-break detector. */ + noteStepUsage(usage: TokenUsage | undefined): void { + this.cacheHint.noteStepUsage(usage); + } + + /** Compaction shrinks the cached prefix — reset the cache-break baseline. */ + noteCompactionFinished(): void { + this.cacheHint.resetCacheBreakBaseline(); + } + + /** /undo cut the context — the next step's cache drop is expected. */ + noteContextCut(): void { + this.cacheHint.resetCacheBreakBaseline(); + } + private async runMigrationScreen(plan: MigrationPlan): Promise { const result = await new Promise((resolve) => { const screen = new MigrationScreenComponent({ diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index 34571eb36..349ecbdea 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -48,7 +48,7 @@ export interface TUIState { sessions: SessionRow[]; loadingSessions: boolean; sessionsScope: 'cwd' | 'all'; - activeDialog: 'session-picker' | 'help' | 'trust-prompt' | null; + activeDialog: 'session-picker' | 'help' | 'trust-prompt' | 'cache-hint' | null; tasksBrowser: TasksBrowserState | undefined; externalEditorRunning: boolean; queuedMessages: QueuedMessage[]; diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 8ff0041a0..755fa9575 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -69,6 +69,8 @@ export interface AppState { editorCommand: string | null; /** Mirrors the TUI config toggle; defaults to false when absent from older fixtures. */ disablePasteBurst?: boolean; + /** Mirrors the TUI config toggle; defaults to true when absent from older fixtures. */ + cacheExpiryHint?: boolean; notifications: NotificationsConfig; upgrade: UpgradePreferences; /** Footer status line customization from tui.toml; absent means the default layout. */ diff --git a/apps/kimi-code/src/tui/utils/cache-hint.ts b/apps/kimi-code/src/tui/utils/cache-hint.ts new file mode 100644 index 000000000..90a0d3e58 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/cache-hint.ts @@ -0,0 +1,52 @@ +import type { CacheHintConfig } from '#/utils/cache-hint-config'; + +export interface CacheHintInput { + /** Current time, epoch ms. */ + readonly now: number; + /** Last session activity, epoch ms. Missing → skip. */ + readonly lastActiveAt?: number; + /** Current context size in tokens. Missing → skip (no local estimation). */ + readonly totalTokens?: number; + /** Upstream model ID used to look up the rule. Missing/unconfigured → skip. */ + readonly modelId?: string; + /** Fetch failure → undefined → skip. */ + readonly config?: CacheHintConfig; + /** User chose "Don't ask me again" (tui.toml). */ + readonly dismissed: boolean; +} + +export type CacheHintDecision = + | { readonly kind: 'skip' } + | { readonly kind: 'hint'; readonly idleSeconds: number; readonly totalTokens: number }; + +/** + * Shared trigger rule for both the resume and the idle scenarios. Every + * missing-data branch skips — false negatives are acceptable, false positives + * are not. + */ +export function evaluateCacheHint(input: CacheHintInput): CacheHintDecision { + if (input.dismissed) return { kind: 'skip' }; + const { config, modelId, lastActiveAt, totalTokens } = input; + if (config === undefined || modelId === undefined) return { kind: 'skip' }; + if (lastActiveAt === undefined || totalTokens === undefined) return { kind: 'skip' }; + const rule = config.config[modelId]; + if (rule === undefined) return { kind: 'skip' }; + const idleMs = input.now - lastActiveAt; + if (idleMs <= rule.cache_duration * 1000) return { kind: 'skip' }; + if (totalTokens < rule.min_tokens_to_hint) return { kind: 'skip' }; + return { kind: 'hint', idleSeconds: Math.floor(idleMs / 1000), totalTokens }; +} + +/** `45m` / `3h 20m` / `26d 22h`. */ +export function formatIdleDuration(idleSeconds: number): string { + const minutes = Math.max(1, Math.floor(idleSeconds / 60)); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) { + const restMinutes = minutes % 60; + return restMinutes === 0 ? `${hours}h` : `${hours}h ${restMinutes}m`; + } + const days = Math.floor(hours / 24); + const restHours = hours % 24; + return restHours === 0 ? `${days}d` : `${days}d ${restHours}h`; +} diff --git a/apps/kimi-code/src/utils/cache-hint-config.ts b/apps/kimi-code/src/utils/cache-hint-config.ts new file mode 100644 index 000000000..4909970da --- /dev/null +++ b/apps/kimi-code/src/utils/cache-hint-config.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; + +import { + getClientConfig, + peekClientConfig, + resetClientConfigCache, + type ClientConfigFetchOptions, +} from '#/utils/client-configs'; + +/** The cache-hint rules are one named config on the client-configs endpoint. */ +const CONFIG_NAME = 'estimated_cache_duration'; + +const cacheHintModelRuleSchema = z.object({ + min_tokens_to_hint: z.number(), + cache_duration: z.number(), +}); + +const cacheHintConfigSchema = z.object({ + version: z.literal(1), + config: z.record(z.string(), cacheHintModelRuleSchema), +}); + +export type CacheHintConfig = z.infer; +export type CacheHintConfigFetchOptions = ClientConfigFetchOptions; + +/** + * Returns the cache-hint config, preferring the cache (1 day, persisted + * across restarts). Any failure resolves to `undefined` — callers treat + * that as "do not hint". + */ +export async function getCacheHintConfig( + options: CacheHintConfigFetchOptions = {}, +): Promise { + return getClientConfig(CONFIG_NAME, cacheHintConfigSchema, options); +} + +/** Fire-and-forget refresh, e.g. on new-session creation. Never throws. */ +export function refreshCacheHintConfigInBackground( + options: CacheHintConfigFetchOptions = {}, +): void { + void getCacheHintConfig(options).catch(() => undefined); +} + +/** Synchronous peek at the fresh cache; undefined when missing or stale. */ +export function peekCacheHintConfig(now?: number): CacheHintConfig | undefined { + return peekClientConfig(CONFIG_NAME, cacheHintConfigSchema, now); +} + +/** Test hook: drop the in-process cache. */ +export function resetCacheHintConfigCache(): void { + resetClientConfigCache(CONFIG_NAME); +} diff --git a/apps/kimi-code/src/utils/client-configs.ts b/apps/kimi-code/src/utils/client-configs.ts new file mode 100644 index 000000000..02156aabf --- /dev/null +++ b/apps/kimi-code/src/utils/client-configs.ts @@ -0,0 +1,187 @@ +import { join } from 'node:path'; + +import { kimiCodeBaseUrl } from '@moonshot-ai/kimi-code-oauth'; +import { z } from 'zod'; + +import { getCacheDir } from '#/utils/paths'; +import { readJsonFile, writeJsonFile } from '#/utils/persistence'; + +/** + * Generic client for the public client-configs endpoint: + * `POST {kimiCodeBaseUrl}/client_configs {"name": ""}` returns + * `{ name, config: }`, where the payload shape is config-specific + * and validated by the caller-supplied schema. + * + * Each named config is cached for a day, in two layers: an in-process map + * (the only layer the synchronous peek can see) and a JSON file under the + * CLI cache dir (survives restarts, so the TTL holds across processes). An + * entry missing/stale in both layers triggers a refetch. Any failure + * resolves to `undefined` — callers treat that as "config unavailable" and + * degrade quietly. + */ +const CLIENT_CONFIGS_PATH = '/client_configs'; + +/** Cache validity per config name: 1 day. */ +const CONFIG_CACHE_TTL_MS = 24 * 60 * 60 * 1000; +const FETCH_TIMEOUT_MS = 5000; + +export interface ClientConfigFetchOptions { + /** Managed OAuth token; sent as Bearer when present. The endpoint is + * public, so anonymous fetches work too. */ + readonly accessToken?: string; + /** Test hook. */ + readonly fetchImpl?: typeof fetch; + /** Test hook. */ + readonly now?: number; + /** Test hook: override the cache file path, or null to skip the disk + * layer entirely. */ + readonly cacheFile?: string | null; +} + +const cache = new Map(); + +const cacheFileEnvelopeSchema = z.object({ + version: z.literal(1), + fetchedAt: z.number(), + config: z.unknown(), +}); + +function cacheFileFor(name: string, options: ClientConfigFetchOptions): string | undefined { + if (options.cacheFile === null) return undefined; + if (options.cacheFile !== undefined) return options.cacheFile; + return join(getCacheDir(), 'client-configs', `${name.replaceAll(/[^a-zA-Z0-9_-]/g, '_')}.json`); +} + +/** Fresh disk entry, or undefined when missing/stale/invalid. */ +async function readDiskCache( + file: string, + schema: S, + now: number, +): Promise<{ readonly fetchedAt: number; readonly data: z.infer } | undefined> { + let envelope: z.infer; + try { + // The sentinel's fetchedAt=0 reads as stale, i.e. missing. + envelope = await readJsonFile(file, cacheFileEnvelopeSchema, { + version: 1, + fetchedAt: 0, + config: undefined, + }); + } catch { + return undefined; // malformed cache file — treat as missing + } + if (now - envelope.fetchedAt >= CONFIG_CACHE_TTL_MS) return undefined; + const parsed = schema.safeParse(envelope.config); + return parsed.success + ? { fetchedAt: envelope.fetchedAt, data: parsed.data as z.infer } + : undefined; +} + +/** Best-effort persist; a cache write failure must never break the caller. */ +async function writeDiskCache(file: string, data: unknown, now: number): Promise { + try { + await writeJsonFile(file, cacheFileEnvelopeSchema, { + version: 1, + fetchedAt: now, + config: data, + }); + } catch { + // A cache that cannot be written just means the next process refetches. + } +} + +/** Returns the named client config, preferring the caches over the network. */ +export async function getClientConfig( + name: string, + schema: S, + options: ClientConfigFetchOptions = {}, +): Promise | undefined> { + const now = options.now ?? Date.now(); + const hit = cache.get(name); + if (hit !== undefined && now - hit.fetchedAt < CONFIG_CACHE_TTL_MS) { + return hit.data as z.infer; + } + const file = cacheFileFor(name, options); + if (file !== undefined) { + const diskHit = await readDiskCache(file, schema, now); + if (diskHit !== undefined) { + // Warm the in-process layer with the original fetch time, so the entry + // still expires a day after it was actually fetched. + cache.set(name, diskHit); + return diskHit.data; + } + } + const data = await fetchClientConfig(name, schema, options); + if (data === undefined) return undefined; + cache.set(name, { fetchedAt: now, data }); + if (file !== undefined) await writeDiskCache(file, data, now); + return data; +} + +/** Fire-and-forget refresh of a named config. Never throws. */ +export function refreshClientConfigInBackground( + name: string, + schema: S, + options: ClientConfigFetchOptions = {}, +): void { + void getClientConfig(name, schema, options).catch(() => undefined); +} + +/** + * Synchronous peek at the fresh in-process cache; undefined when missing or + * stale. Only sees the in-process layer — the disk layer is read by the + * async `getClientConfig`, which warms this layer. + */ +export function peekClientConfig( + name: string, + schema: S, + now: number = Date.now(), +): z.infer | undefined { + const hit = cache.get(name); + if (hit === undefined || now - hit.fetchedAt >= CONFIG_CACHE_TTL_MS) return undefined; + const parsed = schema.safeParse(hit.data); + return parsed.success ? (parsed.data as z.infer) : undefined; +} + +export async function fetchClientConfig( + name: string, + schema: S, + options: ClientConfigFetchOptions = {}, +): Promise | undefined> { + const fetchFn = options.fetchImpl ?? fetch; + const headers: Record = { + accept: 'application/json', + 'content-type': 'application/json', + }; + if (options.accessToken !== undefined) { + headers['authorization'] = `Bearer ${options.accessToken}`; + } + try { + const response = await fetchFn(`${kimiCodeBaseUrl()}${CLIENT_CONFIGS_PATH}`, { + method: 'POST', + headers, + body: JSON.stringify({ name }), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) return undefined; + const body: unknown = await response.json(); + if (typeof body !== 'object' || body === null) return undefined; + const envelope = body as Record; + if (envelope['name'] !== name) return undefined; + const parsed = schema.safeParse(envelope['config']); + return parsed.success ? (parsed.data as z.infer) : undefined; + } catch { + return undefined; + } +} + +/** + * Test hook: drop one or all in-process cached configs. Disk files in tests + * are isolated via the `cacheFile` option. + */ +export function resetClientConfigCache(name?: string): void { + if (name === undefined) { + cache.clear(); + } else { + cache.delete(name); + } +} diff --git a/apps/kimi-code/test/tui/commands/reload.test.ts b/apps/kimi-code/test/tui/commands/reload.test.ts index c07a31275..b36f96213 100644 --- a/apps/kimi-code/test/tui/commands/reload.test.ts +++ b/apps/kimi-code/test/tui/commands/reload.test.ts @@ -34,6 +34,7 @@ describe('reload slash commands', () => { it('reloads tui.toml without touching Core session state', async () => { await writeTuiConfig(` theme = "light" +cache_expiry_hint = false [editor] command = "vim" @@ -56,6 +57,7 @@ auto_install = false expect(host.state.appState).toMatchObject({ theme: 'light', editorCommand: 'vim', + cacheExpiryHint: false, notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, }); 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 b584c33d6..bf56ba018 100644 --- a/apps/kimi-code/test/tui/commands/update-preferences.test.ts +++ b/apps/kimi-code/test/tui/commands/update-preferences.test.ts @@ -43,8 +43,10 @@ describe('update preference commands', () => { theme: 'auto', editorCommand: null, disablePasteBurst: false, + cacheExpiryHint: true, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: false }, + statusLine: { items: null, command: null }, }); expect(setAppState).toHaveBeenCalledWith({ upgrade: { autoInstall: false } }); expect(track).toHaveBeenCalledWith('upgrade_preference_changed', { auto_install: false }); diff --git a/apps/kimi-code/test/tui/components/dialogs/cache-hint-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/cache-hint-dialog.test.ts new file mode 100644 index 000000000..6f33c9d17 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/cache-hint-dialog.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { CacheHintDialogComponent } from '#/tui/components/dialogs/cache-hint-dialog'; + +const ANSI_SGR = /\[[0-9;]*m/g; + +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +function renderDialog(idleSeconds = (26 * 24 + 22) * 3600, totalTokens = 293000) { + const onSelect = vi.fn(); + const onCancel = vi.fn(); + const dialog = new CacheHintDialogComponent({ idleSeconds, totalTokens, onSelect, onCancel }); + return { dialog, onSelect, onCancel, lines: dialog.render(120).map(strip) }; +} + +describe('CacheHintDialogComponent', () => { + it('renders the title with idle duration and token count', () => { + const { lines } = renderDialog(); + expect( + lines.some((l) => l.includes('This session has been idle for 26d 22h and is ~286k tokens.')), + ).toBe(true); + }); + + it('renders the body line and the standard hint vocabulary', () => { + const { lines } = renderDialog(); + expect( + lines.some((l) => + l.includes('Cache expired — the next message re-sends the entire history at full price.'), + ), + ).toBe(true); + const titleIdx = lines.findIndex((l) => l.includes('This session has been idle')); + expect(lines[titleIdx + 1]).toContain('↑↓ navigate'); + expect(lines[titleIdx + 1]).toContain('Enter select'); + expect(lines[titleIdx + 1]).toContain('Esc cancel'); + }); + + it('renders all four options in order with right-column descriptions', () => { + const { lines } = renderDialog(); + const compact = lines.findIndex((l) => l.includes('Compact and continue')); + const fresh = lines.findIndex((l) => l.includes('Start a new session')); + const asIs = lines.findIndex((l) => l.includes('Continue as-is')); + const never = lines.findIndex((l) => l.includes("Don't ask me again")); + + expect(compact).toBeGreaterThanOrEqual(0); + expect(compact).toBeLessThan(fresh); + expect(fresh).toBeLessThan(asIs); + expect(asIs).toBeLessThan(never); + expect(lines[compact]).toContain('one-time compact cost · cheapest way to keep this topic'); + expect(lines[fresh]).toContain('zero context cost · best for a new task'); + expect(lines[asIs]).toContain('full history kept · highest cost per turn'); + }); + + it('aligns description columns across options', () => { + const { lines } = renderDialog(); + const colOf = (labelNeedle: string, descNeedle: string) => { + const line = lines.find((l) => l.includes(labelNeedle)); + expect(line).toBeDefined(); + return line!.indexOf(descNeedle); + }; + const reference = colOf('Compact and continue', 'one-time compact cost'); + expect(colOf('Start a new session', 'zero context cost')).toBe(reference); + expect(colOf('Continue as-is', 'full history kept')).toBe(reference); + }); + + it('selects the highlighted option on Enter, compact by default', () => { + const { dialog, onSelect } = renderDialog(); + dialog.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith('compact'); + }); + + it('navigates with arrows before selecting', () => { + const { dialog, onSelect } = renderDialog(); + dialog.handleInput('\u001B[B'); // down + dialog.handleInput('\u001B[B'); // down + dialog.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith('continue'); + }); + + it('cancels on Esc without selecting', () => { + const { dialog, onSelect, onCancel } = renderDialog(); + dialog.handleInput('\u001B'); + expect(onCancel).toHaveBeenCalled(); + expect(onSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index 664fda616..9ae144a2b 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -34,6 +34,7 @@ describe('TUI config', () => { const text = readFileSync(filePath, 'utf-8'); expect(text).toContain('Client preferences for kimi-code.'); expect(text).toContain('theme = "auto"'); + expect(text).toContain('cache_expiry_hint = true'); expect(text).toContain('command = ""'); expect(text).toContain('[upgrade]'); expect(text).toContain('auto_install = true'); @@ -60,6 +61,7 @@ auto_install = false expect(config).toEqual({ theme: 'light', disablePasteBurst: false, + cacheExpiryHint: true, editorCommand: 'code --wait', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, @@ -76,6 +78,15 @@ disable_paste_burst = true expect(config.disablePasteBurst).toBe(true); }); + it('parses cache_expiry_hint', () => { + const config = parseTuiConfig(` +theme = "dark" +cache_expiry_hint = false +`); + + expect(config.cacheExpiryHint).toBe(false); + }); + it('normalizes an empty editor command to auto-detect', () => { const config = parseTuiConfig(` [editor] @@ -85,6 +96,7 @@ command = " " expect(config).toEqual({ theme: 'auto', disablePasteBurst: false, + cacheExpiryHint: true, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, @@ -118,6 +130,7 @@ command = " " { theme: 'light', disablePasteBurst: false, + cacheExpiryHint: true, editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, @@ -129,6 +142,7 @@ command = " " expect(await loadTuiConfig(filePath)).toEqual({ theme: 'light', disablePasteBurst: false, + cacheExpiryHint: true, editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, @@ -142,6 +156,7 @@ command = " " { theme, disablePasteBurst: DEFAULT_TUI_CONFIG.disablePasteBurst, + cacheExpiryHint: DEFAULT_TUI_CONFIG.cacheExpiryHint, editorCommand: null, notifications: DEFAULT_TUI_CONFIG.notifications, upgrade: DEFAULT_TUI_CONFIG.upgrade, diff --git a/apps/kimi-code/test/tui/controllers/cache-hint-controller.test.ts b/apps/kimi-code/test/tui/controllers/cache-hint-controller.test.ts new file mode 100644 index 000000000..99834d9d9 --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/cache-hint-controller.test.ts @@ -0,0 +1,662 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + CacheHintController, + type CacheHintHost, +} from '#/tui/controllers/cache-hint-controller'; +import type { CacheHintConfig } from '#/utils/cache-hint-config'; + +const peekMock = vi.fn<() => CacheHintConfig | undefined>(() => undefined); +const getMock = vi.fn(async (): Promise => undefined); + +vi.mock('#/utils/cache-hint-config', () => ({ + peekCacheHintConfig: () => peekMock(), + getCacheHintConfig: (...args: unknown[]) => getMock(...(args as [])), + refreshCacheHintConfigInBackground: () => undefined, + resetCacheHintConfigCache: () => undefined, +})); + +const CONFIG: CacheHintConfig = { + version: 1, + config: { 'kimi-k2': { min_tokens_to_hint: 100000, cache_duration: 600 } }, +}; + +function makeHost( + overrides: { + session?: unknown; + appState?: Record; + createNewSessionFails?: boolean; + } = {}, +) { + const state = { + activeDialog: null as string | null, + appState: { + model: 'k2', + availableModels: { k2: { model: 'kimi-k2', provider: 'managed:kimi-code' } }, + availableProviders: { 'managed:kimi-code': { oauth: { key: 'kimi-code' } } }, + sessionId: 's1', + streamingPhase: 'idle', + isCompacting: false, + contextTokens: 150000, + cacheExpiryHint: true, + ...overrides.appState, + }, + }; + const host: CacheHintHost = { + engineV2: true, + harness: { auth: { getCachedAccessToken: vi.fn(async () => 'tok') } } as never, + session: (overrides.session ?? { id: 's1' }) as never, + state: state as never, + track: vi.fn(), + setAppState: vi.fn((patch) => Object.assign(state.appState, patch)), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), + showError: vi.fn(), + createNewSession: vi.fn(async () => { + if (overrides.createNewSessionFails !== true) state.appState.sessionId = 's2'; + }), + sendNormalUserInput: vi.fn(async () => undefined), + }; + return { host, state }; +} + +function resumeSession(replayTimes: number[], tokenCount: number, updatedAt = 0) { + return { + id: 's1', + summary: { updatedAt }, + getResumeState: () => ({ + agents: { + main: { + replay: replayTimes.map((time) => ({ type: 'message', time })), + context: { tokenCount }, + }, + }, + }), + }; +} + +async function flush(times = 20): Promise { + for (let i = 0; i < times; i++) await new Promise((r) => setImmediate(r)); +} + +beforeEach(() => { + peekMock.mockReset().mockReturnValue(undefined); + getMock.mockReset().mockResolvedValue(undefined); + vi.useRealTimers(); +}); + +describe('CacheHintController scenario 2 (idle submit)', () => { + it('does not intercept a fresh submit (no activity baseline)', () => { + const { host } = makeHost(); + const controller = new CacheHintController(host); + expect(controller.maybeInterceptOnSubmit('hello')).toBe(false); + }); + + it('does not intercept when idle for less than the coarse floor', () => { + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + expect(controller.maybeInterceptOnSubmit('hello')).toBe(false); + expect(peekMock).not.toHaveBeenCalled(); + }); + + it('does not intercept when the provider is not OAuth-managed', () => { + peekMock.mockReturnValue(CONFIG); + const { host } = makeHost({ + appState: { + availableProviders: { 'managed:kimi-code': {} }, // apiKey form: no oauth + }, + }); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + expect(controller.maybeInterceptOnSubmit('hello')).toBe(false); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); + + it('does not cold-fetch for providers that can never match a rule', async () => { + // Config cache cold (peek returns undefined by default): without the + // applicability gate this submit would be swallowed for a fetch that can + // never produce a hint. + const { host } = makeHost({ + appState: { + availableProviders: { 'managed:kimi-code': {} }, // apiKey form: no oauth + }, + }); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + expect(controller.maybeInterceptOnSubmit('hello')).toBe(false); + await flush(); + expect(getMock).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); + + it('swallows a cold-cache submit, fetches, and releases when no rule matches', async () => { + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + expect(controller.maybeInterceptOnSubmit('hello')).toBe(true); + await flush(); + expect(getMock).toHaveBeenCalled(); + // Fetch resolved without a matching rule → the message is released. + expect(host.sendNormalUserInput).toHaveBeenCalledWith('hello', undefined); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); + + it('fetches on a cold-cache submit and shows the dialog when a rule matches', async () => { + getMock.mockResolvedValue(CONFIG); + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + expect(controller.maybeInterceptOnSubmit('hello')).toBe(true); + await vi.waitFor(() => { + expect(host.mountEditorReplacement).toHaveBeenCalled(); + }); + expect(host.track).toHaveBeenCalledWith( + 'cache_hint_shown', + expect.objectContaining({ scene: 'idle' }), + ); + vi.restoreAllMocks(); + }); + + it('serializes cold-cache submits so they keep their order', async () => { + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + + expect(controller.maybeInterceptOnSubmit('hello')).toBe(true); + expect(controller.maybeInterceptOnSubmit('world')).toBe(true); + await flush(); + vi.restoreAllMocks(); + + expect(host.sendNormalUserInput).toHaveBeenCalledTimes(2); + expect( + (host.sendNormalUserInput as ReturnType).mock.calls.map((c) => c[0]), + ).toEqual(['hello', 'world']); + }); + + it('restores chained submits instead of sending when the dialog is dismissed', async () => { + getMock.mockResolvedValue(CONFIG); + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + + expect(controller.maybeInterceptOnSubmit('hello')).toBe(true); + expect(controller.maybeInterceptOnSubmit('world')).toBe(true); + await vi.waitFor(() => { + expect(host.mountEditorReplacement).toHaveBeenCalled(); + }); + vi.restoreAllMocks(); + + const dialog = (host.mountEditorReplacement as ReturnType).mock.calls[0]![0] as { + handleInput: (data: string) => void; + }; + dialog.handleInput('\u001B'); // dismiss the first dialog + await flush(); + + // Nothing was sent; both inputs are back in the editor, newline-joined. + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + expect(host.restoreInputText).toHaveBeenLastCalledWith('hello\nworld'); + }); + + it('hands the stashed input back when the session switched during the fetch', async () => { + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + expect(controller.maybeInterceptOnSubmit('hello')).toBe(true); + (host as unknown as { session: unknown }).session = { id: 's2' }; + await flush(); + vi.restoreAllMocks(); + + expect(host.restoreInputText).toHaveBeenCalledWith('hello'); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('releases instead of mounting when a foreground operation started during the fetch', async () => { + getMock.mockResolvedValue(CONFIG); + const { host, state } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + expect(controller.maybeInterceptOnSubmit('hello')).toBe(true); + // A foreground operation (turn / /compact) kicked off mid-fetch. + state.appState.streamingPhase = 'waiting'; + await flush(); + vi.restoreAllMocks(); + + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).toHaveBeenCalledWith('hello', undefined); + }); + + it('intercepts and shows the dialog when all conditions hold', () => { + peekMock.mockReturnValue(CONFIG); + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + + expect(controller.maybeInterceptOnSubmit('hello')).toBe(true); + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + expect(host.track).toHaveBeenCalledWith( + 'cache_hint_shown', + expect.objectContaining({ scene: 'idle', model: 'k2' }), + ); + vi.restoreAllMocks(); + }); + + it('does not advance the cache baseline at turn begin (the prompt may fail pre-model)', () => { + peekMock.mockReturnValue(CONFIG); + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + // A send begins a turn but fails before any model request — the expired + // baseline must survive, so the retry still intercepts. + controller.onTurnBegin(); + expect(controller.maybeInterceptOnSubmit('hello')).toBe(true); + vi.restoreAllMocks(); + }); + + it('does not intercept twice in the same idle cycle', () => { + peekMock.mockReturnValue(CONFIG); + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + expect(controller.maybeInterceptOnSubmit('hello')).toBe(true); + expect(controller.maybeInterceptOnSubmit('again')).toBe(false); + vi.restoreAllMocks(); + }); + + it('resends the stashed input on continue', async () => { + peekMock.mockReturnValue(CONFIG); + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + controller.maybeInterceptOnSubmit('hello'); + vi.restoreAllMocks(); + + const dialog = (host.mountEditorReplacement as ReturnType).mock.calls[0]![0] as { + handleInput: (data: string) => void; + }; + dialog.handleInput('\u001B[B'); // down → new + dialog.handleInput('\u001B[B'); // down → continue + dialog.handleInput('\r'); + await flush(); + expect(host.sendNormalUserInput).toHaveBeenCalledWith('hello', undefined); + expect(host.track).toHaveBeenCalledWith('cache_hint_action', { + action: 'continue', + scene: 'idle', + }); + }); + + it('restores the input on Esc without sending', async () => { + peekMock.mockReturnValue(CONFIG); + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + controller.maybeInterceptOnSubmit('hello'); + vi.restoreAllMocks(); + + const dialog = (host.mountEditorReplacement as ReturnType).mock.calls[0]![0] as { + handleInput: (data: string) => void; + }; + dialog.handleInput('\u001B'); + await flush(); + expect(host.restoreInputText).toHaveBeenCalledWith('hello'); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('compacts then resends once compaction engages', async () => { + peekMock.mockReturnValue(CONFIG); + const compact = vi.fn(async () => undefined); + const { host, state } = makeHost({ session: { id: 's1', compact } }); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + controller.maybeInterceptOnSubmit('hello'); + vi.restoreAllMocks(); + + const dialog = (host.mountEditorReplacement as ReturnType).mock.calls[0]![0] as { + handleInput: (data: string) => void; + }; + dialog.handleInput('\r'); // compact (default) + // The engine flips isCompacting asynchronously via the started event. + setTimeout(() => { + state.appState.isCompacting = true; + }, 10); + await vi.waitFor(() => { + expect(host.sendNormalUserInput).toHaveBeenCalledWith('hello', undefined); + }); + expect(compact).toHaveBeenCalledWith({}); + }); + + it('starts a new session and resends', async () => { + peekMock.mockReturnValue(CONFIG); + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + controller.maybeInterceptOnSubmit('hello'); + vi.restoreAllMocks(); + + const dialog = (host.mountEditorReplacement as ReturnType).mock.calls[0]![0] as { + handleInput: (data: string) => void; + }; + dialog.handleInput('\u001B[B'); // down → new + dialog.handleInput('\r'); + await flush(); + expect(host.createNewSession).toHaveBeenCalled(); + expect(host.sendNormalUserInput).toHaveBeenCalledWith('hello', undefined); + }); + + it('keeps the input when new-session creation fails', async () => { + peekMock.mockReturnValue(CONFIG); + const { host, state } = makeHost({ createNewSessionFails: true }); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + controller.maybeInterceptOnSubmit('hello'); + vi.restoreAllMocks(); + + const dialog = (host.mountEditorReplacement as ReturnType).mock.calls[0]![0] as { + handleInput: (data: string) => void; + }; + dialog.handleInput('\u001B[B'); + dialog.handleInput('\r'); + await flush(); + expect(state.appState.sessionId).toBe('s1'); + expect(host.restoreInputText).toHaveBeenCalledWith('hello'); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); +}); + +describe('CacheHintController cache-break detection', () => { + const u = (inputCacheRead: number) => ({ + inputOther: 100, + output: 50, + inputCacheRead, + inputCacheCreation: 0, + }); + + it('does not judge the first measured step', () => { + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.noteStepUsage(u(10000)); + expect(host.track).not.toHaveBeenCalled(); + }); + + it('records cache activity on a completed step, even one without usage', () => { + peekMock.mockReturnValue(CONFIG); + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + // 20 min later a step completes — the provider round trip refreshed the + // server-side cache… + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + controller.noteStepUsage(undefined); + // …so a submit right after is fresh and must not be intercepted. + expect(controller.maybeInterceptOnSubmit('hello')).toBe(false); + vi.restoreAllMocks(); + }); + + it('reports a drop beyond the ratio and token gates with both usages', () => { + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.noteStepUsage(u(10000)); + controller.noteStepUsage(u(7000)); + + expect(host.track).toHaveBeenCalledWith( + 'cache_break_detected', + expect.objectContaining({ + prev_model: 'k2', + curr_model: 'k2', + prev_input_cache_read: 10000, + curr_input_cache_read: 7000, + cache_read_drop_ratio: 0.3, + }), + ); + }); + + it('stays quiet within the ratio gate or under the token threshold', () => { + const a = makeHost(); + const controllerA = new CacheHintController(a.host); + controllerA.noteStepUsage(u(100000)); + controllerA.noteStepUsage(u(96000)); // 4% drop — inside the ratio gate + expect(a.host.track).not.toHaveBeenCalled(); + + const b = makeHost(); + const controllerB = new CacheHintController(b.host); + controllerB.noteStepUsage(u(4000)); + controllerB.noteStepUsage(u(2500)); // drop 1500 ≤ 2000 — under the token threshold + expect(b.host.track).not.toHaveBeenCalled(); + }); + + it('skips unmeasured usage without touching the baseline', () => { + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.noteStepUsage(u(10000)); + controller.noteStepUsage(undefined); + controller.noteStepUsage({ inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 }); + controller.noteStepUsage(u(5000)); + expect(host.track).toHaveBeenCalledWith( + 'cache_break_detected', + expect.objectContaining({ prev_input_cache_read: 10000, curr_input_cache_read: 5000 }), + ); + }); + + it('resets the baseline on compaction, but records a mid-session model/effort switch', () => { + const { host, state } = makeHost(); + const controller = new CacheHintController(host); + controller.noteStepUsage(u(10000)); + controller.resetCacheBreakBaseline(); + controller.noteStepUsage(u(100)); // post-compaction drop is expected + expect(host.track).not.toHaveBeenCalled(); + + // A model switch busts the cache key — that drop IS the signal to record. + controller.noteStepUsage(u(10000)); + state.appState.model = 'other-model'; + controller.noteStepUsage(u(100)); + expect(host.track).toHaveBeenCalledWith( + 'cache_break_detected', + expect.objectContaining({ + prev_model: 'k2', + curr_model: 'other-model', + prev_input_cache_read: 10000, + curr_input_cache_read: 100, + }), + ); + }); +}); + +describe('CacheHintController scenario 1 (resume)', () => { + /** maybeShowOnResume awaits the user's choice; dismiss the dialog once mounted. */ + async function showOnResumeAndDismiss( + controller: CacheHintController, + host: CacheHintHost, + ): Promise { + const pending = controller.maybeShowOnResume(); + await vi.waitFor(() => { + expect(host.mountEditorReplacement).toHaveBeenCalled(); + }); + const dialog = (host.mountEditorReplacement as ReturnType).mock.calls[0]![0] as { + handleInput: (data: string) => void; + }; + dialog.handleInput('\u001B'); + await pending; + } + + it('shows the dialog on resume when idle beyond cache_duration', async () => { + getMock.mockResolvedValue(CONFIG); + const session = resumeSession([Date.now() - 1200_000], 150000); + const { host } = makeHost({ session }); + const controller = new CacheHintController(host); + + await showOnResumeAndDismiss(controller, host); + expect(host.track).toHaveBeenCalledWith( + 'cache_hint_shown', + expect.objectContaining({ scene: 'resume' }), + ); + }); + + it('shows at most once per session', async () => { + getMock.mockResolvedValue(CONFIG); + const session = resumeSession([Date.now() - 1200_000], 150000); + const { host } = makeHost({ session }); + const controller = new CacheHintController(host); + + await showOnResumeAndDismiss(controller, host); + await controller.maybeShowOnResume(); + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + }); + + it('falls back to summary.updatedAt when there are no replay records', async () => { + getMock.mockResolvedValue(CONFIG); + const session = resumeSession([], 150000, Date.now() - 1200_000); + const { host } = makeHost({ session }); + const controller = new CacheHintController(host); + + await showOnResumeAndDismiss(controller, host); + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + }); + + it('drops the dialog when the session switched during the config fetch', async () => { + let resolveFetch!: (config: CacheHintConfig) => void; + getMock.mockImplementation( + () => + new Promise((res) => { + resolveFetch = res; + }), + ); + const session = resumeSession([Date.now() - 1200_000], 150000); + const { host } = makeHost({ session }); + const controller = new CacheHintController(host); + + const pending = controller.maybeShowOnResume(); + await flush(); // let the fetch start (resolveFetch gets assigned) + (host as unknown as { session: unknown }).session = { id: 'other-session' }; + resolveFetch(CONFIG); + await pending; + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + + it('drops the dialog when a turn started during the config fetch', async () => { + let resolveFetch!: (config: CacheHintConfig) => void; + getMock.mockImplementation( + () => + new Promise((res) => { + resolveFetch = res; + }), + ); + const session = resumeSession([Date.now() - 1200_000], 150000); + const { host, state } = makeHost({ session }); + const controller = new CacheHintController(host); + + const pending = controller.maybeShowOnResume(); + await flush(); + // The user sent the first prompt while the fetch was in flight. + state.appState.streamingPhase = 'waiting'; + resolveFetch(CONFIG); + await pending; + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + + it('re-evaluates against fresh activity when a turn completed during the config fetch', async () => { + let resolveFetch!: (config: CacheHintConfig) => void; + getMock.mockImplementation( + () => + new Promise((res) => { + resolveFetch = res; + }), + ); + // Idle long past the window when the resume check started… + const session = resumeSession([Date.now() - 1200_000], 150000); + const { host } = makeHost({ session }); + const controller = new CacheHintController(host); + + const pending = controller.maybeShowOnResume(); + await flush(); // let the fetch start (resolveFetch gets assigned) + // …but the user's first prompt ran to completion while the config was in + // flight, refreshing the server-side cache — the stale replay timestamp + // must not trigger the dialog. + controller.recordActivity(); + resolveFetch(CONFIG); + await pending; + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + + it('ignores local-only state records when computing the resume idle time', async () => { + getMock.mockResolvedValue(CONFIG); + const oldMessage = { type: 'message', time: Date.now() - 1200_000 }; + const recentStateRecord = { type: 'permission_updated', time: Date.now() - 5000 }; + const session = { + id: 's1', + summary: { updatedAt: 0 }, + getResumeState: () => ({ + agents: { + main: { replay: [oldMessage, recentStateRecord], context: { tokenCount: 150000 } }, + }, + }), + }; + const { host } = makeHost({ session }); + const controller = new CacheHintController(host); + + // The recent permission change must not mask the expired cache. + await showOnResumeAndDismiss(controller, host); + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + }); + + it('skips when the config cannot be resolved', async () => { + const session = resumeSession([Date.now() - 1200_000], 150000); + const { host } = makeHost({ session }); + const controller = new CacheHintController(host); + + await controller.maybeShowOnResume(); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + + it('skips small sessions below the token threshold', async () => { + getMock.mockResolvedValue(CONFIG); + const session = resumeSession([Date.now() - 1200_000], 50_000); + const { host } = makeHost({ session }); + const controller = new CacheHintController(host); + + await controller.maybeShowOnResume(); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + + it('seeds the activity baseline when the resume check skips inside the cache window', async () => { + getMock.mockResolvedValue(CONFIG); + peekMock.mockReturnValue(CONFIG); + const now = Date.now(); + // 9 minutes into a 10-minute cache window — nothing to show on resume. + const session = resumeSession([now - 540_000], 150000); + const { host } = makeHost({ session }); + const controller = new CacheHintController(host); + + await controller.maybeShowOnResume(); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + + // Two minutes later the window has expired; the seeded baseline lets the + // idle-submit path catch it instead of waving the prompt through. + vi.spyOn(Date, 'now').mockReturnValue(now + 660_000); + expect(controller.maybeInterceptOnSubmit('hello')).toBe(true); + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + expect(host.track).toHaveBeenCalledWith( + 'cache_hint_shown', + expect.objectContaining({ scene: 'idle' }), + ); + vi.restoreAllMocks(); + }); +}); 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 new file mode 100644 index 000000000..86531df8f --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-compaction.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; +import { getBuiltInPalette } from '#/tui/theme'; + +function makeHost() { + const host = { + state: { + appState: { + sessionId: 's1', + streamingPhase: 'waiting', + isCompacting: true, + model: 'kimi-model', + permissionMode: 'auto', + }, + queuedMessages: [], + queuedMessageDispatchPending: false, + theme: { palette: getBuiltInPalette('dark') }, + toolOutputExpanded: false, + todoPanel: { getTodos: vi.fn(() => []) }, + transcriptContainer: { addChild: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session: { id: 's1' }, + aborted: false, + sessionEventUnsubscribe: undefined, + streamingUI: { + setTurnId: vi.fn(), + flushNow: vi.fn(), + resetToolUi: vi.fn(), + finalizeTurn: vi.fn(), + hasActiveTurn: vi.fn(() => false), + hasThinkingDraft: vi.fn(() => false), + flushThinkingToTranscript: vi.fn(), + appendAssistantDelta: vi.fn(), + scheduleFlush: vi.fn(), + beginCompaction: vi.fn(), + endCompaction: vi.fn(), + cancelCompaction: vi.fn(), + }, + requireSession: vi.fn(), + setAppState: vi.fn((patch: Record) => + Object.assign(host.state.appState, patch), + ), + patchLivePane: vi.fn(), + resetLivePane: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + showNotice: vi.fn(), + track: vi.fn(), + recordSessionActivity: vi.fn(), + noteStepUsage: vi.fn(), + noteCompactionFinished: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), + appendTranscriptEntry: vi.fn(), + sendNormalUserInput: vi.fn(), + sendQueuedMessage: vi.fn(), + shiftQueuedMessage: vi.fn(), + btwPanelController: { routeEvent: vi.fn(() => false) }, + tasksBrowserController: {}, + }; + return { host: host as any }; +} + +const compactionCompleted = { + type: 'compaction.completed', + sessionId: 's1', + agentId: 'main', + result: { summary: 'summary', tokensBefore: 100, tokensAfter: 10, compactedCount: 1 }, +} as const; + +const compactionCancelled = { + type: 'compaction.cancelled', + sessionId: 's1', + agentId: 'main', +} as const; + +describe('SessionEventHandler compaction cache bookkeeping', () => { + it('records activity and resets the cache-break baseline after a completed compaction', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(compactionCompleted, vi.fn()); + expect(host.recordSessionActivity).toHaveBeenCalledOnce(); + expect(host.noteCompactionFinished).toHaveBeenCalledOnce(); + }); + + it('keeps both baselines after a cancelled compaction (context was not cut)', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(compactionCancelled, vi.fn()); + expect(host.noteCompactionFinished).not.toHaveBeenCalled(); + expect(host.recordSessionActivity).not.toHaveBeenCalled(); + }); +}); 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 8b9d5fbdf..6a0bcdd33 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 @@ -86,6 +86,9 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) { showStatus: vi.fn(), showNotice: vi.fn(), track: vi.fn(), + recordSessionActivity: vi.fn(), + noteStepUsage: vi.fn(), + noteCompactionFinished: vi.fn(), mountEditorReplacement: vi.fn(), restoreEditor: vi.fn(), restoreInputText: vi.fn(), 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 3220d1b91..882d79e4e 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 @@ -47,6 +47,9 @@ function makeHost() { showNotice: vi.fn(), updateActivityPane: vi.fn(), track: vi.fn(), + recordSessionActivity: vi.fn(), + noteStepUsage: vi.fn(), + noteCompactionFinished: vi.fn(), mountEditorReplacement: vi.fn(), restoreEditor: vi.fn(), restoreInputText: vi.fn(), diff --git a/apps/kimi-code/test/tui/utils/cache-hint.test.ts b/apps/kimi-code/test/tui/utils/cache-hint.test.ts new file mode 100644 index 000000000..894eea36c --- /dev/null +++ b/apps/kimi-code/test/tui/utils/cache-hint.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import type { CacheHintConfig } from '#/utils/cache-hint-config'; +import { evaluateCacheHint, formatIdleDuration } from '#/tui/utils/cache-hint'; + +const CONFIG: CacheHintConfig = { + version: 1, + config: { + k3: { min_tokens_to_hint: 100000, cache_duration: 600 }, + }, +}; + +const NOW = 1_800_000_000_000; +const IDLE_BEYOND_TTL = NOW - 601_000; // 601s > 600s cache_duration + +function input(overrides: Partial[0]> = {}) { + return { + now: NOW, + lastActiveAt: IDLE_BEYOND_TTL, + totalTokens: 150000, + modelId: 'k3', + config: CONFIG, + dismissed: false, + ...overrides, + }; +} + +describe('evaluateCacheHint', () => { + it('hints when idle exceeds cache_duration and tokens clear the threshold', () => { + expect(evaluateCacheHint(input())).toEqual({ + kind: 'hint', + idleSeconds: 601, + totalTokens: 150000, + }); + }); + + it('skips when dismissed', () => { + expect(evaluateCacheHint(input({ dismissed: true })).kind).toBe('skip'); + }); + + it.each([ + ['config', { config: undefined }], + ['modelId', { modelId: undefined }], + ['lastActiveAt', { lastActiveAt: undefined }], + ['totalTokens', { totalTokens: undefined }], + ] as const)('skips when %s is missing', (_name, overrides) => { + expect(evaluateCacheHint(input(overrides)).kind).toBe('skip'); + }); + + it('skips when the model is not in the config', () => { + expect(evaluateCacheHint(input({ modelId: 'unknown-model' })).kind).toBe('skip'); + }); + + it('skips at exactly cache_duration (strictly-greater rule)', () => { + expect( + evaluateCacheHint(input({ lastActiveAt: NOW - 600_000 })).kind, + ).toBe('skip'); + }); + + it('skips below the token threshold', () => { + expect(evaluateCacheHint(input({ totalTokens: 99999 })).kind).toBe('skip'); + }); + + it('skips on clock skew (negative idle)', () => { + expect(evaluateCacheHint(input({ lastActiveAt: NOW + 60_000 })).kind).toBe('skip'); + }); +}); + +describe('formatIdleDuration', () => { + it.each([ + [45 * 60, '45m'], + [60 * 60, '1h'], + [(3 * 60 + 20) * 60, '3h 20m'], + [24 * 60 * 60, '1d'], + [(2 * 24 + 4) * 60 * 60, '2d 4h'], + [(26 * 24 + 22) * 60 * 60, '26d 22h'], + ])('formats %ss as %s', (seconds, expected) => { + expect(formatIdleDuration(seconds)).toBe(expected); + }); +}); diff --git a/apps/kimi-code/test/utils/client-configs.test.ts b/apps/kimi-code/test/utils/client-configs.test.ts new file mode 100644 index 000000000..f97effa8f --- /dev/null +++ b/apps/kimi-code/test/utils/client-configs.test.ts @@ -0,0 +1,356 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + fetchClientConfig, + getClientConfig, + peekClientConfig, + resetClientConfigCache, +} from '#/utils/client-configs'; +import { z } from 'zod'; + +const configSchema = z.object({ + version: z.literal(1), + config: z.record(z.string(), z.object({ min_tokens_to_hint: z.number(), cache_duration: z.number() })), +}); + +const CONFIG = { + version: 1, + config: { k3: { min_tokens_to_hint: 200000, cache_duration: 600 } }, +}; + +const ENVELOPE = { name: 'estimated_cache_duration', config: CONFIG }; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +afterEach(() => { + resetClientConfigCache(); +}); + +describe('fetchClientConfig', () => { + it('POSTs the config name and unwraps the envelope', async () => { + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + + const result = await fetchClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + }); + + expect(result).toEqual(CONFIG); + expect(fetchImpl).toHaveBeenCalledWith( + expect.stringContaining('/client_configs'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ name: 'estimated_cache_duration' }), + }), + ); + }); + + it('sends the bearer token when provided, anonymous otherwise', async () => { + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + + await fetchClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + accessToken: 'tok', + }); + expect(fetchImpl).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ authorization: 'Bearer tok' }), + }), + ); + + await fetchClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + }); + expect(fetchImpl).toHaveBeenLastCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.not.objectContaining({ authorization: expect.anything() }), + }), + ); + }); + + it('returns undefined on non-OK responses', async () => { + const fetchImpl = vi.fn(async () => jsonResponse('no', 503)); + + await expect( + fetchClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + }), + ).resolves.toBeUndefined(); + }); + + it('returns undefined when the payload fails the caller schema', async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ name: 'estimated_cache_duration', config: { version: 2, config: {} } }), + ); + + await expect( + fetchClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + }), + ).resolves.toBeUndefined(); + }); + + it('returns undefined when the envelope name does not match', async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ name: 'some_other_config', config: CONFIG }), + ); + + await expect( + fetchClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + }), + ).resolves.toBeUndefined(); + }); + + it('returns undefined when fetch throws', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('offline'); + }); + + await expect( + fetchClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + }), + ).resolves.toBeUndefined(); + }); +}); + +describe('getClientConfig', () => { + it('serves the in-process cache within a day', async () => { + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + const now = Date.now(); + + const first = await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + now, + cacheFile: null, + }); + const second = await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + now: now + 60_000, + cacheFile: null, + }); + + expect(first).toEqual(CONFIG); + expect(second).toEqual(CONFIG); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('refetches when the cache is older than a day', async () => { + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + const now = Date.now(); + + await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + now, + cacheFile: null, + }); + const result = await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + now: now + 25 * 60 * 60 * 1000, + cacheFile: null, + }); + + expect(result).toEqual(CONFIG); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it('caches each config name independently', async () => { + const other = { name: 'other_config', config: CONFIG }; + const fetchImpl = vi.fn(async (url: unknown, init?: { body?: string }) => + jsonResponse(init?.body?.includes('other') ? other : ENVELOPE), + ); + const now = Date.now(); + + await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + now, + cacheFile: null, + }); + const second = await getClientConfig('other_config', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + now, + cacheFile: null, + }); + + expect(second).toEqual(CONFIG); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it('resolves to undefined when the refetch fails', async () => { + const fetchImpl = vi.fn(async () => jsonResponse('no', 500)); + + await expect( + getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + cacheFile: null, + }), + ).resolves.toBeUndefined(); + }); +}); + +describe('peekClientConfig', () => { + it('returns the cached config only while fresh', async () => { + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + const now = Date.now(); + await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + now, + cacheFile: null, + }); + + expect(peekClientConfig('estimated_cache_duration', configSchema, now + 60_000)).toEqual(CONFIG); + expect( + peekClientConfig('estimated_cache_duration', configSchema, now + 25 * 60 * 60 * 1000), + ).toBeUndefined(); + }); + + it('returns undefined for a config that was never fetched', () => { + expect(peekClientConfig('estimated_cache_duration', configSchema)).toBeUndefined(); + }); +}); + +describe('getClientConfig disk cache', () => { + let dir: string; + let file: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'client-configs-')); + file = join(dir, 'estimated_cache_duration.json'); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('serves a fresh disk entry without network and warms the in-process cache', async () => { + const now = Date.now(); + await writeFile(file, JSON.stringify({ version: 1, fetchedAt: now, config: CONFIG })); + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + + const result = await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + now: now + 60_000, + cacheFile: file, + }); + + expect(result).toEqual(CONFIG); + expect(fetchImpl).not.toHaveBeenCalled(); + // The in-process layer was warmed with the original fetch time. + expect(peekClientConfig('estimated_cache_duration', configSchema, now + 60_000)).toEqual(CONFIG); + }); + + it('serves the disk entry after the in-process cache is dropped (restart)', async () => { + const now = Date.now(); + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + now, + cacheFile: file, + }); + + resetClientConfigCache(); + const offline = vi.fn(async (): Promise => { + throw new Error('offline'); + }); + const result = await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: offline as typeof fetch, + now: now + 60_000, + cacheFile: file, + }); + + expect(result).toEqual(CONFIG); + expect(offline).not.toHaveBeenCalled(); + }); + + it('keeps the original fetch time when warming from disk (no TTL extension)', async () => { + const now = Date.now(); + await writeFile( + file, + JSON.stringify({ version: 1, fetchedAt: now - 23 * 60 * 60 * 1000, config: CONFIG }), + ); + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + + // 23h old on disk: still fresh. + await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + now, + cacheFile: file, + }); + // 2h later (25h since the actual fetch): the warmed entry must be stale. + expect(peekClientConfig('estimated_cache_duration', configSchema, now + 2 * 60 * 60 * 1000)).toBeUndefined(); + }); + + it('refetches and rewrites the file when the disk entry is stale', async () => { + const now = Date.now(); + await writeFile( + file, + JSON.stringify({ version: 1, fetchedAt: now - 25 * 60 * 60 * 1000, config: CONFIG }), + ); + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + + const result = await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + now, + cacheFile: file, + }); + + expect(result).toEqual(CONFIG); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const onDisk = JSON.parse(await readFile(file, 'utf-8')) as { fetchedAt: number }; + expect(onDisk.fetchedAt).toBe(now); + }); + + it('treats a malformed cache file as missing', async () => { + await writeFile(file, 'not json'); + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + + const result = await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + cacheFile: file, + }); + + expect(result).toEqual(CONFIG); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('ignores a disk entry whose payload fails the caller schema', async () => { + await writeFile( + file, + JSON.stringify({ version: 1, fetchedAt: Date.now(), config: { version: 2 } }), + ); + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + + const result = await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + cacheFile: file, + }); + + expect(result).toEqual(CONFIG); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('still resolves when the cache file cannot be written', async () => { + // The parent path is a regular file, so mkdir for the cache file fails. + const blocker = join(dir, 'blocker'); + await writeFile(blocker, 'x'); + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + + const result = await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + cacheFile: join(blocker, 'nested', 'config.json'), + }); + + expect(result).toEqual(CONFIG); + }); +}); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 023f11202..27f5f03f3 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -429,6 +429,7 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c | --- | --- | --- | --- | | `theme` | `string` | `auto` | Color theme: `auto` (follow the terminal), `dark`, `light`, or the name of a [custom theme](../customization/themes.md) | | `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` | Show a dialog when resuming a long-idle session or submitting after a long idle stretch, warning that the context cache has likely expired and offering to compact or start a new session (v2 engine only) | | `[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` | @@ -440,6 +441,7 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c # ~/.kimi-code/tui.toml theme = "auto" # "auto" | "dark" | "light" | custom theme name 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 [editor] command = "" # empty uses $VISUAL / $EDITOR diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 02f8512fe..f102efba0 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -429,6 +429,7 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod | --- | --- | --- | --- | | `theme` | `string` | `auto` | 配色主题:`auto`(跟随终端)、`dark`、`light`,或[自定义主题](../customization/themes.md)的名字 | | `disable_paste_burst` | `boolean` | `false` | 禁用非 bracketed paste 的粘贴突发兜底;默认开启,避免快速多行粘贴被逐行提交 | +| `cache_expiry_hint` | `boolean` | `true` | resume 长时间未活动的会话、或长时间空闲后发送消息时,若上下文缓存可能已过期则弹出提醒,可选择先压缩或新建会话(仅 v2 引擎) | | `[editor].command` | `string` | `""` | 编写长输入用的外部编辑器命令;留空则回退到 `$VISUAL` / `$EDITOR` | | `[notifications].enabled` | `boolean` | `true` | 是否发送桌面通知 | | `[notifications].notification_condition` | `string` | `unfocused` | 何时通知:`unfocused`(仅终端失去焦点时)或 `always`(总是) | @@ -440,6 +441,7 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod # ~/.kimi-code/tui.toml theme = "auto" # "auto" | "dark" | "light" | 自定义主题名 disable_paste_burst = false # true 表示禁用非 bracketed paste 的粘贴突发兜底 +cache_expiry_hint = true # false 表示关闭 resume / 空闲提交时的"缓存已过期"提醒弹窗 [editor] command = "" # 留空则使用 $VISUAL / $EDITOR