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
This commit is contained in:
程序员阿江(Relakkes) 2026-04-23 12:11:30 +08:00
parent 48da5ccbfa
commit 90c3db1790
2 changed files with 74 additions and 2 deletions

View File

@ -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: {

View File

@ -511,9 +511,16 @@ export const useChatStore = create<ChatStore>((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: '' } : {}),