From a57f1b6730f1a97da12607ea419326d95d2b401e Mon Sep 17 00:00:00 2001 From: vinlee19 <1401597760@qq.com> Date: Fri, 17 Jul 2026 23:07:16 +0800 Subject: [PATCH 1/2] feat(tui): show live retry progress in the activity pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine emits turn.step.retrying with full attempt/backoff data, but the interactive TUI dropped the event, so LLM retries (up to 10 attempts over ~3 minutes for sustained 429s) showed only the generic moon spinner and the session appeared frozen. Print mode and the v2 activity view already surface this event; the shell TUI was the only consumer that did not. Add a 'retrying' streaming phase driven by the event: the activity pane spinner shows a warning-colored live label such as "Rate limited (429) · attempt 3/10 · retrying in 12s", counting down on MoonLoader's existing 120ms tick via a new setLabelFn (no extra timer) and degrading to "retrying now…" once the backoff elapses. The failed attempt's partial live output is discarded before the next attempt re-streams (new StreamingUIController.discardStreamingBlock removes the already-rendered block; the existing reset methods only clear buffers). retryStatus is cleared centrally in setAppState on any phase transition out of 'retrying', with defensive clears on the paths that set no phase (empty thinking delta, tool-call start, step completed). Esc during backoff already aborts the sleep; no changes needed there. No transcript writes per retry; behavior is unchanged when no retry occurs. --- .changeset/tui-retry-progress.md | 5 + .../src/tui/components/chrome/moon-loader.ts | 8 + .../src/tui/components/panes/activity-pane.ts | 7 +- .../tui/controllers/session-event-handler.ts | 35 ++++- .../src/tui/controllers/streaming-ui.ts | 14 ++ apps/kimi-code/src/tui/kimi-tui.ts | 50 ++++++- apps/kimi-code/src/tui/types.ts | 16 +- apps/kimi-code/src/tui/utils/retry-status.ts | 47 ++++++ .../test/tui/kimi-tui-message-flow.test.ts | 141 ++++++++++++++++++ apps/kimi-code/test/tui/moon-loader.test.ts | 53 +++++++ .../test/tui/utils/retry-status.test.ts | 113 ++++++++++++++ 11 files changed, 482 insertions(+), 7 deletions(-) create mode 100644 .changeset/tui-retry-progress.md create mode 100644 apps/kimi-code/src/tui/utils/retry-status.ts create mode 100644 apps/kimi-code/test/tui/moon-loader.test.ts create mode 100644 apps/kimi-code/test/tui/utils/retry-status.test.ts diff --git a/.changeset/tui-retry-progress.md b/.changeset/tui-retry-progress.md new file mode 100644 index 0000000000..68a78529dc --- /dev/null +++ b/.changeset/tui-retry-progress.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show live retry progress in the TUI activity pane. When an LLM request fails and is retried, the moon spinner now shows a warning-colored status like `Rate limited (429) · attempt 3/10 · retrying in 12s` with a live countdown, instead of an unchanged generic spinner for the whole backoff window. The failed attempt's partial output is discarded before the next attempt re-streams. diff --git a/apps/kimi-code/src/tui/components/chrome/moon-loader.ts b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts index a9d21452e7..2777189dfc 100644 --- a/apps/kimi-code/src/tui/components/chrome/moon-loader.ts +++ b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts @@ -19,6 +19,7 @@ export class MoonLoader extends Text { private interval: number; private colorFn?: (s: string) => string; private label: string; + private labelFn?: () => string; private displayText = ''; // Inline text used when the spinner is embedded into another line (e.g. the // agent-swarm progress status line). It intentionally excludes the tip: the @@ -64,10 +65,16 @@ export class MoonLoader extends Text { } setLabel(label: string): void { + this.labelFn = undefined; this.label = label; this.updateDisplay(); } + setLabelFn(fn: (() => string) | undefined): void { + this.labelFn = fn; + this.updateDisplay(); + } + setColorFn(colorFn: (s: string) => string): void { this.colorFn = colorFn; this.updateDisplay(); @@ -89,6 +96,7 @@ export class MoonLoader extends Text { } private updateDisplay(): void { + if (this.labelFn !== undefined) this.label = this.labelFn(); const frame = this.frames[this.currentFrame]!; const coloredFrame = this.colorFn ? this.colorFn(frame) : frame; const baseText = this.label ? `${coloredFrame} ${this.label}` : coloredFrame; 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 22e6f3bc5c..139598f1d1 100644 --- a/apps/kimi-code/src/tui/components/panes/activity-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/activity-pane.ts @@ -2,7 +2,7 @@ import { Container, Spacer } from '@moonshot-ai/pi-tui'; import type { MoonLoader } from '#/tui/components/chrome/moon-loader'; -export type ActivityPaneMode = 'hidden' | 'waiting' | 'thinking' | 'composing' | 'tool'; +export type ActivityPaneMode = 'hidden' | 'waiting' | 'thinking' | 'composing' | 'tool' | 'retrying'; export interface ActivityPaneOptions { readonly mode: ActivityPaneMode; @@ -18,7 +18,10 @@ export class ActivityPaneComponent extends Container { this.spinnerRef = options.spinner; if ( - (options.mode === 'waiting' || options.mode === 'tool' || options.mode === 'composing') && + (options.mode === 'waiting' || + options.mode === 'tool' || + options.mode === 'composing' || + options.mode === 'retrying') && options.spinner !== undefined ) { this.addChild(new Spacer(1)); 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 00d79c3d8c..dd29e373fd 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, WarningEvent, } from '@moonshot-ai/kimi-code-sdk'; @@ -51,6 +52,7 @@ import { serializeToolResultOutput, stringValue, } from '../utils/event-payload'; +import { buildRetryStatus } from '../utils/retry-status'; import { readGoalQueue, removeGoalQueueItem, @@ -245,7 +247,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; @@ -367,7 +369,24 @@ export class SessionEventHandler { }); } + private handleStepRetrying(event: TurnStepRetryingEvent): void { + // The failed attempt's partial live output is invalid — the next attempt + // re-streams from scratch. Mirror print mode's discardAssistant() + // (cli/run-prompt.ts:578) instead of committing it to the transcript. The + // TUI renders assistant text live, so discarding also means pulling the + // already-rendered partial block, not just clearing the buffers. + this.host.streamingUI.discardPending(); + this.host.streamingUI.discardStreamingBlock(); + this.host.streamingUI.resetLiveText(); + this.host.streamingUI.resetToolUi(); + this.host.patchLivePane({ mode: 'waiting', pendingApproval: null, pendingQuestion: null }); + this.host.setAppState({ streamingPhase: 'retrying', retryStatus: buildRetryStatus(event) }); + } + private handleStepCompleted(event: TurnStepCompletedEvent): void { + if (this.host.state.appState.streamingPhase === 'retrying') { + this.host.setAppState({ streamingPhase: 'waiting' }); + } this.host.streamingUI.flushNow(); this.maybeShowDebugTiming(event); @@ -453,7 +472,14 @@ export class SessionEventHandler { // moon spinner while no ThinkingComponent is ever created (it needs visible // text), leaving a blank, spinner-less gap until the first real text/tool // token arrives. Keep the moon up until actual thinking text shows up. - if (event.delta.trim().length === 0 && !streamingUI.hasThinkingDraft()) return; + if (event.delta.trim().length === 0 && !streamingUI.hasThinkingDraft()) { + // Encrypted-only thinking never enters `thinking` mode, so clear a lingering + // retry phase here — otherwise the retry label would outlive the retry. + if (state.appState.streamingPhase === 'retrying') { + this.host.setAppState({ streamingPhase: 'waiting' }); + } + return; + } streamingUI.appendThinkingDelta(event.delta); this.host.patchLivePane({ mode: 'idle' }); if (state.appState.streamingPhase !== 'thinking') { @@ -531,6 +557,11 @@ export class SessionEventHandler { pendingApproval: null, pendingQuestion: null, }); + // A tool call can begin without an intervening delta phase; clear a lingering + // retry phase so the retry label does not persist under the tool spinner. + if (this.host.state.appState.streamingPhase === 'retrying') { + this.host.setAppState({ streamingPhase: 'waiting' }); + } } private handleToolCallDelta(event: ToolCallDeltaEvent): void { diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 484b9f02ad..8907d12c9e 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -529,6 +529,20 @@ export class StreamingUIController { this.disposeActiveThinkingComponent(); } + // Drop the in-progress live assistant block instead of finalizing it. Unlike + // resetLiveText (which only clears buffers and the ref), this removes the + // already-rendered component and its transcript entry, so a failed step's + // partial output does not linger above the retried attempt's fresh stream. + discardStreamingBlock(): void { + const block = this._streamingBlock; + if (block === null) return; + const { state } = this.host; + state.transcriptContainer.removeChild(block.component); + state.transcriptEntries = state.transcriptEntries.filter((entry) => entry !== block.entry); + this._streamingBlock = null; + state.ui.requestRender(); + } + resetToolUi(): void { this.pendingToolCallFlushIds.clear(); this.clearFlushTimerIfIdle(); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index a05165ca2b..6624738106 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -131,6 +131,7 @@ import { import { hasDispose, isExpandable } from './utils/component-capabilities'; import { isDeadTerminalError } from './utils/dead-terminal'; import { formatErrorMessage } from './utils/event-payload'; +import { formatRetryLabel } from './utils/retry-status'; import { pickForegroundTasks } from './utils/foreground-task'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; import { extractMediaAttachments, rewriteMediaPlaceholders } from './utils/image-placeholder'; @@ -228,6 +229,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { availableProviders: {}, sessionTitle: null, goal: null, + retryStatus: null, mcpServersSummary: null, banner: undefined, }; @@ -1446,6 +1448,17 @@ export class KimiTUI { } setAppState(patch: Partial): void { + // A phase transition away from `retrying` (without an explicit retryStatus in + // the same patch) clears the stale backoff status centrally, so every exit + // path — delta, tool call, step/turn end — need not remember to null it. + if ( + patch.streamingPhase !== undefined && + patch.streamingPhase !== 'retrying' && + patch.retryStatus === undefined && + (this.state.appState.retryStatus ?? null) !== null + ) { + patch = { ...patch, retryStatus: null }; + } if (!hasPatchChanges(this.state.appState, patch)) return; const additionalDirsChanged = 'additionalDirs' in patch && @@ -2304,8 +2317,16 @@ export class KimiTUI { if ( activityModeKey === this.lastActivityMode && - (effectiveMode === 'waiting' || effectiveMode === 'thinking' || effectiveMode === 'tool') + (effectiveMode === 'waiting' || + effectiveMode === 'thinking' || + effectiveMode === 'tool' || + effectiveMode === 'retrying') ) { + // Re-sync so a fresh retry event (attempt N -> N+1) refreshes the label + // immediately instead of waiting for the next spinner tick. + if (effectiveMode === 'retrying') { + this.applyRetrySpinnerState(); + } if (placeSpinnerInAgentSwarm) { this.syncAgentSwarmActivitySpinner(this.state.activitySpinner?.instance); } @@ -2366,6 +2387,15 @@ export class KimiTUI { ); break; } + case 'retrying': { + const spinner = this.ensureActivitySpinner('moon'); + this.applyRetrySpinnerState(); + this.syncAgentSwarmActivitySpinner(undefined); + this.state.activityContainer.addChild( + new ActivityPaneComponent({ mode: 'retrying', spinner }), + ); + break; + } case 'idle': case 'session': { this.stopActivitySpinner(); @@ -2392,6 +2422,8 @@ export class KimiTUI { // until it finishes, signalling that input is busy / queued. if (streamingPhase === 'shell') return 'waiting'; + if (streamingPhase === 'retrying') return 'retrying'; + if (this.state.livePane.mode === 'idle') { if (streamingPhase === 'thinking' || streamingPhase === 'composing') { return streamingPhase; @@ -2615,7 +2647,8 @@ export class KimiTUI { effectiveMode === 'waiting' || effectiveMode === 'thinking' || effectiveMode === 'composing' || - effectiveMode === 'tool' + effectiveMode === 'tool' || + effectiveMode === 'retrying' ); } @@ -2668,6 +2701,19 @@ export class KimiTUI { } } + // The label is a live function (not a static string) so the moon spinner's + // 120ms tick re-renders the backoff countdown without a dedicated timer. + private applyRetrySpinnerState(): void { + const spinner = this.state.activitySpinner?.instance; + if (spinner === undefined) return; + spinner.setTip(''); + spinner.setLabelFn(() => { + const status = this.state.appState.retryStatus; + if (status === null || status === undefined) return ''; + return currentTheme.fg('warning', formatRetryLabel(status)); + }); + } + // ========================================================================= // Dialogs / Selectors // ========================================================================= diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index d895b11e12..57789fb1a1 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -24,6 +24,18 @@ export interface BannerState { ttlHours?: number; } +export interface RetryStatus { + readonly failedAttempt: number; + readonly nextAttempt: number; + readonly maxAttempts: number; + readonly delayMs: number; + /** Epoch ms when the backoff sleep ends (Date.now() + delayMs at event receipt). */ + readonly nextRetryAt: number; + readonly errorName: string; + readonly errorMessage: string; + readonly statusCode?: number; +} + export interface AppState { model: string; workDir: string; @@ -43,7 +55,7 @@ export interface AppState { maxContextTokens: number; isCompacting: boolean; isReplaying: boolean; - streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing' | 'shell'; + streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing' | 'shell' | 'retrying'; streamingStartTime: number; theme: ThemeName; version: string; @@ -57,6 +69,8 @@ export interface AppState { sessionTitle: string | null; /** Current goal snapshot for the footer badge; null/undefined when no active goal. */ goal?: GoalSnapshot | null; + /** Live retry backoff status; null/undefined when the current step is not retrying. */ + retryStatus?: RetryStatus | null; mcpServersSummary: string | null; /** Optional banner shown below the welcome panel; null means no banner to render. */ banner?: BannerState | null; diff --git a/apps/kimi-code/src/tui/utils/retry-status.ts b/apps/kimi-code/src/tui/utils/retry-status.ts new file mode 100644 index 0000000000..16caaf2296 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/retry-status.ts @@ -0,0 +1,47 @@ +import type { TurnStepRetryingEvent } from '@moonshot-ai/kimi-code-sdk'; + +import type { RetryStatus } from '#/tui/types'; + +const TIMEOUT_RE = /timeout|timed out/i; + +export function buildRetryStatus( + event: TurnStepRetryingEvent, + now = Date.now(), +): RetryStatus { + return { + failedAttempt: event.failedAttempt, + nextAttempt: event.nextAttempt, + maxAttempts: event.maxAttempts, + delayMs: event.delayMs, + nextRetryAt: now + event.delayMs, + errorName: event.errorName, + errorMessage: event.errorMessage, + statusCode: event.statusCode, + }; +} + +export function retryReasonText( + status: Pick, +): string { + const code = status.statusCode; + if (code !== undefined) { + if (code === 429) return 'Rate limited (429)'; + if (code === 408) return 'Request timed out (408)'; + if (code >= 500) return `Server error (${code})`; + return `Provider error (${code})`; + } + if (TIMEOUT_RE.test(status.errorName) || TIMEOUT_RE.test(status.errorMessage)) { + return 'Connection timed out'; + } + return 'Connection issue'; +} + +export function formatRetryLabel(status: RetryStatus, now = Date.now()): string { + const reason = retryReasonText(status); + const attempt = `attempt ${status.nextAttempt}/${status.maxAttempts}`; + const remaining = status.nextRetryAt - now; + if (remaining > 0) { + return `${reason} · ${attempt} · retrying in ${Math.ceil(remaining / 1000)}s`; + } + return `${reason} · ${attempt} · retrying now…`; +} diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 567270558c..aa249ee210 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -5371,3 +5371,144 @@ describe('/effort support_efforts override', () => { expect(session.setThinking).not.toHaveBeenCalled(); }); }); + +describe('turn.step.retrying progress', () => { + function retryingEvent(overrides: Record = {}): Event { + return { + type: 'turn.step.retrying', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + step: 0, + failedAttempt: 1, + nextAttempt: 2, + maxAttempts: 10, + delayMs: 30_000, + errorName: 'RateLimitError', + errorMessage: 'Too many requests', + statusCode: 429, + ...overrides, + } as unknown as Event; + } + + function turnStarted(): Event { + return { type: 'turn.started', agentId: 'main', sessionId: 'ses-1', turnId: 1 } as Event; + } + + it('shows the retry reason and attempt in the activity pane, without a tip', async () => { + const { driver } = await makeDriver(); + driver.sessionEventHandler.handleEvent(turnStarted(), vi.fn()); + + driver.sessionEventHandler.handleEvent(retryingEvent(), vi.fn()); + + expect(driver.state.appState.streamingPhase).toBe('retrying'); + expect(driver.state.appState.retryStatus).not.toBeNull(); + expect(driver.state.appState.retryStatus?.nextAttempt).toBe(2); + const activity = stripSgr(renderActivity(driver)); + expect(activity).toContain('Rate limited (429)'); + expect(activity).toContain('attempt 2/10'); + expect(activity).not.toContain('Tip:'); + }); + + it('updates the attempt counter on a second retry event', async () => { + const { driver } = await makeDriver(); + driver.sessionEventHandler.handleEvent(turnStarted(), vi.fn()); + driver.sessionEventHandler.handleEvent(retryingEvent(), vi.fn()); + + driver.sessionEventHandler.handleEvent( + retryingEvent({ failedAttempt: 2, nextAttempt: 3 }), + vi.fn(), + ); + + expect(driver.state.appState.retryStatus?.nextAttempt).toBe(3); + expect(stripSgr(renderActivity(driver))).toContain('attempt 3/10'); + }); + + it('clears the retry status when assistant text starts streaming', async () => { + const { driver } = await makeDriver(); + driver.sessionEventHandler.handleEvent(retryingEvent(), vi.fn()); + expect(driver.state.appState.streamingPhase).toBe('retrying'); + + driver.sessionEventHandler.handleEvent( + { type: 'assistant.delta', agentId: 'main', sessionId: 'ses-1', delta: 'hello' } as Event, + vi.fn(), + ); + + expect(driver.state.appState.streamingPhase).toBe('composing'); + expect(driver.state.appState.retryStatus).toBeNull(); + }); + + it('clears the retry status on an empty (encrypted) thinking delta', async () => { + const { driver } = await makeDriver(); + driver.sessionEventHandler.handleEvent(retryingEvent(), vi.fn()); + + driver.sessionEventHandler.handleEvent( + { type: 'thinking.delta', agentId: 'main', sessionId: 'ses-1', delta: '' } as Event, + vi.fn(), + ); + + expect(driver.state.appState.streamingPhase).toBe('waiting'); + expect(driver.state.appState.retryStatus).toBeNull(); + }); + + it('clears the retry status when a tool call starts', async () => { + const { driver } = await makeDriver(); + driver.sessionEventHandler.handleEvent(retryingEvent(), vi.fn()); + + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_1', + name: 'Bash', + args: { command: 'ls' }, + } as Event, + vi.fn(), + ); + + expect(driver.state.appState.retryStatus).toBeNull(); + }); + + it('clears the retry status when the turn is cancelled after a retry', async () => { + const { driver } = await makeDriver(); + driver.sessionEventHandler.handleEvent(turnStarted(), vi.fn()); + driver.sessionEventHandler.handleEvent(retryingEvent(), vi.fn()); + expect(driver.state.appState.streamingPhase).toBe('retrying'); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.step.interrupted', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + step: 0, + reason: 'aborted', + } as Event, + vi.fn(), + ); + driver.sessionEventHandler.handleEvent( + { type: 'turn.ended', agentId: 'main', sessionId: 'ses-1', turnId: 1, reason: 'cancelled' } as Event, + vi.fn(), + ); + + expect(driver.state.appState.streamingPhase).toBe('idle'); + expect(driver.state.appState.retryStatus).toBeNull(); + }); + + it('discards the partial assistant draft when the step retries', async () => { + const { driver } = await makeDriver(); + driver.sessionEventHandler.handleEvent(turnStarted(), vi.fn()); + driver.sessionEventHandler.handleEvent( + { type: 'assistant.delta', agentId: 'main', sessionId: 'ses-1', delta: 'half a thought' } as Event, + vi.fn(), + ); + driver.streamingUI.flushNow(); + expect(stripSgr(renderTranscript(driver))).toContain('half a thought'); + + driver.sessionEventHandler.handleEvent(retryingEvent(), vi.fn()); + + expect(stripSgr(renderTranscript(driver))).not.toContain('half a thought'); + }); +}); diff --git a/apps/kimi-code/test/tui/moon-loader.test.ts b/apps/kimi-code/test/tui/moon-loader.test.ts new file mode 100644 index 0000000000..f7c630933b --- /dev/null +++ b/apps/kimi-code/test/tui/moon-loader.test.ts @@ -0,0 +1,53 @@ +import type { TUI } from '@moonshot-ai/pi-tui'; +import { describe, expect, it, vi } from 'vitest'; + +import { MoonLoader } from '#/tui/components/chrome/moon-loader'; +import { MOON_SPINNER_INTERVAL_MS } from '#/tui/constant/rendering'; + +function strip(text: string): string { + return text.replaceAll(/\[[0-9;]*m/g, ''); +} + +function fakeUi(): TUI { + return { requestRender: vi.fn() } as unknown as TUI; +} + +describe('MoonLoader label function', () => { + it('re-evaluates the label function on each spinner tick', () => { + vi.useFakeTimers(); + try { + const loader = new MoonLoader(fakeUi(), 'moon'); + let attempt = 2; + loader.setLabelFn(() => `attempt ${attempt}/10`); + expect(strip(loader.render(80).join('\n'))).toContain('attempt 2/10'); + + attempt = 3; + vi.advanceTimersByTime(MOON_SPINNER_INTERVAL_MS); + expect(strip(loader.render(80).join('\n'))).toContain('attempt 3/10'); + + loader.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it('setLabel clears a previously set label function', () => { + vi.useFakeTimers(); + try { + const loader = new MoonLoader(fakeUi(), 'moon'); + let attempt = 2; + loader.setLabelFn(() => `attempt ${attempt}/10`); + loader.setLabel('static label'); + + attempt = 3; + vi.advanceTimersByTime(MOON_SPINNER_INTERVAL_MS); + const rendered = strip(loader.render(80).join('\n')); + expect(rendered).toContain('static label'); + expect(rendered).not.toContain('attempt'); + + loader.dispose(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/kimi-code/test/tui/utils/retry-status.test.ts b/apps/kimi-code/test/tui/utils/retry-status.test.ts new file mode 100644 index 0000000000..4e5d831074 --- /dev/null +++ b/apps/kimi-code/test/tui/utils/retry-status.test.ts @@ -0,0 +1,113 @@ +import type { TurnStepRetryingEvent } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it } from 'vitest'; + +import { buildRetryStatus, formatRetryLabel, retryReasonText } from '#/tui/utils/retry-status'; +import type { RetryStatus } from '#/tui/types'; + +function makeStatus(overrides: Partial = {}): RetryStatus { + return { + failedAttempt: 1, + nextAttempt: 2, + maxAttempts: 10, + delayMs: 2_000, + nextRetryAt: 10_000, + errorName: 'Error', + errorMessage: 'boom', + ...overrides, + }; +} + +describe('retryReasonText', () => { + it('maps 429 to a rate-limit reason', () => { + expect(retryReasonText({ statusCode: 429, errorName: 'x', errorMessage: 'y' })).toBe( + 'Rate limited (429)', + ); + }); + + it('maps 408 to a request-timeout reason', () => { + expect(retryReasonText({ statusCode: 408, errorName: 'x', errorMessage: 'y' })).toBe( + 'Request timed out (408)', + ); + }); + + it('maps 5xx to a server-error reason with the code', () => { + expect(retryReasonText({ statusCode: 503, errorName: 'x', errorMessage: 'y' })).toBe( + 'Server error (503)', + ); + }); + + it('maps other status codes to a generic provider-error reason', () => { + expect(retryReasonText({ statusCode: 418, errorName: 'x', errorMessage: 'y' })).toBe( + 'Provider error (418)', + ); + }); + + it('detects a connection timeout from the error name or message when no status code', () => { + expect( + retryReasonText({ + statusCode: undefined, + errorName: 'FetchError', + errorMessage: 'request timed out', + }), + ).toBe('Connection timed out'); + expect( + retryReasonText({ statusCode: undefined, errorName: 'TimeoutError', errorMessage: 'nope' }), + ).toBe('Connection timed out'); + }); + + it('falls back to a generic connection issue with no status code and no timeout hint', () => { + expect( + retryReasonText({ + statusCode: undefined, + errorName: 'ECONNRESET', + errorMessage: 'socket hang up', + }), + ).toBe('Connection issue'); + }); +}); + +describe('formatRetryLabel', () => { + it('rounds the remaining backoff up to whole seconds', () => { + const status = makeStatus({ statusCode: 429, nextRetryAt: 10_000 }); + expect(formatRetryLabel(status, 9_500)).toBe( + 'Rate limited (429) · attempt 2/10 · retrying in 1s', + ); + }); + + it('shows "retrying now" once the backoff window has elapsed', () => { + const status = makeStatus({ statusCode: 429, nextRetryAt: 10_000 }); + expect(formatRetryLabel(status, 10_000)).toBe( + 'Rate limited (429) · attempt 2/10 · retrying now…', + ); + expect(formatRetryLabel(status, 10_500)).toBe( + 'Rate limited (429) · attempt 2/10 · retrying now…', + ); + }); +}); + +describe('buildRetryStatus', () => { + it('copies event fields and computes nextRetryAt from now + delayMs', () => { + const event: TurnStepRetryingEvent = { + type: 'turn.step.retrying', + turnId: 1, + step: 0, + failedAttempt: 3, + nextAttempt: 4, + maxAttempts: 10, + delayMs: 2_000, + errorName: 'RateLimitError', + errorMessage: 'slow down', + statusCode: 429, + }; + expect(buildRetryStatus(event, 5_000)).toEqual({ + failedAttempt: 3, + nextAttempt: 4, + maxAttempts: 10, + delayMs: 2_000, + nextRetryAt: 7_000, + errorName: 'RateLimitError', + errorMessage: 'slow down', + statusCode: 429, + }); + }); +}); From 7b1cd6c1610be9069fb965f2f4c576264418b847 Mon Sep 17 00:00:00 2001 From: vinlee19 <1401597760@qq.com> Date: Sat, 18 Jul 2026 00:41:14 +0800 Subject: [PATCH 2/2] fix(tui): discard all failed-attempt artifacts when a step retries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A retry re-streams the step from scratch, but the discard path only removed the active assistant streaming block. Thinking bubbles and tool-call previews are mounted into the transcript and merely disposed (spinner stopped) on reset, and assistant text already finalized when a tool preview started had dropped its ref entirely — so all of them stayed rendered above the fresh attempt, duplicating content. Track every transcript component the streaming UI mounts during the current step (assistant blocks with their transcript entries, thinking, tool previews and their group containers) and pull them all on turn.step.retrying. The scope clears at step boundaries so committed output from completed steps is never touched; group upgrades replace a solo card in place, and Container.removeChild is a no-op for non-children, so tracking both card and group stays safe. --- .../tui/controllers/session-event-handler.ts | 15 +-- .../src/tui/controllers/streaming-ui.ts | 56 ++++++++++-- .../test/tui/kimi-tui-message-flow.test.ts | 91 +++++++++++++++++++ 3 files changed, 147 insertions(+), 15 deletions(-) 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 dd29e373fd..5afe321e6e 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -358,6 +358,7 @@ export class SessionEventHandler { this.host.streamingUI.setStep(event.step); this.host.streamingUI.resetToolUi(); this.host.streamingUI.finalizeLiveTextBuffers('waiting'); + this.host.streamingUI.clearStepArtifactScope(); this.host.patchLivePane({ mode: 'waiting', pendingApproval: null, @@ -370,13 +371,14 @@ export class SessionEventHandler { } private handleStepRetrying(event: TurnStepRetryingEvent): void { - // The failed attempt's partial live output is invalid — the next attempt - // re-streams from scratch. Mirror print mode's discardAssistant() - // (cli/run-prompt.ts:578) instead of committing it to the transcript. The - // TUI renders assistant text live, so discarding also means pulling the - // already-rendered partial block, not just clearing the buffers. + // The failed attempt's output is invalid — the next attempt re-streams the + // step from scratch. Mirror print mode's discardAssistant() + // (cli/run-prompt.ts:578) instead of committing it: the TUI renders + // live, so discarding means pulling every already-mounted artifact of the + // attempt (assistant blocks, thinking, tool previews), not just clearing + // buffers. this.host.streamingUI.discardPending(); - this.host.streamingUI.discardStreamingBlock(); + this.host.streamingUI.discardStepArtifacts(); this.host.streamingUI.resetLiveText(); this.host.streamingUI.resetToolUi(); this.host.patchLivePane({ mode: 'waiting', pendingApproval: null, pendingQuestion: null }); @@ -387,6 +389,7 @@ export class SessionEventHandler { if (this.host.state.appState.streamingPhase === 'retrying') { this.host.setAppState({ streamingPhase: 'waiting' }); } + this.host.streamingUI.clearStepArtifactScope(); this.host.streamingUI.flushNow(); this.maybeShowDebugTiming(event); diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 8907d12c9e..6694901222 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -1,4 +1,5 @@ import type { Session } from '@moonshot-ai/kimi-code-sdk'; +import type { Component } from '@moonshot-ai/pi-tui'; import { AgentGroupComponent } from '../components/messages/agent-group'; import { AssistantMessageComponent } from '../components/messages/assistant-message'; @@ -54,6 +55,15 @@ export class StreamingUIController { private _assistantDraft = ''; private _thinkingDraft = ''; private _streamingBlock: { component: AssistantMessageComponent; entry: TranscriptEntry } | null = null; + // Every transcript component mounted while streaming the current step's LLM + // attempt (assistant block, thinking bubble, tool-call previews and their + // group containers). A retry re-streams the step from scratch, so these are + // exactly what must be pulled from the transcript when the attempt fails — + // dispose()/reset methods only stop timers and drop refs, they do not + // unmount. Cleared at step boundaries; group upgrades replace a solo card + // in place, so removing both the card and its group is a safe no-op for + // whichever is no longer a direct child. + private _stepArtifacts: Array<{ component: Component; entry?: TranscriptEntry }> = []; private _activeThinkingComponent: ThinkingComponent | undefined = undefined; private _activeCompactionBlock: CompactionComponent | undefined = undefined; private _activeToolCalls = new Map(); @@ -526,23 +536,42 @@ export class StreamingUIController { this._assistantDraft = ''; this._streamingBlock = null; this._thinkingDraft = ''; + this._stepArtifacts = []; this.disposeActiveThinkingComponent(); } - // Drop the in-progress live assistant block instead of finalizing it. Unlike - // resetLiveText (which only clears buffers and the ref), this removes the - // already-rendered component and its transcript entry, so a failed step's - // partial output does not linger above the retried attempt's fresh stream. - discardStreamingBlock(): void { - const block = this._streamingBlock; - if (block === null) return; + // Pull everything the failed attempt rendered — assistant blocks (including + // ones already finalized when a tool preview started), the thinking bubble, + // tool-call previews and their group containers — plus their transcript + // entries, so the retried attempt re-streams into a clean transcript. + discardStepArtifacts(): void { + if (this._stepArtifacts.length === 0 && this._streamingBlock === null) return; const { state } = this.host; - state.transcriptContainer.removeChild(block.component); - state.transcriptEntries = state.transcriptEntries.filter((entry) => entry !== block.entry); + for (const artifact of this._stepArtifacts) { + state.transcriptContainer.removeChild(artifact.component); + } + const droppedEntries = new Set( + this._stepArtifacts + .map((artifact) => artifact.entry) + .filter((entry) => entry !== undefined), + ); + if (droppedEntries.size > 0) { + state.transcriptEntries = state.transcriptEntries.filter( + (entry) => !droppedEntries.has(entry), + ); + } + this._stepArtifacts = []; this._streamingBlock = null; + this._pendingAgentGroup = null; + this._pendingReadGroup = null; state.ui.requestRender(); } + // Artifacts become permanent once the step outlives its retry window. + clearStepArtifactScope(): void { + this._stepArtifacts = []; + } + resetToolUi(): void { this.pendingToolCallFlushIds.clear(); this.clearFlushTimerIfIdle(); @@ -616,6 +645,7 @@ export class StreamingUIController { this._streamingBlock = { component, entry }; this.host.pushTranscriptEntry(entry); state.transcriptContainer.addChild(component); + this._stepArtifacts.push({ component, entry }); state.ui.requestRender(); } @@ -654,6 +684,7 @@ export class StreamingUIController { ); if (state.toolOutputExpanded) this._activeThinkingComponent.setExpanded(true); state.transcriptContainer.addChild(this._activeThinkingComponent); + this._stepArtifacts.push({ component: this._activeThinkingComponent }); } else { this._activeThinkingComponent.setText(fullText); } @@ -688,6 +719,7 @@ export class StreamingUIController { if (!handled) handled = this.tryAttachReadToolCall(toolCall, tc); if (!handled) { state.transcriptContainer.addChild(tc); + this._stepArtifacts.push({ component: tc }); state.ui.requestRender(); } @@ -820,6 +852,7 @@ export class StreamingUIController { if (cur === null) { this._pendingAgentGroup = { step, turnId, solo: tc }; state.transcriptContainer.addChild(tc); + this._stepArtifacts.push({ component: tc }); state.ui.requestRender(); return true; } @@ -833,6 +866,7 @@ export class StreamingUIController { if (solo === undefined) { this._pendingAgentGroup = { step, turnId, solo: tc }; state.transcriptContainer.addChild(tc); + this._stepArtifacts.push({ component: tc }); state.ui.requestRender(); return true; } @@ -855,6 +889,7 @@ export class StreamingUIController { state.transcriptContainer.addChild(group); } group.attach(solo.toolCallView.id, solo); + this._stepArtifacts.push({ component: group }); return group; } @@ -877,6 +912,7 @@ export class StreamingUIController { if (cur === null) { this._pendingReadGroup = { step, turnId, solo: tc }; state.transcriptContainer.addChild(tc); + this._stepArtifacts.push({ component: tc }); state.ui.requestRender(); return true; } @@ -890,6 +926,7 @@ export class StreamingUIController { if (solo === undefined) { this._pendingReadGroup = { step, turnId, solo: tc }; state.transcriptContainer.addChild(tc); + this._stepArtifacts.push({ component: tc }); state.ui.requestRender(); return true; } @@ -912,6 +949,7 @@ export class StreamingUIController { state.transcriptContainer.addChild(group); } group.attach(solo.toolCallView.id, solo); + this._stepArtifacts.push({ component: group }); return group; } } diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index aa249ee210..5cb3f2c68b 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -5511,4 +5511,95 @@ describe('turn.step.retrying progress', () => { expect(stripSgr(renderTranscript(driver))).not.toContain('half a thought'); }); + + it('discards a rendered thinking bubble when the step retries', async () => { + const { driver } = await makeDriver(); + driver.sessionEventHandler.handleEvent(turnStarted(), vi.fn()); + driver.sessionEventHandler.handleEvent( + { type: 'thinking.delta', agentId: 'main', sessionId: 'ses-1', delta: 'pondering deeply' } as Event, + vi.fn(), + ); + driver.streamingUI.flushNow(); + expect(stripSgr(renderTranscript(driver))).toContain('pondering deeply'); + + driver.sessionEventHandler.handleEvent(retryingEvent(), vi.fn()); + + expect(stripSgr(renderTranscript(driver))).not.toContain('pondering deeply'); + }); + + it('discards a tool-call preview when the step retries', async () => { + const { driver } = await makeDriver(); + driver.sessionEventHandler.handleEvent(turnStarted(), vi.fn()); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_retry_1', + name: 'Bash', + args: { command: 'ls -la /tmp/retry-probe' }, + } as Event, + vi.fn(), + ); + driver.streamingUI.flushNow(); + expect(stripSgr(renderTranscript(driver))).toContain('retry-probe'); + + driver.sessionEventHandler.handleEvent(retryingEvent(), vi.fn()); + + expect(stripSgr(renderTranscript(driver))).not.toContain('retry-probe'); + }); + + it('discards assistant text already finalized by a tool-call preview when the step retries', async () => { + const { driver } = await makeDriver(); + driver.sessionEventHandler.handleEvent(turnStarted(), vi.fn()); + driver.sessionEventHandler.handleEvent( + { type: 'assistant.delta', agentId: 'main', sessionId: 'ses-1', delta: 'finalized preamble' } as Event, + vi.fn(), + ); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_retry_2', + name: 'Bash', + args: { command: 'true' }, + } as Event, + vi.fn(), + ); + driver.streamingUI.flushNow(); + expect(stripSgr(renderTranscript(driver))).toContain('finalized preamble'); + + driver.sessionEventHandler.handleEvent(retryingEvent(), vi.fn()); + + expect(stripSgr(renderTranscript(driver))).not.toContain('finalized preamble'); + }); + + it('keeps a completed step\'s output when a later step retries', async () => { + const { driver } = await makeDriver(); + driver.sessionEventHandler.handleEvent(turnStarted(), vi.fn()); + driver.sessionEventHandler.handleEvent( + { type: 'turn.step.started', agentId: 'main', sessionId: 'ses-1', turnId: 1, step: 0 } as Event, + vi.fn(), + ); + driver.sessionEventHandler.handleEvent( + { type: 'assistant.delta', agentId: 'main', sessionId: 'ses-1', delta: 'step zero result' } as Event, + vi.fn(), + ); + driver.streamingUI.flushNow(); + driver.sessionEventHandler.handleEvent( + { type: 'turn.step.completed', agentId: 'main', sessionId: 'ses-1', turnId: 1, step: 0 } as Event, + vi.fn(), + ); + driver.sessionEventHandler.handleEvent( + { type: 'turn.step.started', agentId: 'main', sessionId: 'ses-1', turnId: 1, step: 1 } as Event, + vi.fn(), + ); + + driver.sessionEventHandler.handleEvent(retryingEvent({ step: 1 }), vi.fn()); + + expect(stripSgr(renderTranscript(driver))).toContain('step zero result'); + }); });