diff --git a/.changeset/tui-retry-progress.md b/.changeset/tui-retry-progress.md new file mode 100644 index 00000000000..76143cb0bbe --- /dev/null +++ b/.changeset/tui-retry-progress.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show retry progress in the loading indicator when a model request fails and is retried, with the attempt count and a detail line for the provider error. diff --git a/apps/kimi-code/src/tui/components/panes/activity-pane.ts b/apps/kimi-code/src/tui/components/panes/activity-pane.ts index 22e6f3bc5c8..43f9ece4126 100644 --- a/apps/kimi-code/src/tui/components/panes/activity-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/activity-pane.ts @@ -1,6 +1,8 @@ -import { Container, Spacer } from '@moonshot-ai/pi-tui'; +import { Container, Spacer, Text } from '@moonshot-ai/pi-tui'; import type { MoonLoader } from '#/tui/components/chrome/moon-loader'; +import { ACTIVITY_DETAIL_INDENT } from '#/tui/constant/rendering'; +import { currentTheme } from '#/tui/theme'; export type ActivityPaneMode = 'hidden' | 'waiting' | 'thinking' | 'composing' | 'tool'; @@ -8,6 +10,12 @@ export interface ActivityPaneOptions { readonly mode: ActivityPaneMode; readonly spinner?: MoonLoader; readonly tip?: string; + /** Extra dim line rendered under the spinner (e.g. step retry error detail). */ + readonly detail?: string; +} + +export function formatActivitySpinnerTip(tip: string | undefined): string { + return tip === undefined || tip.length === 0 ? '' : ` · Tip: ${tip}`; } export class ActivityPaneComponent extends Container { @@ -22,10 +30,11 @@ export class ActivityPaneComponent extends Container { options.spinner !== undefined ) { this.addChild(new Spacer(1)); - if (options.tip) { - options.spinner.setTip(` · Tip: ${options.tip}`); - } + options.spinner.setTip(formatActivitySpinnerTip(options.tip)); this.addChild(options.spinner); + if (options.detail !== undefined && options.detail.length > 0) { + this.addChild(new Text(currentTheme.fg('textDim', options.detail), ACTIVITY_DETAIL_INDENT, 0)); + } } } diff --git a/apps/kimi-code/src/tui/constant/rendering.ts b/apps/kimi-code/src/tui/constant/rendering.ts index a6a1c6b7d4b..d3de252f3dd 100644 --- a/apps/kimi-code/src/tui/constant/rendering.ts +++ b/apps/kimi-code/src/tui/constant/rendering.ts @@ -12,6 +12,15 @@ export const RESULT_PREVIEW_LINES = 3; export const THINKING_PREVIEW_LINES = 2; export const COMMAND_PREVIEW_LINES = 10; +// Cap on the step-retry detail line under the waiting spinner, so huge +// provider error bodies (occasionally whole HTML error pages) can't flood +// the activity pane. +export const RETRY_DETAIL_MAX_CHARS = 160; +// Left indent (cells) for the detail line under the waiting spinner, aligning +// it with the label text: 1 (the spinner Text's own paddingX) + 2 (moon +// frame) + 1 (space between frame and label). +export const ACTIVITY_DETAIL_INDENT = 4; + // Retention caps for the subagent activity store (background-agent detail // view): only the most recent steps are kept, older steps are discarded // whole, and per-step text / per-call output keep bounded tails. 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 3735465089a..9cb1029bdc5 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -27,6 +27,7 @@ import type { TurnStartedEvent, TurnStepCompletedEvent, TurnStepInterruptedEvent, + TurnStepRetryingEvent, TurnStepStartedEvent, TokenUsage, WarningEvent, @@ -166,6 +167,7 @@ export class SessionEventHandler { private queuedGoalPromotionPending = false; private queuedGoalPromotionInFlight = false; private queuedGoalPromotionTimer: ReturnType | undefined; + private stepRetryAttemptTimer: ReturnType | undefined; resetRuntimeState(): void { this.backgroundTasks.clear(); @@ -184,6 +186,7 @@ export class SessionEventHandler { this.queuedGoalPromotionPending = false; this.queuedGoalPromotionInFlight = false; this.clearQueuedGoalPromotionTimer(); + this.clearStepRetryAttemptTimer(); this.stopAllMcpServerStatusSpinners(); } @@ -267,7 +270,7 @@ export class SessionEventHandler { case 'turn.step.started': this.handleStepBegin(event); break; case 'turn.step.interrupted': this.handleStepInterrupted(event); break; case 'turn.step.completed': this.handleStepCompleted(event); break; - case 'turn.step.retrying': break; + case 'turn.step.retrying': this.handleStepRetrying(event); break; case 'tool.progress': this.handleToolProgress(event); break; case 'shell.output': this.host.handleShellOutput(event); break; case 'shell.started': this.host.handleShellStarted(event); break; @@ -354,6 +357,7 @@ export class SessionEventHandler { private handleTurnEnd(event: TurnEndedEvent, sendQueued: (item: QueuedMessage) => void): void { this.host.streamingUI.flushNow(); + this.clearStepRetry(); if (event.reason === 'cancelled') { this.markActiveAgentSwarmsCancelled(); } @@ -414,6 +418,7 @@ export class SessionEventHandler { private handleStepCompleted(event: TurnStepCompletedEvent): void { this.host.streamingUI.flushNow(); + this.clearStepRetry(); this.host.noteStepUsage(event.usage); this.maybeShowDebugTiming(event); @@ -442,6 +447,48 @@ export class SessionEventHandler { this.host.showNotice(title, detail); } + private handleStepRetrying(event: TurnStepRetryingEvent): void { + // The failure may arrive mid-stream, after thinking/assistant deltas have + // parked the pane in `thinking`/`composing` — drive it back to waiting so + // the retry label and detail actually render during the backoff. + this.host.patchLivePane({ mode: 'waiting' }); + this.host.setAppState({ + streamingPhase: 'waiting', + stepRetry: { + nextAttempt: event.nextAttempt, + maxAttempts: event.maxAttempts, + delayMs: event.delayMs, + errorName: event.errorName, + errorMessage: event.errorMessage, + statusCode: event.statusCode, + phase: 'backoff', + }, + }); + // Both engines sleep for `delayMs` before the next attempt runs, but only + // v2 re-emits `turn.step.started` for it — flip the phase on a timer so the + // stale countdown drops on the legacy engine too. + this.clearStepRetryAttemptTimer(); + this.stepRetryAttemptTimer = setTimeout(() => { + this.stepRetryAttemptTimer = undefined; + const retry = this.host.state.appState.stepRetry; + if (retry === null) return; + this.host.setAppState({ stepRetry: { ...retry, phase: 'attempt' } }); + }, event.delayMs); + } + + private clearStepRetry(): void { + this.clearStepRetryAttemptTimer(); + if (this.host.state.appState.stepRetry === null) return; + this.host.setAppState({ stepRetry: null }); + } + + clearStepRetryAttemptTimer(): void { + if (this.stepRetryAttemptTimer !== undefined) { + clearTimeout(this.stepRetryAttemptTimer); + this.stepRetryAttemptTimer = undefined; + } + } + private maybeShowDebugTiming(event: TurnStepCompletedEvent): void { if (process.env['KIMI_CODE_DEBUG'] !== '1') return; const text = formatStepDebugTiming(event); @@ -469,6 +516,7 @@ export class SessionEventHandler { private handleStepInterrupted(event: TurnStepInterruptedEvent): void { this.host.streamingUI.flushNow(); + this.clearStepRetry(); this.host.streamingUI.resetToolUi(); this.host.streamingUI.finalizeLiveTextBuffers('idle'); const reason = event.reason; @@ -621,6 +669,7 @@ export class SessionEventHandler { private handleToolResult(event: ToolResultEvent): void { const { streamingUI } = this.host; streamingUI.flushNow(); + this.clearStepRetry(); const resultData: ToolResultBlockData = { tool_call_id: event.toolCallId, output: serializeToolResultOutput(event.output), diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index c1254bce89c..2eceb579f6a 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -136,6 +136,7 @@ import { type LoginProgressSpinnerHandle, type QueuedMessage, type SteerInputItem, + type StepRetryState, type TranscriptEntry, type TUIStartupOptions, type TUIStartupState, @@ -152,6 +153,7 @@ import { startupTrace } from '#/utils/startup-trace'; import { REPLAY_TURN_LIMIT } from './utils/message-replay'; import { hasPatchChanges } from './utils/object-patch'; import { sessionRowsForPicker } from './utils/session-picker-rows'; +import { formatStepRetryDetail, formatStepRetryLabel } from './utils/step-retry'; import { formatBashOutputForDisplay } from './utils/shell-output'; import { thinkingEffortFromConfig } from './utils/thinking-config'; import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup'; @@ -210,6 +212,10 @@ function loadingTipKind(mode: EffectiveActivityPaneMode): LoadingTipKind | undef return undefined; } +function waitingSpinnerLabel(retry: StepRetryState | null): string { + return retry === null ? '' : formatStepRetryLabel(retry); +} + function sameStringArrays(a: readonly string[], b: readonly string[]): boolean { return a.length === b.length && a.every((value, index) => value === b[index]); } @@ -241,6 +247,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { isReplaying: false, streamingPhase: 'idle', streamingStartTime: 0, + stepRetry: null, theme: input.tuiConfig.theme, version: input.version, editorCommand: input.tuiConfig.editorCommand, @@ -977,6 +984,7 @@ export class KimiTUI { await this.harness.close(); } finally { this.sessionEventHandler.stopAllMcpServerStatusSpinners(); + this.sessionEventHandler.clearStepRetryAttemptTimer(); this.uninstallRainbowDance(); try { await this.state.terminal.drainInput(); @@ -2712,7 +2720,13 @@ export class KimiTUI { } this.syncTerminalProgress(this.shouldShowTerminalProgress(effectiveMode)); const placeSpinnerInAgentSwarm = this.shouldPlaceActivitySpinnerInAgentSwarm(effectiveMode); - const activityModeKey = `${effectiveMode}:${placeSpinnerInAgentSwarm ? 'swarm' : 'pane'}`; + // Carry the retry state in the mode key so an incoming/cleared + // `turn.step.retrying` rebuilds the waiting pane with fresh label and + // detail instead of hitting the cached-pane early return below. + const retry = effectiveMode === 'waiting' ? this.state.appState.stepRetry : null; + const retryKey = + retry === null ? '' : `${formatStepRetryLabel(retry)}|${formatStepRetryDetail(retry)}`; + const activityModeKey = `${effectiveMode}:${placeSpinnerInAgentSwarm ? 'swarm' : 'pane'}:${retryKey}`; if ( activityModeKey === this.lastActivityMode && @@ -2734,14 +2748,16 @@ export class KimiTUI { this.state.ui.requestRender(); return; case 'waiting': { - const spinner = this.ensureActivitySpinner('moon'); + const stepRetry = this.state.appState.stepRetry; + const spinner = this.ensureActivitySpinner('moon', waitingSpinnerLabel(stepRetry)); this.syncAgentSwarmActivitySpinner(placeSpinnerInAgentSwarm ? spinner : undefined); if (placeSpinnerInAgentSwarm) break; this.state.activityContainer.addChild( new ActivityPaneComponent({ mode: 'waiting', spinner, - tip: this.currentLoadingTip?.tip, + tip: stepRetry === null ? this.currentLoadingTip?.tip : undefined, + detail: stepRetry === null ? undefined : formatStepRetryDetail(stepRetry), }), ); break; diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index d423aec7053..d1e3341d87d 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -64,6 +64,8 @@ export interface AppState { isReplaying: boolean; streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing' | 'shell'; streamingStartTime: number; + /** Pending step retry backoff (fed by `turn.step.retrying`); null when no retry is in flight. */ + stepRetry: StepRetryState | null; theme: ThemeName; version: string; editorCommand: string | null; @@ -85,6 +87,24 @@ export interface AppState { banner?: BannerState | null; } +export interface StepRetryState { + /** Upcoming attempt number (1-based). */ + nextAttempt: number; + maxAttempts: number; + /** Backoff wait before the next attempt, in milliseconds. */ + delayMs: number; + errorName: string; + errorMessage: string; + /** HTTP status code for `APIStatusError`; undefined for network/timeout failures. */ + statusCode?: number; + /** + * `backoff` while sleeping before the next attempt (label shows the + * countdown); `attempt` once the `delayMs` backoff has elapsed and the next + * attempt is running — the countdown has expired by then and is dropped. + */ + phase: 'backoff' | 'attempt'; +} + export interface ToolCallBlockData { id: string; name: string; diff --git a/apps/kimi-code/src/tui/utils/step-retry.ts b/apps/kimi-code/src/tui/utils/step-retry.ts new file mode 100644 index 00000000000..34a79887863 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/step-retry.ts @@ -0,0 +1,19 @@ +import { RETRY_DETAIL_MAX_CHARS } from '../constant/rendering'; +import type { StepRetryState } from '../types'; + +export function formatStepRetryLabel(retry: StepRetryState): string { + const base = `Retrying (${retry.nextAttempt}/${retry.maxAttempts}) · ${retry.errorName}`; + if (retry.phase === 'attempt') return base; + const delaySeconds = Math.max(1, Math.ceil(retry.delayMs / 1000)); + return `${base} · in ${delaySeconds}s`; +} + +/** Detail line under the spinner: status code + provider message, single-line, capped. */ +export function formatStepRetryDetail(retry: StepRetryState): string { + const message = retry.errorMessage.replaceAll(/\s+/g, ' ').trim(); + const code = retry.statusCode === undefined ? '' : String(retry.statusCode); + const detail = [code, message].filter((part) => part.length > 0).join(' · '); + return detail.length > RETRY_DETAIL_MAX_CHARS + ? `${detail.slice(0, RETRY_DETAIL_MAX_CHARS - 1)}…` + : detail; +} diff --git a/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts b/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts index a6be39adfb6..36bd1fcf51d 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts @@ -29,6 +29,7 @@ const baseState: AppState = { isReplaying: false, streamingPhase: 'idle', streamingStartTime: 0, + stepRetry: null, planMode: false, inputMode: 'prompt', swarmMode: false, diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index 2fe6f3e52e6..79abf826e8e 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -48,6 +48,7 @@ const appState: AppState = { isReplaying: false, streamingPhase: 'idle', streamingStartTime: 0, + stepRetry: null, planMode: false, inputMode: 'prompt', swarmMode: false, diff --git a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts index bc1b754fb67..18eef144018 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -25,6 +25,7 @@ const appState: AppState = { isReplaying: false, streamingPhase: 'idle', streamingStartTime: 0, + stepRetry: null, planMode: false, inputMode: 'prompt', swarmMode: false, diff --git a/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts b/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts index 76acd438ce5..314c7a18a86 100644 --- a/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts @@ -28,23 +28,39 @@ function createMockSpinner(initialText = 'working') { describe('ActivityPaneComponent', () => { it('renders waiting loader after a spacer', () => { + const { spinner } = createMockSpinner('loading'); const component = new ActivityPaneComponent({ mode: 'waiting', - spinner: new Text('loading', 0, 0) as never, + spinner, }); expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'loading']); }); it('renders composing spinner after a spacer', () => { + const { spinner } = createMockSpinner('working'); const component = new ActivityPaneComponent({ mode: 'composing', - spinner: new Text('working', 0, 0) as never, + spinner, }); expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'working']); }); + it('renders the detail line under the waiting spinner', () => { + const { spinner } = createMockSpinner('working'); + const component = new ActivityPaneComponent({ + mode: 'waiting', + spinner, + detail: '429 · rate limited', + }); + + const lines = component + .render(80) + .map((line) => line.replaceAll(/\u001B\[[0-9;]*m/g, '').trimEnd()); + expect(lines).toEqual(['', 'working', ' 429 · rate limited']); + }); + it.each(['waiting', 'tool', 'composing'] as const)( 'renders %s spinner with tip after a spacer', (mode) => { diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts new file mode 100644 index 00000000000..a60aa55c636 --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, 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: false, + model: 'kimi-model', + permissionMode: 'auto', + stepRetry: null, + }, + 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(), + setStep: vi.fn(), + flushNow: vi.fn(), + resetToolUi: vi.fn(), + finalizeTurn: vi.fn(), + finalizeLiveTextBuffers: vi.fn(), + completeToolResult: 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 retryingEvent = { + type: 'turn.step.retrying', + sessionId: 's1', + agentId: 'main', + turnId: 1, + step: 1, + failedAttempt: 1, + nextAttempt: 2, + maxAttempts: 10, + delayMs: 4000, + errorName: 'APIStatusError', + errorMessage: 'rate limited', + statusCode: 429, +} as const; + +describe('SessionEventHandler step retry state', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('stores the retry snapshot when a step starts retrying', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + expect(host.state.appState.stepRetry).toEqual({ + nextAttempt: 2, + maxAttempts: 10, + delayMs: 4000, + errorName: 'APIStatusError', + errorMessage: 'rate limited', + statusCode: 429, + phase: 'backoff', + }); + }); + + it('drives the pane back to waiting so mid-stream retries render', () => { + const { host } = makeHost(); + host.state.appState.streamingPhase = 'composing'; + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + expect(host.patchLivePane).toHaveBeenCalledWith({ mode: 'waiting' }); + expect(host.state.appState.streamingPhase).toBe('waiting'); + }); + + it.each([ + [{ type: 'turn.step.completed', turnId: 1, step: 1 }, 'turn.step.completed'], + [ + { type: 'turn.step.interrupted', turnId: 1, step: 1, reason: 'error' }, + 'turn.step.interrupted', + ], + [{ type: 'turn.ended', turnId: 1, reason: 'completed' }, 'turn.ended'], + [ + { type: 'tool.result', turnId: 1, toolCallId: 'tc1', output: 'ok', isError: false }, + 'tool.result', + ], + ])('clears the retry snapshot on %s', (event, _label) => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + expect(host.state.appState.stepRetry).not.toBeNull(); + handler.handleEvent( + { sessionId: 's1', agentId: 'main', ...event } as any, + vi.fn(), + ); + expect(host.state.appState.stepRetry).toBeNull(); + }); + + it('flips to attempt phase once the backoff delay elapses', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + expect(host.state.appState.stepRetry).toMatchObject({ phase: 'backoff' }); + vi.advanceTimersByTime(4000); + expect(host.state.appState.stepRetry).toMatchObject({ nextAttempt: 2, phase: 'attempt' }); + }); + + it('cancels the phase flip when the retry is cleared during the backoff', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + handler.handleEvent( + { + type: 'turn.step.interrupted', + sessionId: 's1', + agentId: 'main', + turnId: 1, + step: 1, + reason: 'error', + } as any, + vi.fn(), + ); + vi.advanceTimersByTime(10_000); + expect(host.state.appState.stepRetry).toBeNull(); + }); + + it('keeps the retry snapshot on turn.step.started (v2 re-emits it per attempt)', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + handler.handleEvent( + { type: 'turn.step.started', sessionId: 's1', agentId: 'main', turnId: 1, step: 1 } as any, + vi.fn(), + ); + expect(host.state.appState.stepRetry).toMatchObject({ nextAttempt: 2, phase: 'backoff' }); + }); + + it('cancels the pending phase flip via clearStepRetryAttemptTimer (TUI shutdown path)', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + handler.clearStepRetryAttemptTimer(); + vi.advanceTimersByTime(10_000); + expect(host.state.appState.stepRetry).toMatchObject({ phase: 'backoff' }); + }); +}); diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index 0899cf07023..8e17cc8f6b3 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -22,6 +22,7 @@ function fakeInitialAppState(): AppState { isReplaying: false, streamingPhase: 'idle', streamingStartTime: 0, + stepRetry: null, theme: 'dark', version: '0.0.0-test', editorCommand: null, diff --git a/apps/kimi-code/test/tui/utils/step-retry.test.ts b/apps/kimi-code/test/tui/utils/step-retry.test.ts new file mode 100644 index 00000000000..9111471c888 --- /dev/null +++ b/apps/kimi-code/test/tui/utils/step-retry.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { RETRY_DETAIL_MAX_CHARS } from '#/tui/constant/rendering'; +import { formatStepRetryDetail, formatStepRetryLabel } from '#/tui/utils/step-retry'; +import type { StepRetryState } from '#/tui/types'; + +function retry(partial: Partial = {}): StepRetryState { + return { + nextAttempt: 2, + maxAttempts: 10, + delayMs: 4000, + errorName: 'APIStatusError', + errorMessage: 'rate limited', + statusCode: 429, + phase: 'backoff', + ...partial, + }; +} + +describe('formatStepRetryLabel', () => { + it('shows attempts, raw error name, and backoff delay', () => { + expect(formatStepRetryLabel(retry())).toBe('Retrying (2/10) · APIStatusError · in 4s'); + }); + + it('drops the stale countdown once the attempt is running', () => { + expect(formatStepRetryLabel(retry({ phase: 'attempt' }))).toBe( + 'Retrying (2/10) · APIStatusError', + ); + }); + + it('rounds sub-second delays up to 1s', () => { + expect(formatStepRetryLabel(retry({ delayMs: 500 }))).toContain('in 1s'); + }); +}); + +describe('formatStepRetryDetail', () => { + it('prefixes the message with the status code', () => { + expect(formatStepRetryDetail(retry())).toBe('429 · rate limited'); + }); + + it('omits the status code for network/timeout failures', () => { + expect( + formatStepRetryDetail( + retry({ errorName: 'APIConnectionError', errorMessage: 'fetch failed', statusCode: undefined }), + ), + ).toBe('fetch failed'); + }); + + it('collapses multi-line error bodies into one line', () => { + expect(formatStepRetryDetail(retry({ errorMessage: 'line one\n\n line two' }))).toBe( + '429 · line one line two', + ); + }); + + it('caps huge error bodies', () => { + const detail = formatStepRetryDetail(retry({ errorMessage: 'x'.repeat(1000) })); + expect(detail.length).toBe(RETRY_DETAIL_MAX_CHARS); + expect(detail.endsWith('…')).toBe(true); + }); + + it('returns the status code alone when the message is empty', () => { + expect(formatStepRetryDetail(retry({ errorMessage: '' }))).toBe('429'); + }); +});