mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
fix(tui): stop thinking spinner leaking past turn end on empty deltas (#97)
* fix: stop thinking spinner leaking past turn end on empty deltas When a provider emits an empty thinking delta (e.g. Anthropic signature_delta -> think: ""), a ThinkingComponent was created with a running spinner but thinkingDraft stayed empty. Subsequent calls to flushThinkingToTranscript guarded on thinkingDraft.length and returned early without calling onThinkingEnd(), leaking the spinner past the turn end. Two fixes: - onThinkingUpdate: skip component creation for empty text when no existing component needs updating (source prevention). - flushThinkingToTranscript: finalize any orphaned component even when thinkingDraft is empty (defensive cleanup). * chore: add changeset for thinking spinner fix * refactor(tui): simplify flushThinkingToTranscript --------- Co-authored-by: liruifengv <liruifeng1024@gmail.com>
This commit is contained in:
parent
d1c381f38a
commit
2e8c417818
3 changed files with 66 additions and 4 deletions
5
.changeset/fix-thinking-spinner-leak.md
Normal file
5
.changeset/fix-thinking-spinner-leak.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Fix thinking spinner leaking past turn end when an empty thinking delta creates an orphaned thinking component.
|
||||
|
|
@ -1925,12 +1925,11 @@ export class KimiTUI {
|
|||
}
|
||||
|
||||
// Finalizes live thinking output and moves the live pane to the next mode.
|
||||
// onThinkingEnd() is safe to call even when no component exists (it no-ops
|
||||
// when activeThinkingComponent is undefined), and clearing an already-empty
|
||||
// thinkingDraft is harmless, so we can unconditionally clean up.
|
||||
private flushThinkingToTranscript(nextMode: LivePaneState['mode'] = 'idle'): void {
|
||||
this.flushStreamingUiUpdatesNow();
|
||||
if (this.state.thinkingDraft.length === 0) {
|
||||
this.patchLivePane({ mode: nextMode });
|
||||
return;
|
||||
}
|
||||
this.state.thinkingDraft = '';
|
||||
this.onThinkingEnd();
|
||||
this.patchLivePane({ mode: nextMode });
|
||||
|
|
@ -3717,6 +3716,9 @@ export class KimiTUI {
|
|||
|
||||
// Creates or updates the live thinking transcript component.
|
||||
private onThinkingUpdate(fullText: string): void {
|
||||
// Avoid creating a component whose spinner will never stop when the text
|
||||
// is empty and there is no existing component to update.
|
||||
if (fullText.length === 0 && this.state.activeThinkingComponent === undefined) return;
|
||||
if (this.state.activeThinkingComponent === undefined) {
|
||||
this.state.pendingAgentGroup = null;
|
||||
this.state.pendingReadGroup = null;
|
||||
|
|
|
|||
|
|
@ -1503,6 +1503,61 @@ describe('KimiTUI message flow', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not create a thinking component for empty thinking deltas', async () => {
|
||||
const { driver } = await makeDriver();
|
||||
driver.state.appState.isStreaming = true;
|
||||
driver.state.appState.streamingStartTime = 1;
|
||||
|
||||
// An empty thinking delta — as emitted by providers like Anthropic
|
||||
// (signature_delta → think: '') — must not create a ThinkingComponent
|
||||
// whose spinner would leak past turn end.
|
||||
driver.handleEvent(
|
||||
{
|
||||
type: 'thinking.delta',
|
||||
agentId: 'main',
|
||||
sessionId: 'ses-1',
|
||||
delta: '',
|
||||
} as Event,
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(driver.state.activeThinkingComponent).toBeUndefined();
|
||||
});
|
||||
|
||||
it('finalizes an orphaned thinking component on turn end', async () => {
|
||||
const { driver } = await makeDriver();
|
||||
driver.state.appState.isStreaming = true;
|
||||
driver.state.appState.streamingStartTime = 1;
|
||||
const sendQueued = vi.fn();
|
||||
|
||||
// Simulate a ThinkingComponent that leaked into state without
|
||||
// corresponding thinkingDraft content (the exact scenario that
|
||||
// could happen without the empty-delta guard above).
|
||||
const { ThinkingComponent } = await import('../../src/tui/components/messages/thinking');
|
||||
driver.state.activeThinkingComponent = new ThinkingComponent(
|
||||
'',
|
||||
driver.state.theme.colors,
|
||||
true,
|
||||
'live',
|
||||
driver.state.ui,
|
||||
);
|
||||
|
||||
driver.handleEvent(
|
||||
{
|
||||
type: 'turn.ended',
|
||||
agentId: 'main',
|
||||
sessionId: 'ses-1',
|
||||
turnId: 1,
|
||||
reason: 'completed',
|
||||
} as Event,
|
||||
sendQueued,
|
||||
);
|
||||
|
||||
// flushThinkingToTranscript must finalize the component even when
|
||||
// thinkingDraft is empty, so the spinner does not outlive the turn.
|
||||
expect(driver.state.activeThinkingComponent).toBeUndefined();
|
||||
});
|
||||
|
||||
it('renders newly streamed thinking expanded when ctrl+o toggle was already active', async () => {
|
||||
const { driver } = await makeDriver();
|
||||
driver.state.toolOutputExpanded = true;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue