mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-31 16:33:34 +08:00
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:
parent
c712f52858
commit
efb8b5c9b4
@ -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()
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user