fix(tui): keep waiting spinner during encrypted reasoning streams (#1256)

Empty (encrypted/redacted) thinking deltas no longer switch out of waiting mode, which previously stopped the moon spinner while no thinking component was ever created, leaving a blank spinner-less gap until the first real text/tool token.
This commit is contained in:
liruifengv 2026-07-01 14:39:35 +08:00 committed by GitHub
parent ef61f4369b
commit 0cc02ac67d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 66 additions and 0 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Keep the waiting spinner visible while encrypted reasoning streams, fixing a blank spinner-less gap before the first response text appears.

View file

@ -438,6 +438,14 @@ export class SessionEventHandler {
private handleThinkingDelta(event: ThinkingDeltaEvent): void {
const { state, streamingUI } = this.host;
// Encrypted / redacted reasoning (e.g. Kimi over the Anthropic-compatible
// protocol) streams thinking deltas whose visible text is empty — only an
// opaque signature rides along. Such deltas carry nothing to render, so
// switching into the `thinking` pane mode here would stop the "waiting"
// 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.length === 0 && !streamingUI.hasThinkingDraft()) return;
streamingUI.appendThinkingDelta(event.delta);
this.host.patchLivePane({ mode: 'idle' });
if (state.appState.streamingPhase !== 'thinking') {

View file

@ -13,6 +13,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel';
import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app';
import { MOON_SPINNER_FRAMES } from '#/tui/constant/rendering';
import {
AgentSwarmProgressComponent,
agentSwarmGridHeightForTerminalRows,
@ -4528,6 +4529,58 @@ command = "vim"
expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(false);
});
it('keeps the waiting moon spinner while reasoning streams only empty (encrypted) thinking deltas', async () => {
const { driver } = await makeDriver();
// Turn begins -> waiting mode shows the moon spinner.
driver.sessionEventHandler.handleEvent(
{
type: 'turn.started',
agentId: 'main',
sessionId: 'ses-1',
turnId: 1,
} as Event,
vi.fn(),
);
expect(driver.state.appState.streamingPhase).toBe('waiting');
expect(driver.state.livePane.mode).toBe('waiting');
// Encrypted reasoning: thinking.delta events whose visible text is empty.
for (let i = 0; i < 3; i++) {
driver.sessionEventHandler.handleEvent(
{
type: 'thinking.delta',
agentId: 'main',
sessionId: 'ses-1',
delta: '',
} as Event,
vi.fn(),
);
}
// The moon must stay up: still waiting, no orphan thinking component, and
// the activity pane still renders a moon frame (no blank, spinner-less gap).
expect(driver.state.appState.streamingPhase).toBe('waiting');
expect(driver.state.livePane.mode).toBe('waiting');
expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(false);
const activity = stripSgr(renderActivity(driver));
expect(MOON_SPINNER_FRAMES.some((frame) => activity.includes(frame))).toBe(true);
// Real thinking text finally arrives -> transition into thinking mode.
driver.sessionEventHandler.handleEvent(
{
type: 'thinking.delta',
agentId: 'main',
sessionId: 'ses-1',
delta: 'actual reasoning',
} as Event,
vi.fn(),
);
driver.streamingUI.flushNow();
expect(driver.state.appState.streamingPhase).toBe('thinking');
expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(true);
});
it('finalizes an orphaned thinking component on turn end', async () => {
const { driver } = await makeDriver();
driver.state.appState.streamingPhase = 'thinking';