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: '' } : {}),