From 90c3db179010f93cd5c36ec61cbd25d76f532a5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 23 Apr 2026 12:11:30 +0800 Subject: [PATCH] Prevent task-progress updates from corrupting streamed reply formatting Desktop chat currently reuses one status channel for assistant streaming and background task progress. When a task update arrived mid-turn, the store flushed the in-flight markdown into a completed message, which caused the remaining deltas to render as a second bubble and broke markdown styling. Keep the reply in streaming state until the turn actually becomes idle, and lock the behavior with a regression test that injects task progress between text deltas. Constraint: Background task progress currently shares the same status channel as assistant streaming Rejected: Retune markdown renderer styles | would hide the symptom without preserving message integrity Confidence: high Scope-risk: narrow Reversibility: clean Directive: Do not flush streaming assistant text on non-idle status transitions unless the turn is actually ending Tested: bun run test src/stores/chatStore.test.ts; bun run lint Not-tested: Manual desktop UI verification with a live agent session --- desktop/src/stores/chatStore.test.ts | 65 ++++++++++++++++++++++++++++ desktop/src/stores/chatStore.ts | 11 ++++- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index aab57fdc..511d54ef 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -618,6 +618,71 @@ describe('chatStore history mapping', () => { vi.useRealTimers() }) + it('does not split one streamed markdown reply when task progress arrives mid-stream', () => { + vi.useFakeTimers() + + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: { + messages: [], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'content_start', + blockType: 'text', + }) + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'content_delta', + text: '1. **`core/audio/waveform.py:19-31`** — 同步阻塞 I/O。', + }) + vi.advanceTimersByTime(60) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'status', + state: 'tool_executing', + verb: 'Task in progress', + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'content_delta', + text: ' 建议直接用 `subprocess.PIPE` 流式处理。', + }) + vi.advanceTimersByTime(60) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'message_complete', + usage: { input_tokens: 1, output_tokens: 2 }, + }) + + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([ + { + type: 'assistant_text', + content: + '1. **`core/audio/waveform.py:19-31`** — 同步阻塞 I/O。 建议直接用 `subprocess.PIPE` 流式处理。', + }, + ]) + + vi.runOnlyPendingTimers() + vi.useRealTimers() + }) + it('sends Computer Use approval payloads back over websocket', () => { useChatStore.setState({ sessions: { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 5c78f7d7..b4289781 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -511,9 +511,16 @@ export const useChatStore = create((set, get) => ({ case 'status': update((session) => { const pendingText = `${session.streamingText}${consumePendingDelta()}` - const shouldFlush = pendingText.trim() && session.chatState === 'streaming' && msg.state !== 'streaming' + const hasPendingStreamText = + session.chatState === 'streaming' && pendingText.trim().length > 0 + // Background task progress can arrive while the assistant is still + // streaming one markdown reply. Keep that turn intact so we do not + // split formatting markers (for example backticks/strong markers) + // across separate bubbles. + const preserveStreamingTurn = hasPendingStreamText && msg.state !== 'idle' + const shouldFlush = hasPendingStreamText && msg.state === 'idle' return { - chatState: msg.state, + chatState: preserveStreamingTurn ? 'streaming' : msg.state, ...(msg.verb && msg.verb !== 'Thinking' ? { statusVerb: msg.verb } : {}), ...(msg.tokens ? { tokenUsage: { ...session.tokenUsage, output_tokens: msg.tokens } } : {}), ...(msg.state === 'idle' ? { activeThinkingId: null, statusVerb: '' } : {}),