From 33194ea4df1022671c58f5d0f7d96876edef766b Mon Sep 17 00:00:00 2001 From: Randy Date: Mon, 25 May 2026 23:56:50 +0800 Subject: [PATCH 1/2] feat(tui): show output token throughput --- .changeset/show-token-throughput.md | 5 ++ .../src/tui/components/chrome/footer.ts | 46 ++++++++++++------ apps/kimi-code/src/tui/kimi-tui.ts | 35 ++++++++++++++ apps/kimi-code/src/tui/types.ts | 1 + .../src/tui/utils/token-throughput.ts | 18 +++++++ .../replay-hydrate-agent-group.test.ts | 1 + .../panels/footer-bg-agents.test.ts | 1 + .../components/panels/footer-context.test.ts | 22 +++++++++ .../test/tui/create-tui-state.test.ts | 1 + .../test/tui/utils/token-throughput.test.ts | 48 +++++++++++++++++++ 10 files changed, 163 insertions(+), 15 deletions(-) create mode 100644 .changeset/show-token-throughput.md create mode 100644 apps/kimi-code/src/tui/utils/token-throughput.ts create mode 100644 apps/kimi-code/test/tui/utils/token-throughput.test.ts diff --git a/.changeset/show-token-throughput.md b/.changeset/show-token-throughput.md new file mode 100644 index 00000000..b4c3bf38 --- /dev/null +++ b/.changeset/show-token-throughput.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Show recent model output speed in the footer after each completed response. diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index 9079da44..784d11fb 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -19,7 +19,7 @@ import { type GitStatus, type GitStatusCache, } from '#/utils/git/git-status'; -import { safeUsageRatio } from '#/utils/usage/usage-format'; +import { formatTokenCount, safeUsageRatio } from '#/utils/usage/usage-format'; const MAX_CWD_SEGMENTS = 3; @@ -83,12 +83,6 @@ function shortenCwd(path: string): string { return `…/${tail}`; } -function formatTokenCount(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; - return String(n); -} - function safeUsage(usage: number): number { return safeUsageRatio(usage); } @@ -101,6 +95,24 @@ function formatContextStatus(usage: number, tokens?: number, maxTokens?: number) return `context: ${pct}`; } +function formatTokenThroughput(tokensPerSecond: number | null): string | undefined { + if ( + tokensPerSecond === null || + !Number.isFinite(tokensPerSecond) || + tokensPerSecond <= 0 + ) { + return undefined; + } + + const value = + tokensPerSecond >= 100 + ? String(Math.round(tokensPerSecond)) + : tokensPerSecond >= 10 + ? tokensPerSecond.toFixed(1) + : tokensPerSecond.toFixed(2); + return `speed: ${value} tok/s`; +} + export function formatFooterGitBadge(status: GitStatus, colors: ColorPalette): string { const base = chalk.hex(colors.status)(formatGitBadgeBase(status)); if (status.pullRequest === null) return base; @@ -236,7 +248,7 @@ export class FooterComponent implements Component { line1 = truncateToWidth(leftLine, width, '…'); } - // ── Line 2: transient hint (bottom-left) + context (right) ── + // ── Line 2: transient hint / token speed (bottom-left) + context (right) ── const contextText = formatContextStatus( state.contextUsage, state.contextTokens, @@ -244,18 +256,22 @@ export class FooterComponent implements Component { ); const contextWidth = visibleWidth(contextText); let line2: string; - if (this.transientHint) { + const throughputText = formatTokenThroughput(state.outputTokensPerSecond); + const leftStatus = this.transientHint ?? throughputText; + if (leftStatus) { const maxHintWidth = Math.max(0, width - contextWidth - 1); const shownHint = - visibleWidth(this.transientHint) <= maxHintWidth - ? this.transientHint - : truncateToWidth(this.transientHint, maxHintWidth, '…'); + visibleWidth(leftStatus) <= maxHintWidth + ? leftStatus + : truncateToWidth(leftStatus, maxHintWidth, '…'); const hintWidth = visibleWidth(shownHint); const pad = Math.max(0, width - hintWidth - contextWidth); + const statusStyle = + this.transientHint === null + ? chalk.hex(colors.textMuted) + : chalk.hex(colors.warning).bold; line2 = - chalk.hex(colors.warning).bold(shownHint) + - ' '.repeat(pad) + - chalk.hex(colors.text)(contextText); + statusStyle(shownHint) + ' '.repeat(pad) + chalk.hex(colors.text)(contextText); } else { const leftPad = Math.max(0, width - contextWidth); line2 = ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 885b6db0..9d79c382 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -235,6 +235,7 @@ import { installTerminalFocusTracking } from './utils/terminal-focus'; import { notifyTerminalOnce } from './utils/terminal-notification'; import { createTerminalState, type TerminalState } from './utils/terminal-state'; import { installTerminalThemeTracking } from './utils/terminal-theme'; +import { outputTokensPerSecond } from './utils/token-throughput'; import { detectTmuxKeyboardWarning } from './utils/tmux-keyboard'; import { nextTranscriptId } from './utils/transcript-id'; @@ -380,6 +381,8 @@ export interface TUIState { externalEditorRunning: boolean; currentTurnId: string | undefined; currentStep: number; + currentStepStartedAtMs: number; + currentStepModelFinishedAtMs: number | undefined; assistantDraft: string; assistantStreamActive: boolean; thinkingDraft: string; @@ -405,6 +408,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { contextUsage: 0, contextTokens: 0, maxContextTokens: 0, + outputTokensPerSecond: null, isStreaming: false, isCompacting: false, isReplaying: false, @@ -491,6 +495,8 @@ export function createTUIState(options: KimiTUIOptions): TUIState { externalEditorRunning: false, currentTurnId: undefined, currentStep: 0, + currentStepStartedAtMs: 0, + currentStepModelFinishedAtMs: undefined, assistantDraft: '', assistantStreamActive: false, thinkingDraft: '', @@ -947,6 +953,7 @@ export class KimiTUI { contextTokens: 0, maxContextTokens: 0, contextUsage: 0, + outputTokensPerSecond: null, sessionTitle: null, }); this.state.startupNotice = combineStartupNotice( @@ -994,6 +1001,7 @@ export class KimiTUI { sessionId: '', model: '', sessionTitle: null, + outputTokensPerSecond: null, }); await this.refreshSkillCommands(); } @@ -1037,6 +1045,7 @@ export class KimiTUI { maxContextTokens: 0, contextUsage: 0, contextTokens: 0, + outputTokensPerSecond: null, }); } @@ -1545,6 +1554,8 @@ export class KimiTUI { // Resets request-scoped state before submitting work to the active session. private beginSessionRequest(): void { this.state.currentTurnId = undefined; + this.state.currentStepStartedAtMs = Date.now(); + this.state.currentStepModelFinishedAtMs = undefined; this.resetLiveTextRuntime(); this.resetLiveToolUiState(); this.resetToolCallState(); @@ -1558,6 +1569,7 @@ export class KimiTUI { isStreaming: true, streamingPhase: 'waiting', streamingStartTime: Date.now(), + outputTokensPerSecond: null, }); } @@ -1938,6 +1950,7 @@ export class KimiTUI { contextTokens: status.contextTokens, maxContextTokens: status.maxContextTokens, contextUsage: status.contextUsage, + outputTokensPerSecond: null, sessionTitle: session.summary?.title ?? null, }); } @@ -2033,6 +2046,8 @@ export class KimiTUI { this.setTodoList([]); this.state.currentTurnId = undefined; this.state.currentStep = 0; + this.state.currentStepStartedAtMs = 0; + this.state.currentStepModelFinishedAtMs = undefined; this.resetLiveTextRuntime(); this.updateQueueDisplay(); } @@ -2355,6 +2370,8 @@ export class KimiTUI { void _event; this.resetLiveToolUiState(); this.state.currentStep = 0; + this.state.currentStepStartedAtMs = Date.now(); + this.state.currentStepModelFinishedAtMs = undefined; this.patchLivePane({ mode: 'waiting', pendingApproval: null, @@ -2364,6 +2381,7 @@ export class KimiTUI { isStreaming: true, streamingPhase: 'waiting', streamingStartTime: Date.now(), + outputTokensPerSecond: null, }); } @@ -2383,6 +2401,8 @@ export class KimiTUI { private handleStepBegin(event: TurnStepStartedEvent): void { this.flushStreamingUiUpdatesNow(); this.state.currentStep = event.step; + this.state.currentStepStartedAtMs = Date.now(); + this.state.currentStepModelFinishedAtMs = undefined; this.resetLiveToolUiState(); this.finalizeLiveTextBuffers('waiting'); this.patchLivePane({ @@ -2393,6 +2413,7 @@ export class KimiTUI { this.setAppState({ streamingPhase: 'waiting', streamingStartTime: Date.now(), + outputTokensPerSecond: null, }); } @@ -2406,6 +2427,7 @@ export class KimiTUI { // notice pointing at the config knob. private handleStepCompleted(event: TurnStepCompletedEvent): void { this.flushStreamingUiUpdatesNow(); + this.updateOutputTokenThroughput(event.usage); if (event.finishReason !== 'max_tokens') return; // Scope the truncation marking to tool calls that belong to the @@ -2443,6 +2465,18 @@ export class KimiTUI { this.showNotice(title, detail); } + private updateOutputTokenThroughput(usage: TurnStepCompletedEvent['usage']): void { + if (this.state.currentStepStartedAtMs <= 0) return; + const endedAtMs = this.state.currentStepModelFinishedAtMs ?? Date.now(); + const tokensPerSecond = outputTokensPerSecond( + usage, + this.state.currentStepStartedAtMs, + endedAtMs, + ); + if (tokensPerSecond === null) return; + this.setAppState({ outputTokensPerSecond: tokensPerSecond }); + } + private isAnthropicSessionActive(): boolean { const providerKey = this.state.appState.availableModels[this.state.appState.model]?.provider; if (providerKey === undefined) return false; @@ -2526,6 +2560,7 @@ export class KimiTUI { // Starts or updates a rendered tool call from a tool-call start event. private handleToolCall(event: ToolCallStartedEvent): void { this.flushStreamingUiUpdatesNow(); + this.state.currentStepModelFinishedAtMs ??= Date.now(); const toolCall: ToolCallBlockData = { id: event.toolCallId, name: event.name, diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 16be3550..bf018627 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -21,6 +21,7 @@ export interface AppState { contextUsage: number; contextTokens: number; maxContextTokens: number; + outputTokensPerSecond: number | null; isStreaming: boolean; isCompacting: boolean; isReplaying: boolean; diff --git a/apps/kimi-code/src/tui/utils/token-throughput.ts b/apps/kimi-code/src/tui/utils/token-throughput.ts new file mode 100644 index 00000000..9d19bed7 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/token-throughput.ts @@ -0,0 +1,18 @@ +import type { TokenUsage } from '@moonshot-ai/kimi-code-sdk'; + +export function outputTokensPerSecond( + usage: TokenUsage | undefined, + startedAtMs: number, + endedAtMs: number, +): number | null { + const outputTokens = usage?.output; + if (typeof outputTokens !== 'number' || !Number.isFinite(outputTokens) || outputTokens <= 0) { + return null; + } + if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) return null; + + const elapsedMs = endedAtMs - startedAtMs; + if (elapsedMs <= 0) return null; + + return outputTokens / (elapsedMs / 1000); +} diff --git a/apps/kimi-code/test/tui/actions/replay-hydrate-agent-group.test.ts b/apps/kimi-code/test/tui/actions/replay-hydrate-agent-group.test.ts index 31197ace..0fff29a7 100644 --- a/apps/kimi-code/test/tui/actions/replay-hydrate-agent-group.test.ts +++ b/apps/kimi-code/test/tui/actions/replay-hydrate-agent-group.test.ts @@ -21,6 +21,7 @@ function makeAppState(): AppState { contextUsage: 0, contextTokens: 0, maxContextTokens: 100, + outputTokensPerSecond: null, isStreaming: false, isCompacting: false, isReplaying: false, diff --git a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts index e66e7f11..1900876f 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts @@ -21,6 +21,7 @@ function baseState(overrides: Partial = {}): AppState { contextUsage: 0, contextTokens: 0, maxContextTokens: 200_000, + outputTokensPerSecond: null, isStreaming: false, isCompacting: false, isReplaying: false, diff --git a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts index 4861b68c..d77e22d9 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts @@ -30,6 +30,7 @@ function baseState(overrides: Partial = {}): AppState { contextUsage: 0, contextTokens: 0, maxContextTokens: 0, + outputTokensPerSecond: null, isStreaming: false, isCompacting: false, isReplaying: false, @@ -115,6 +116,27 @@ describe('FooterComponent — context NaN resilience', () => { expect(strip(line2 ?? '')).toContain('context: 0.0%'); }); + it('renders recent output token throughput on the context line', () => { + const footer = new FooterComponent( + baseState({ outputTokensPerSecond: 24.62, contextUsage: 0.125 }), + darkColors, + ); + + const [, line2] = footer.render(120); + expect(strip(line2 ?? '')).toContain('speed: 24.6 tok/s'); + expect(strip(line2 ?? '')).toContain('context: 12.5%'); + }); + + it('lets transient hints temporarily replace token throughput', () => { + const footer = new FooterComponent(baseState({ outputTokensPerSecond: 24.62 }), darkColors); + + footer.setTransientHint('Press Ctrl-C again to exit'); + + const [, line2] = footer.render(120); + expect(strip(line2 ?? '')).toContain('Press Ctrl-C again to exit'); + expect(strip(line2 ?? '')).not.toContain('tok/s'); + }); + it('highlights the pull request badge separately from git status text', () => { const previousLevel = chalk.level; chalk.level = 3; 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 4da74384..2a259d7d 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -16,6 +16,7 @@ function fakeInitialAppState(): AppState { contextUsage: 0, contextTokens: 0, maxContextTokens: 0, + outputTokensPerSecond: null, isStreaming: false, isCompacting: false, isReplaying: false, diff --git a/apps/kimi-code/test/tui/utils/token-throughput.test.ts b/apps/kimi-code/test/tui/utils/token-throughput.test.ts new file mode 100644 index 00000000..32744786 --- /dev/null +++ b/apps/kimi-code/test/tui/utils/token-throughput.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { outputTokensPerSecond } from '#/tui/utils/token-throughput'; + +describe('outputTokensPerSecond', () => { + it('computes output token throughput from elapsed time', () => { + expect( + outputTokensPerSecond( + { + inputOther: 100, + inputCacheRead: 0, + inputCacheCreation: 0, + output: 75, + }, + 1_000, + 4_000, + ), + ).toBe(25); + }); + + it('returns null for missing usage, zero output, or invalid duration', () => { + expect(outputTokensPerSecond(undefined, 1_000, 4_000)).toBeNull(); + expect( + outputTokensPerSecond( + { + inputOther: 0, + inputCacheRead: 0, + inputCacheCreation: 0, + output: 0, + }, + 1_000, + 4_000, + ), + ).toBeNull(); + expect( + outputTokensPerSecond( + { + inputOther: 0, + inputCacheRead: 0, + inputCacheCreation: 0, + output: 10, + }, + 4_000, + 1_000, + ), + ).toBeNull(); + }); +}); From 4f65ae0fc6110e9dd63f7b493df73d8c78b14324 Mon Sep 17 00:00:00 2001 From: Randy Date: Tue, 26 May 2026 10:30:59 +0800 Subject: [PATCH 2/2] fix(tui): measure throughput from model deltas --- apps/kimi-code/src/tui/kimi-tui.ts | 48 +++++--- .../test/tui/kimi-tui-message-flow.test.ts | 107 ++++++++++++++++++ 2 files changed, 139 insertions(+), 16 deletions(-) diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 9d79c382..9d15d8d3 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -381,8 +381,8 @@ export interface TUIState { externalEditorRunning: boolean; currentTurnId: string | undefined; currentStep: number; - currentStepStartedAtMs: number; - currentStepModelFinishedAtMs: number | undefined; + currentStepFirstDeltaAtMs: number | undefined; + currentStepLastDeltaAtMs: number | undefined; assistantDraft: string; assistantStreamActive: boolean; thinkingDraft: string; @@ -495,8 +495,8 @@ export function createTUIState(options: KimiTUIOptions): TUIState { externalEditorRunning: false, currentTurnId: undefined, currentStep: 0, - currentStepStartedAtMs: 0, - currentStepModelFinishedAtMs: undefined, + currentStepFirstDeltaAtMs: undefined, + currentStepLastDeltaAtMs: undefined, assistantDraft: '', assistantStreamActive: false, thinkingDraft: '', @@ -1554,8 +1554,7 @@ export class KimiTUI { // Resets request-scoped state before submitting work to the active session. private beginSessionRequest(): void { this.state.currentTurnId = undefined; - this.state.currentStepStartedAtMs = Date.now(); - this.state.currentStepModelFinishedAtMs = undefined; + this.resetCurrentStepModelTiming(); this.resetLiveTextRuntime(); this.resetLiveToolUiState(); this.resetToolCallState(); @@ -2046,8 +2045,7 @@ export class KimiTUI { this.setTodoList([]); this.state.currentTurnId = undefined; this.state.currentStep = 0; - this.state.currentStepStartedAtMs = 0; - this.state.currentStepModelFinishedAtMs = undefined; + this.resetCurrentStepModelTiming(); this.resetLiveTextRuntime(); this.updateQueueDisplay(); } @@ -2370,8 +2368,7 @@ export class KimiTUI { void _event; this.resetLiveToolUiState(); this.state.currentStep = 0; - this.state.currentStepStartedAtMs = Date.now(); - this.state.currentStepModelFinishedAtMs = undefined; + this.resetCurrentStepModelTiming(); this.patchLivePane({ mode: 'waiting', pendingApproval: null, @@ -2401,8 +2398,7 @@ export class KimiTUI { private handleStepBegin(event: TurnStepStartedEvent): void { this.flushStreamingUiUpdatesNow(); this.state.currentStep = event.step; - this.state.currentStepStartedAtMs = Date.now(); - this.state.currentStepModelFinishedAtMs = undefined; + this.resetCurrentStepModelTiming(); this.resetLiveToolUiState(); this.finalizeLiveTextBuffers('waiting'); this.patchLivePane({ @@ -2466,17 +2462,29 @@ export class KimiTUI { } private updateOutputTokenThroughput(usage: TurnStepCompletedEvent['usage']): void { - if (this.state.currentStepStartedAtMs <= 0) return; - const endedAtMs = this.state.currentStepModelFinishedAtMs ?? Date.now(); + const startedAtMs = this.state.currentStepFirstDeltaAtMs; + const endedAtMs = this.state.currentStepLastDeltaAtMs; + if (startedAtMs === undefined || endedAtMs === undefined) return; const tokensPerSecond = outputTokensPerSecond( usage, - this.state.currentStepStartedAtMs, + startedAtMs, endedAtMs, ); if (tokensPerSecond === null) return; this.setAppState({ outputTokensPerSecond: tokensPerSecond }); } + private resetCurrentStepModelTiming(): void { + this.state.currentStepFirstDeltaAtMs = undefined; + this.state.currentStepLastDeltaAtMs = undefined; + } + + private recordCurrentStepModelDelta(): void { + const now = Date.now(); + this.state.currentStepFirstDeltaAtMs ??= now; + this.state.currentStepLastDeltaAtMs = now; + } + private isAnthropicSessionActive(): boolean { const providerKey = this.state.appState.availableModels[this.state.appState.model]?.provider; if (providerKey === undefined) return false; @@ -2503,6 +2511,9 @@ export class KimiTUI { // Appends a thinking delta to the live thinking block. private handleThinkingDelta(event: ThinkingDeltaEvent): void { + if (event.delta.length > 0) { + this.recordCurrentStepModelDelta(); + } this.state.thinkingDraft += event.delta; this.pendingThinkingFlush = true; this.patchLivePane({ mode: 'idle' }); @@ -2514,6 +2525,9 @@ export class KimiTUI { // Appends an assistant text delta to the live assistant block. private handleAssistantDelta(event: AssistantDeltaEvent): void { + if (event.delta.length > 0) { + this.recordCurrentStepModelDelta(); + } if (this.state.thinkingDraft.length > 0) { this.flushThinkingToTranscript('idle'); } @@ -2560,7 +2574,6 @@ export class KimiTUI { // Starts or updates a rendered tool call from a tool-call start event. private handleToolCall(event: ToolCallStartedEvent): void { this.flushStreamingUiUpdatesNow(); - this.state.currentStepModelFinishedAtMs ??= Date.now(); const toolCall: ToolCallBlockData = { id: event.toolCallId, name: event.name, @@ -2593,6 +2606,9 @@ export class KimiTUI { // Accumulates streaming tool-call arguments and updates the rendered call. private handleToolCallDelta(event: ToolCallDeltaEvent): void { if (event.toolCallId.length === 0) return; + if ((event.argumentsPart?.length ?? 0) > 0 || (event.name?.length ?? 0) > 0) { + this.recordCurrentStepModelDelta(); + } const id = event.toolCallId; const existing = this.state.streamingToolCallArguments.get(id); const argumentsText = appendStreamingArgsPreview( 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 ad5899a0..33ef70ef 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 @@ -702,6 +702,113 @@ describe('KimiTUI message flow', () => { } }); + it('computes output throughput from the streamed model delta window', async () => { + vi.useFakeTimers(); + try { + const { driver } = await makeDriver(); + driver.state.appState.isStreaming = true; + vi.setSystemTime(1_000); + driver.handleEvent( + { + type: 'turn.step.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + step: 1, + } as Event, + vi.fn(), + ); + + vi.setSystemTime(2_000); + driver.handleEvent( + { + type: 'assistant.delta', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + delta: 'a', + } as Event, + vi.fn(), + ); + vi.setSystemTime(5_000); + driver.handleEvent( + { + type: 'assistant.delta', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + delta: 'b', + } as Event, + vi.fn(), + ); + + vi.setSystemTime(12_000); + driver.handleEvent( + { + type: 'turn.step.completed', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + step: 1, + usage: { + inputOther: 0, + inputCacheRead: 0, + inputCacheCreation: 0, + output: 60, + }, + finishReason: 'tool_use', + } as Event, + vi.fn(), + ); + + expect(driver.state.appState.outputTokensPerSecond).toBe(20); + } finally { + vi.useRealTimers(); + } + }); + + it('does not show throughput when no model deltas were observed', async () => { + vi.useFakeTimers(); + try { + const { driver } = await makeDriver(); + driver.state.appState.isStreaming = true; + vi.setSystemTime(1_000); + driver.handleEvent( + { + type: 'turn.step.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + step: 1, + } as Event, + vi.fn(), + ); + + vi.setSystemTime(12_000); + driver.handleEvent( + { + type: 'turn.step.completed', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + step: 1, + usage: { + inputOther: 0, + inputCacheRead: 0, + inputCacheCreation: 0, + output: 60, + }, + finishReason: 'tool_use', + } as Event, + vi.fn(), + ); + + expect(driver.state.appState.outputTokensPerSecond).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + it('coalesces streaming tool-call argument preview updates', async () => { vi.useFakeTimers(); try {