From efb8b5c9b45807a59d90824b6526590084d4a421 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: Mon, 27 Jul 2026 00:30:59 +0800 Subject: [PATCH] fix(desktop): keep a streamed thinking block whole while a background agent works (#1108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One continuous thinking block arrived in the chat split across several `已思考` bubbles, with nothing rendered between them. The report blamed the model — DeepSeek — but the provider is not involved: the trace holds a single well-formed thinking block, and reopening the session restores it, because history is rebuilt from the transcript's own blocks. The split comes from three pieces that are each correct alone. A background (async) agent's tool activity is re-emitted as a normal `tool_use_complete` / `tool_result` carrying `parentToolUseId` (`handler.ts`, added in 1c554dc30 so background subagents stop showing "no tool activity"). Those land at the end of `messages`. `MessageList` then folds them into the parent agent card rather than rendering them inline. But the merge test for a streamed thinking chunk asked whether the *array tail* was thinking — and the tail was now a child tool call, so every chunk after one started a new bubble. The split points are the moments background activity arrived, which is why they look arbitrary and why the gaps between bubbles are empty. `findStreamMergeTargetIndex` skips those bubbled-child messages and merges against the last real main-stream message instead. The same tail test backed `appendAssistantTextMessage`, so a reply could be chopped the same way; both now share the helper. Reproducing needs thinking mode, a `run_in_background` agent, and child activity landing mid-thought — rare enough that most sessions never see it, and near-certain for a slow reasoning model made to wait on agents. The screenshot shows exactly that: four subagents running, and the mangled thought reads "The agents are still running. Let me wait a bit." Covered by three tests: thinking stays one block across interleaved child activity, a reply stays one block, and the main agent's own tool call still opens a new block. Verified against the reproduction before and after. Desktop suite 3050 tests green, tsc and build clean. (cherry picked from commit 7d8a22f9465b8f987193dda7af5cae3858c481fc) --- desktop/src/stores/chatStore.test.ts | 109 +++++++++++++++++++++++++++ desktop/src/stores/chatStore.ts | 30 +++++++- 2 files changed, 135 insertions(+), 4 deletions(-) diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index ca0f499d..15d97b1b 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -270,6 +270,115 @@ describe('chatStore tool settlement', () => { }) }) +// #1108: a background (async) agent's tool activity bubbles into the main +// message stream carrying parentToolUseId, but MessageList folds those into +// the agent card instead of rendering them inline. Merging streamed blocks +// against the raw array tail therefore chopped one continuous thinking block +// (or reply) into several, with nothing visible in between. +describe('chatStore background agent activity interleaving', () => { + beforeEach(() => { + useChatStore.setState({ + ...initialState, + sessions: { [TEST_SESSION_ID]: makeSession() }, + }) + }) + + function startBackgroundAgent(store: ReturnType) { + store.handleServerMessage(TEST_SESSION_ID, { + type: 'tool_use_complete', + toolName: 'Task', + toolUseId: 'agent-1', + input: { description: 'Explore project', run_in_background: true }, + }) + } + + function emitChildToolActivity(store: ReturnType, id: string) { + store.handleServerMessage(TEST_SESSION_ID, { + type: 'tool_use_complete', + toolName: 'Grep', + toolUseId: id, + input: { pattern: 'needle' }, + parentToolUseId: 'agent-1', + }) + store.handleServerMessage(TEST_SESSION_ID, { + type: 'tool_result', + toolUseId: id, + content: 'match', + isError: false, + parentToolUseId: 'agent-1', + }) + } + + it('keeps one thinking block while a background agent streams tool activity', () => { + const store = useChatStore.getState() + startBackgroundAgent(store) + + store.handleServerMessage(TEST_SESSION_ID, { type: 'thinking', text: 'The agents ' }) + emitChildToolActivity(store, 'child-grep-1') + store.handleServerMessage(TEST_SESSION_ID, { type: 'thinking', text: 'are still running. ' }) + emitChildToolActivity(store, 'child-grep-2') + store.handleServerMessage(TEST_SESSION_ID, { type: 'thinking', text: 'Let me wait a bit.' }) + + const messages = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages ?? [] + const thinking = messages.filter((message) => message.type === 'thinking') + expect(thinking).toHaveLength(1) + expect(thinking[0]).toMatchObject({ + content: 'The agents are still running. Let me wait a bit.', + }) + }) + + it('keeps one assistant reply while a background agent streams tool activity', () => { + const store = useChatStore.getState() + startBackgroundAgent(store) + + store.handleServerMessage(TEST_SESSION_ID, { type: 'content_start', blockType: 'text' }) + store.handleServerMessage(TEST_SESSION_ID, { type: 'content_delta', text: 'First half. ' }) + store.handleServerMessage(TEST_SESSION_ID, { + type: 'message_complete', + usage: { input_tokens: 1, output_tokens: 1 }, + }) + emitChildToolActivity(store, 'child-grep-3') + store.handleServerMessage(TEST_SESSION_ID, { type: 'content_start', blockType: 'text' }) + store.handleServerMessage(TEST_SESSION_ID, { type: 'content_delta', text: 'Second half.' }) + store.handleServerMessage(TEST_SESSION_ID, { + type: 'message_complete', + usage: { input_tokens: 1, output_tokens: 1 }, + }) + + const messages = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages ?? [] + const assistantText = messages.filter((message) => message.type === 'assistant_text') + expect(assistantText).toHaveLength(1) + expect(assistantText[0]).toMatchObject({ content: 'First half. Second half.' }) + }) + + it('still starts a new thinking block after the main agent runs its own tool', () => { + const store = useChatStore.getState() + + store.handleServerMessage(TEST_SESSION_ID, { type: 'thinking', text: 'Before the tool.' }) + store.handleServerMessage(TEST_SESSION_ID, { + type: 'tool_use_complete', + toolName: 'Read', + toolUseId: 'read-1', + input: { file_path: '/a.md' }, + }) + store.handleServerMessage(TEST_SESSION_ID, { + type: 'tool_result', + toolUseId: 'read-1', + content: 'contents', + isError: false, + }) + store.handleServerMessage(TEST_SESSION_ID, { type: 'thinking', text: 'After the tool.' }) + + const messages = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages ?? [] + const thinking = messages.filter((message) => message.type === 'thinking') + expect(thinking).toHaveLength(2) + expect(thinking.map((message) => message.content)).toEqual([ + 'Before the tool.', + 'After the tool.', + ]) + }) +}) + describe('chatStore history mapping', () => { beforeEach(() => { sendMock.mockReset() diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 04b51772..c4e0f114 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -557,6 +557,24 @@ function clearPendingToolInputDelta(sessionId: string): void { pendingToolInputDeltaBySession.delete(sessionId) } +/** + * 后台(异步)子 agent 的工具活动会带着 parentToolUseId 冒泡进主消息流,但 + * 渲染层把它们折叠进父 agent 卡片、不在主流单独显示(MessageList 的 + * childToolCallsByParent)。合并流式块时必须跳过这些"隐形"消息去看真正的上一 + * 条主流消息,否则主 agent 一段连续的 thinking / 正文会被它们切成好几块 —— + * 块之间还什么都不显示(#1108)。 + */ +function findStreamMergeTargetIndex(messages: UIMessage[]): number { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]! + const isBubbledChildActivity = + (message.type === 'tool_use' || message.type === 'tool_result') && + Boolean(message.parentToolUseId) + if (!isBubbledChildActivity) return index + } + return -1 +} + function appendAssistantTextMessage( messages: UIMessage[], content: string, @@ -567,7 +585,8 @@ function appendAssistantTextMessage( const trimmedContent = content.trim() if (!trimmedContent) return messages - const last = messages[messages.length - 1] + const lastIndex = findStreamMergeTargetIndex(messages) + const last = lastIndex >= 0 ? messages[lastIndex] : undefined // Wake/reconnect replay can resend persisted assistant text without a // transcript id. Ignore chunks that are already present in the hydrated tail. if ( @@ -595,7 +614,9 @@ function appendAssistantTextMessage( ? { transcriptMessageId: transcriptMessageId ?? last.transcriptMessageId } : {}), } - return [...messages.slice(0, -1), merged] + const next = [...messages] + next[lastIndex] = merged + return next } return [ @@ -2173,10 +2194,11 @@ export const useChatStore = create((set, get) => ({ const base = pendingText.trim() ? appendAssistantTextMessage(s.messages, pendingText, Date.now()) : s.messages - const last = base[base.length - 1] + const lastIndex = findStreamMergeTargetIndex(base) + const last = lastIndex >= 0 ? base[lastIndex] : undefined if (last && last.type === 'thinking') { const updated = [...base] - updated[updated.length - 1] = { ...last, content: last.content + msg.text } + updated[lastIndex] = { ...last, content: last.content + msg.text } return { messages: updated, chatState: 'thinking',