mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Keep mid-turn follow-up tool activity aligned with the latest user message
The desktop transcript renderer was continuing to inline later nested tool calls under an older parent card even after a new user message had been submitted mid-session. That made the session timeline diverge from CLI behavior, where queued user input becomes the boundary for the next tool round. This change rebuilds the render model around visible message boundaries so nested tool calls only stay attached to a parent within the same contiguous segment. A regression test now covers the interleaved user-message case. Constraint: Preserve existing grouped tool rendering for contiguous parent-child tool activity Rejected: Rework the websocket/session pipeline to synthesize extra transcript events | the bug was in render ordering, not transport semantics Confidence: high Scope-risk: narrow Reversibility: clean Directive: If mid-turn follow-up prompts change again, validate both render ordering and nested child attachment rules together Tested: bun run test --run src/components/chat/MessageList.test.tsx; bun run lint Not-tested: Full desktop app manual session playback against a live running tool turn
This commit is contained in:
parent
5f86585c34
commit
df8db2c1ff
@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { MessageList, buildRenderItems } from './MessageList'
|
||||
import { MessageList, buildRenderModel } from './MessageList'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import type { UIMessage } from '../../types/chat'
|
||||
@ -117,14 +117,66 @@ describe('MessageList nested tool calls', () => {
|
||||
},
|
||||
]
|
||||
|
||||
const toolUseIds = new Set(messages.filter((message) => message.type === 'tool_use').map((message) => message.toolUseId))
|
||||
const renderItems = buildRenderItems(messages, toolUseIds)
|
||||
const { renderItems } = buildRenderModel(messages)
|
||||
const toolGroups = renderItems.filter((item) => item.kind === 'tool_group')
|
||||
|
||||
expect(toolGroups).toHaveLength(2)
|
||||
expect(toolGroups.map((item) => item.toolCalls[0]?.toolUseId)).toEqual(['agent-1', 'write-1'])
|
||||
})
|
||||
|
||||
it('keeps later nested tool calls after an interleaved user message', () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: 'tool-agent',
|
||||
type: 'tool_use',
|
||||
toolName: 'Agent',
|
||||
toolUseId: 'agent-1',
|
||||
input: { description: 'Inspect src/components' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'tool-read',
|
||||
type: 'tool_use',
|
||||
toolName: 'Read',
|
||||
toolUseId: 'read-1',
|
||||
input: { file_path: '/tmp/example.ts' },
|
||||
timestamp: 2,
|
||||
parentToolUseId: 'agent-1',
|
||||
},
|
||||
{
|
||||
id: 'user-follow-up',
|
||||
type: 'user_text',
|
||||
content: '顺便把刚才的问题也处理掉',
|
||||
timestamp: 3,
|
||||
},
|
||||
{
|
||||
id: 'tool-write',
|
||||
type: 'tool_use',
|
||||
toolName: 'Write',
|
||||
toolUseId: 'write-1',
|
||||
input: { file_path: '/tmp/out.ts', content: 'export const value = 1' },
|
||||
timestamp: 4,
|
||||
parentToolUseId: 'agent-1',
|
||||
},
|
||||
]
|
||||
|
||||
const { renderItems, childToolCallsByParent } = buildRenderModel(messages)
|
||||
const renderedKinds = renderItems.map((item) =>
|
||||
item.kind === 'tool_group'
|
||||
? `tool:${item.toolCalls[0]?.toolUseId}`
|
||||
: `message:${item.message.id}`,
|
||||
)
|
||||
|
||||
expect(renderedKinds).toEqual([
|
||||
'tool:agent-1',
|
||||
'message:user-follow-up',
|
||||
'tool:write-1',
|
||||
])
|
||||
expect(
|
||||
(childToolCallsByParent.get('agent-1') ?? []).map((toolCall) => toolCall.toolUseId),
|
||||
).toEqual(['read-1'])
|
||||
})
|
||||
|
||||
it('shows failed agent status and compact unavailable summary for Explore launch errors', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
|
||||
@ -22,19 +22,58 @@ type RenderItem =
|
||||
| { kind: 'tool_group'; toolCalls: ToolCall[]; id: string }
|
||||
| { kind: 'message'; message: UIMessage }
|
||||
|
||||
export function buildRenderItems(messages: UIMessage[], toolUseIds: Set<string>): RenderItem[] {
|
||||
const items: RenderItem[] = []
|
||||
let pendingToolCalls: ToolCall[] = []
|
||||
type RenderModel = {
|
||||
renderItems: RenderItem[]
|
||||
toolResultMap: Map<string, ToolResult>
|
||||
childToolCallsByParent: Map<string, ToolCall[]>
|
||||
}
|
||||
|
||||
const flushGroup = () => {
|
||||
function appendChildToolCall(
|
||||
childToolCallsByParent: Map<string, ToolCall[]>,
|
||||
parentToolUseId: string,
|
||||
toolCall: ToolCall,
|
||||
) {
|
||||
const siblings = childToolCallsByParent.get(parentToolUseId)
|
||||
if (siblings) {
|
||||
siblings.push(toolCall)
|
||||
} else {
|
||||
childToolCallsByParent.set(parentToolUseId, [toolCall])
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRenderModel(messages: UIMessage[]): RenderModel {
|
||||
const items: RenderItem[] = []
|
||||
const toolResultMap = new Map<string, ToolResult>()
|
||||
const childToolCallsByParent = new Map<string, ToolCall[]>()
|
||||
const toolUseIds = new Set<string>()
|
||||
let pendingToolCalls: ToolCall[] = []
|
||||
const inlineParentToolUseIds = new Set<string>()
|
||||
|
||||
const flushGroup = (resetInlineParents = false) => {
|
||||
if (pendingToolCalls.length > 0) {
|
||||
items.push({
|
||||
kind: 'tool_group',
|
||||
toolCalls: [...pendingToolCalls],
|
||||
id: `group-${pendingToolCalls[0]!.id}`,
|
||||
})
|
||||
for (const toolCall of pendingToolCalls) {
|
||||
inlineParentToolUseIds.add(toolCall.toolUseId)
|
||||
}
|
||||
pendingToolCalls = []
|
||||
}
|
||||
|
||||
if (resetInlineParents) {
|
||||
inlineParentToolUseIds.clear()
|
||||
}
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.type === 'tool_use') {
|
||||
toolUseIds.add(msg.toolUseId)
|
||||
}
|
||||
if (msg.type === 'tool_result') {
|
||||
toolResultMap.set(msg.toolUseId, msg)
|
||||
}
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
@ -43,24 +82,30 @@ export function buildRenderItems(messages: UIMessage[], toolUseIds: Set<string>)
|
||||
}
|
||||
|
||||
if (msg.type === 'tool_use') {
|
||||
if (msg.parentToolUseId) {
|
||||
const parentIsPending = msg.parentToolUseId
|
||||
? pendingToolCalls.some((toolCall) => toolCall.toolUseId === msg.parentToolUseId)
|
||||
: false
|
||||
|
||||
if (msg.parentToolUseId && (inlineParentToolUseIds.has(msg.parentToolUseId) || parentIsPending)) {
|
||||
flushGroup()
|
||||
appendChildToolCall(childToolCallsByParent, msg.parentToolUseId, msg)
|
||||
inlineParentToolUseIds.add(msg.toolUseId)
|
||||
continue
|
||||
}
|
||||
if (msg.toolName === 'AskUserQuestion') {
|
||||
flushGroup()
|
||||
flushGroup(true)
|
||||
items.push({ kind: 'message', message: msg })
|
||||
} else {
|
||||
pendingToolCalls.push(msg)
|
||||
}
|
||||
} else {
|
||||
flushGroup()
|
||||
flushGroup(true)
|
||||
items.push({ kind: 'message', message: msg })
|
||||
}
|
||||
}
|
||||
|
||||
flushGroup()
|
||||
return items
|
||||
return { renderItems: items, toolResultMap, childToolCallsByParent }
|
||||
}
|
||||
|
||||
export function MessageList() {
|
||||
@ -77,31 +122,10 @@ export function MessageList() {
|
||||
bottomRef.current?.scrollIntoView?.({ behavior: 'smooth' })
|
||||
}, [messages.length, streamingText])
|
||||
|
||||
const { toolResultMap, childToolCallsByParent, renderItems } = useMemo(() => {
|
||||
const toolUseIds = new Set<string>()
|
||||
const toolResultMap = new Map<string, ToolResult>()
|
||||
const childToolCallsByParent = new Map<string, ToolCall[]>()
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.type === 'tool_use') {
|
||||
toolUseIds.add(msg.toolUseId)
|
||||
if (msg.parentToolUseId) {
|
||||
const siblings = childToolCallsByParent.get(msg.parentToolUseId)
|
||||
if (siblings) {
|
||||
siblings.push(msg)
|
||||
} else {
|
||||
childToolCallsByParent.set(msg.parentToolUseId, [msg])
|
||||
}
|
||||
}
|
||||
}
|
||||
if (msg.type === 'tool_result' && msg.toolUseId) {
|
||||
toolResultMap.set(msg.toolUseId, msg)
|
||||
}
|
||||
}
|
||||
|
||||
const renderItems = buildRenderItems(messages, toolUseIds)
|
||||
return { toolUseIds, toolResultMap, childToolCallsByParent, renderItems }
|
||||
}, [messages])
|
||||
const { toolResultMap, childToolCallsByParent, renderItems } = useMemo(
|
||||
() => buildRenderModel(messages),
|
||||
[messages],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user