fix(desktop): keep a streamed thinking block whole while a background agent works (#1108)

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)
This commit is contained in:
程序员阿江(Relakkes) 2026-07-27 00:30:59 +08:00
parent c712f52858
commit efb8b5c9b4
2 changed files with 135 additions and 4 deletions

View File

@ -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<typeof useChatStore.getState>) {
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<typeof useChatStore.getState>, 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()

View File

@ -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<ChatStore>((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',