diff --git a/ui/src/e2e/chat-heartbeat-activity-rollup.e2e.test.ts b/ui/src/e2e/chat-heartbeat-activity-rollup.e2e.test.ts new file mode 100644 index 000000000000..112941fa8bfe --- /dev/null +++ b/ui/src/e2e/chat-heartbeat-activity-rollup.e2e.test.ts @@ -0,0 +1,93 @@ +// Control UI E2E covers pooling reply-less wake activity (heartbeats) into one rollup row. +import fs from "node:fs/promises"; +import path from "node:path"; +import { expect, it } from "vitest"; +import { controlUiSessionUrl, installMockGateway } from "../test-helpers/control-ui-e2e.ts"; +import { waitForChatScrollIdle } from "./chat-flow.test-support.ts"; +import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; + +const suite = createControlUiE2eSuite({ + name: "Control UI heartbeat activity rollup", + startServerBeforeBrowser: true, +}); + +async function captureProof(page: import("playwright").Page, name: string) { + const artifactDir = process.env.OPENCLAW_CONTROL_UI_E2E_ARTIFACT_DIR?.trim(); + if (!artifactDir) { + return; + } + await fs.mkdir(artifactDir, { recursive: true }); + await page.screenshot({ path: path.join(artifactDir, `${name}.png`), fullPage: true }); +} + +function heartbeatWake(index: number): Array> { + const runId = `heartbeat-run-${index}`; + const callId = `heartbeat-call-${index}`; + const timestamp = 10_000 + index * 1_000; + return [ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: callId, + name: "heartbeat_respond", + arguments: { notify: false }, + }, + ], + runId, + timestamp, + }, + { + role: "toolResult", + toolCallId: callId, + toolName: "heartbeat_respond", + content: "ok", + runId, + timestamp: timestamp + 100, + }, + ]; +} + +suite.define(() => { + it("pools consecutive reply-less heartbeat wakes into one expandable rollup", async () => { + await suite.withPage({ viewport: { height: 900, width: 1200 } }, async ({ page }) => { + const sessionKey = "agent:main:dashboard:heartbeat-rollup"; + const wakeCount = 6; + await installMockGateway(page, { + sessionKey, + historyMessages: [ + { role: "user", content: "Watch the queue.", timestamp: 1_000, runId: "reply-run" }, + { + role: "assistant", + content: [{ type: "text", text: "Watching." }], + timestamp: 2_000, + runId: "reply-run", + }, + ...Array.from({ length: wakeCount }, (_, index) => heartbeatWake(index + 1)).flat(), + ], + }); + + await page.goto(controlUiSessionUrl(suite.server.baseUrl, sessionKey)); + await page.getByText("Watching.", { exact: true }).waitFor(); + await waitForChatScrollIdle(page); + + const rollup = page.locator(".chat-group--activity .chat-activity-group__summary"); + await rollup.waitFor(); + await expect + .poll(async () => rollup.locator(".chat-activity-group__label").textContent()) + .toBe(`Used Heartbeat Respond ×${wakeCount}`); + // One pooled row owns all wakes; no per-wake rows remain in the transcript. + expect(await rollup.count()).toBe(1); + expect(await page.locator(".chat-tool-row").count()).toBe(0); + await captureProof(page, "heartbeat-rollup-collapsed"); + + await rollup.click(); + await expect + .poll(async () => page.locator(".chat-activity-group__body .chat-tool-row").count()) + .toBe(wakeCount); + await waitForChatScrollIdle(page); + await captureProof(page, "heartbeat-rollup-expanded"); + }); + }); +}); diff --git a/ui/src/lib/chat/tool-call-grouping.test.ts b/ui/src/lib/chat/tool-call-grouping.test.ts index 3e1de57ed551..3626288df87d 100644 --- a/ui/src/lib/chat/tool-call-grouping.test.ts +++ b/ui/src/lib/chat/tool-call-grouping.test.ts @@ -65,7 +65,7 @@ describe("summarizeToolGroup", () => { { name: "str_replace_editor", args: { path: "/repo/a.ts" } }, { name: "str_replace_based_edit_tool", args: { command: "rename" } }, ], - "Used str_replace_editor, str_replace_based_edit_tool", + "Used Str Replace Editor, Str Replace Based Edit Tool", ], [ "multi-file apply_patch targets", @@ -115,11 +115,11 @@ describe("summarizeToolGroup", () => { ], "Deleted a file", ], - ["one generic tool by name", [{ name: "mcp__linear" }], "Used mcp__linear"], + ["one generic tool by name", [{ name: "mcp__linear" }], "Used Mcp Linear"], [ "repeat generic tool with a multiplier", - [{ name: "mcp__linear" }, { name: "mcp__linear" }], - "Used mcp__linear ×2", + [{ name: "heartbeat_respond" }, { name: "heartbeat_respond" }], + "Used Heartbeat Respond ×2", ], [ "many distinct generic tools as a count", diff --git a/ui/src/lib/chat/tool-call-grouping.ts b/ui/src/lib/chat/tool-call-grouping.ts index 02355422ab25..bea0abcea8cd 100644 --- a/ui/src/lib/chat/tool-call-grouping.ts +++ b/ui/src/lib/chat/tool-call-grouping.ts @@ -10,6 +10,7 @@ import { resolveToolCallTargetPaths, type ToolCallKind, } from "./tool-call-view.ts"; +import { resolveToolDisplay } from "./tool-display.ts"; type ToolGroupSummaryInput = { name: string; @@ -73,7 +74,9 @@ function countCard(counts: GroupCounts, card: ToolGroupSummaryInput): void { break; default: counts.others += 1; - counts.otherNames.add(card.name); + // Same display label as the standalone row, so a collapsed rollup of + // e.g. heartbeat_respond reads "Heartbeat Respond" in both shapes. + counts.otherNames.add(resolveToolDisplay({ name: card.name, args: card.args }).label); } } } diff --git a/ui/src/pages/chat/chat-thread-build.ts b/ui/src/pages/chat/chat-thread-build.ts index d26d1c5c08be..126d37730435 100644 --- a/ui/src/pages/chat/chat-thread-build.ts +++ b/ui/src/pages/chat/chat-thread-build.ts @@ -30,7 +30,7 @@ import { shouldRenderQueuedSendInThread, } from "./chat-progress.ts"; import { chatMessagesContainQueuedSend } from "./chat-send-support.ts"; -import { coalesceToolActivityMessages, groupMessages } from "./chat-thread-grouping.ts"; +import { groupMessages } from "./chat-thread-grouping.ts"; import { appendCanvasBlockToAssistantMessage, buildMessageKeys, @@ -62,6 +62,7 @@ import { optionalRunIdentity, resolveRunInsertionBounds, } from "./chat-thread-run-identity.ts"; +import { coalesceToolActivityMessages } from "./chat-tool-activity-coalesce.ts"; import { safeNormalizeMessage } from "./chat-turn-boundary.ts"; import { resolveSystemNoticeKind } from "./system-notice-kinds.ts"; import { isLiveTerminalForRun } from "./terminal-message-identity.ts"; diff --git a/ui/src/pages/chat/chat-thread-grouping.ts b/ui/src/pages/chat/chat-thread-grouping.ts index 89318d6d0de5..96d7a6d9e092 100644 --- a/ui/src/pages/chat/chat-thread-grouping.ts +++ b/ui/src/pages/chat/chat-thread-grouping.ts @@ -4,13 +4,11 @@ import { isToolCallContentType, isToolResultContentType, } from "../../../../src/chat/tool-content.js"; -import type { ChatItem, MessageGroup, ToolCard } from "../../lib/chat/chat-types.ts"; +import type { ChatItem, MessageGroup } from "../../lib/chat/chat-types.ts"; import { extractTextCached } from "../../lib/chat/message-extract.ts"; import { normalizeMessage, normalizeRoleForGrouping } from "../../lib/chat/message-normalizer.ts"; import { senderIdentityKey } from "../../lib/chat/sender-label.ts"; -import { extractToolCardsCached } from "../../lib/chat/tool-cards.ts"; import { isContextCompactionActivity } from "./chat-progress.ts"; -import { resolveMessageToolUseId, resolveToolBlockId } from "./chat-thread-items.ts"; import { isKeyedAssistantStreamFallbackMessage, streamPartBoundaryId, @@ -131,342 +129,6 @@ export function groupMessages(items: ChatItem[]): Array } return stampReplyAttribution(result); } -function mergeToolCallResultPair(callItem: ChatItem, resultItem: ChatItem): ChatItem | null { - if (callItem.kind !== "message" || resultItem.kind !== "message") { - return null; - } - const callMessage = asRecord(callItem.message); - const resultMessage = asRecord(resultItem.message); - if (!callMessage || !resultMessage) { - return null; - } - const callRole = typeof callMessage.role === "string" ? callMessage.role.toLowerCase() : ""; - const normalizedResult = safeNormalizeMessage(resultItem.message); - const resultRole = normalizedResult ? normalizeRoleForGrouping(normalizedResult.role) : "unknown"; - if (callRole !== "assistant" || resultRole !== "tool" || !Array.isArray(callMessage.content)) { - return null; - } - const hasToolCallBlock = callMessage.content.some((block) => - isToolCallContentType(asRecord(block)?.type), - ); - if (!hasToolCallBlock) { - return null; - } - - const callCards = extractToolCardsCached(callItem.message, `${callItem.key}:activity-call`); - const resultCards = extractToolCardsCached( - resultItem.message, - `${resultItem.key}:activity-result`, - ); - if (callCards.length === 0 || resultCards.length === 0) { - return null; - } - const rawResultContent = Array.isArray(resultMessage.content) ? resultMessage.content : []; - if (rawResultContent.some((block) => isToolCallContentType(asRecord(block)?.type))) { - return null; - } - const resultOnlyContent = rawResultContent.filter( - (block) => !isToolCallContentType(asRecord(block)?.type), - ); - const hasToolResultBlock = resultOnlyContent.some((block) => - isToolResultContentType(asRecord(block)?.type), - ); - const hasToolResult = - hasToolResultBlock || - resultCards.some((card) => card.outputText !== undefined || card.isError !== undefined); - if (!hasToolResult) { - return null; - } - - const unresolvedCallIds = unresolvedToolCallIds(callItem); - const matchedResults = new Map(); - for (const resultCard of resultCards) { - const callId = resultCard.callId; - if (!callId || !unresolvedCallIds.has(callId) || matchedResults.has(callId)) { - return null; - } - const callCard = callCards.find((card) => card.callId === callId); - if (!callCard) { - return null; - } - const resultName = resultCard.name === "tool" ? callCard.name : resultCard.name; - if ( - normalizeLowercaseStringOrEmpty(callCard.name) !== normalizeLowercaseStringOrEmpty(resultName) - ) { - return null; - } - matchedResults.set(callId, { resultCard, resultName }); - } - - const preservedResultContent = resultOnlyContent.filter( - (block) => asRecord(block)?.type !== "text", - ); - // Raw transcript result blocks usually carry the call id and tool name on the - // message, not the block. Stamp both onto the merged blocks (plus message-level - // details) so card extraction pairs them with the call instead of rendering a - // second bare "Tool" card. - const resultContent = hasToolResultBlock - ? resultOnlyContent.map((block) => { - const record = asRecord(block); - if (!record || !isToolResultContentType(record.type)) { - return block; - } - const callId = resolveToolBlockId(record, resultMessage); - const matched = callId ? matchedResults.get(callId) : undefined; - if (!matched) { - return block; - } - const stamped: Record = Object.assign({}, record); - stamped.id = callId; - stamped.name = - typeof record.name === "string" && record.name.trim() ? record.name : matched.resultName; - if (record.details === undefined && resultMessage.details !== undefined) { - stamped.details = resultMessage.details; - } - if ( - record.isError === undefined && - record.is_error === undefined && - matched.resultCard.isError !== undefined - ) { - stamped.isError = matched.resultCard.isError; - } - return stamped; - }) - : (() => { - const [matched] = matchedResults.values(); - if (!matched) { - return preservedResultContent; - } - return [ - { - type: "tool_result", - id: matched.resultCard.callId, - name: matched.resultName, - text: matched.resultCard.outputText ?? "", - ...(matched.resultCard.details !== undefined - ? { details: matched.resultCard.details } - : {}), - ...(matched.resultCard.isError !== undefined - ? { isError: matched.resultCard.isError } - : {}), - }, - ...preservedResultContent, - ]; - })(); - return { - ...callItem, - message: { - ...callMessage, - content: [...callMessage.content, ...resultContent], - }, - }; -} - -function unresolvedToolCallIds(item: ChatItem): Set { - const unresolved = new Set(); - if (item.kind !== "message") { - return unresolved; - } - const message = asRecord(item.message); - if ( - !message || - typeof message.role !== "string" || - message.role.toLowerCase() !== "assistant" || - !Array.isArray(message.content) - ) { - return unresolved; - } - for (const block of message.content) { - const record = asRecord(block); - if (!record) { - continue; - } - const callId = resolveToolBlockId(record, message); - if (!callId) { - continue; - } - if (isToolCallContentType(record.type)) { - unresolved.add(callId); - } else if (isToolResultContentType(record.type)) { - unresolved.delete(callId); - } - } - return unresolved; -} - -function isToolTimelineItem(item: ChatItem): boolean { - if (item.kind !== "message") { - return false; - } - const normalized = safeNormalizeMessage(item.message); - return normalized ? normalizeRoleForGrouping(normalized.role) === "tool" : false; -} - -function splitBundledToolResultItems(item: ChatItem): ChatItem[] { - if (item.kind !== "message") { - return [item]; - } - const message = asRecord(item.message); - if (!message || !Array.isArray(message.content) || message.content.length < 2) { - return [item]; - } - const blocksByCallId = new Map(); - for (const block of message.content) { - const record = asRecord(block); - if (!record || !isToolResultContentType(record.type)) { - return [item]; - } - const callId = resolveToolBlockId(record, message); - if (!callId) { - return [item]; - } - const blocks = blocksByCallId.get(callId) ?? []; - blocks.push(block); - blocksByCallId.set(callId, blocks); - } - if (blocksByCallId.size < 2) { - return [item]; - } - return Array.from(blocksByCallId.values(), (content, index) => ({ - ...item, - key: `${item.key}:result:${index}`, - message: { ...message, content }, - })); -} - -function resolveToolResultCallId(item: ChatItem): string | undefined { - if (item.kind !== "message") { - return undefined; - } - const message = asRecord(item.message); - if (!message) { - return undefined; - } - const content = Array.isArray(message.content) ? message.content : []; - if (content.some((block) => isToolCallContentType(asRecord(block)?.type))) { - return undefined; - } - const resultIds = new Set(); - for (const block of content) { - const record = asRecord(block); - if (record && isToolResultContentType(record.type)) { - const callId = resolveToolBlockId(record, message); - if (callId) { - resultIds.add(callId); - } - } - } - const resultId = resultIds.values().next().value; - return resultIds.size > 1 ? undefined : (resultId ?? resolveMessageToolUseId(message)); -} - -function refreshOpenCallIds( - openCallIndexes: Map, - coalesced: ChatItem[], - callIndex: number, -) { - for (const [callId, index] of openCallIndexes) { - if (index === callIndex) { - openCallIndexes.delete(callId); - } - } - for (const callId of unresolvedToolCallIds(coalesced[callIndex]!)) { - openCallIndexes.set(callId, callIndex); - } -} - -export function coalesceToolActivityMessages(items: ChatItem[]): ChatItem[] { - const coalesced: ChatItem[] = []; - // Defer backward-pair removal so all call-id indexes stay stable. - const suppressedIndexes = new Set(); - // Parallel calls can outnumber any fixed lookback window, so each unresolved - // call id owns its current transcript item until a non-tool boundary. - const openCallIndexes = new Map(); - // Keep earlier result slots by call id so later calls can restore complete cards. - const openResultIndexes = new Map(); - for (const item of items) { - const resultItems = splitBundledToolResultItems(item); - const unmatchedResultItems: ChatItem[] = []; - for (const resultItem of resultItems) { - const callId = resolveToolResultCallId(resultItem); - const callIndex = callId ? openCallIndexes.get(callId) : undefined; - const callItem = callIndex === undefined ? undefined : coalesced[callIndex]; - const merged = - callIndex === undefined || !callItem ? null : mergeToolCallResultPair(callItem, resultItem); - if (!merged || callIndex === undefined) { - unmatchedResultItems.push(resultItem); - continue; - } - coalesced[callIndex] = merged; - refreshOpenCallIds(openCallIndexes, coalesced, callIndex); - } - const hasMergedResult = unmatchedResultItems.length < resultItems.length; - if (hasMergedResult || resultItems.length > 1) { - const orphanResults = hasMergedResult ? unmatchedResultItems : resultItems; - for (const orphanResult of orphanResults) { - const callId = resolveToolResultCallId(orphanResult); - if (callId) { - openResultIndexes.set(callId, openResultIndexes.get(callId) ?? coalesced.length); - } - coalesced.push(orphanResult); - } - continue; - } - - const unresolvedCallIds = unresolvedToolCallIds(item); - let backwardMerged = item; - const matchedResultIndexes: number[] = []; - for (const callId of unresolvedCallIds) { - const resultIndex = openResultIndexes.get(callId); - const orphanResult = resultIndex === undefined ? undefined : coalesced[resultIndex]; - const merged = orphanResult ? mergeToolCallResultPair(backwardMerged, orphanResult) : null; - if (merged && resultIndex !== undefined) { - backwardMerged = merged; - matchedResultIndexes.push(resultIndex); - openResultIndexes.delete(callId); - } - } - if (matchedResultIndexes.length > 0) { - const resultIndex = Math.min(...matchedResultIndexes); - coalesced[resultIndex] = backwardMerged; - matchedResultIndexes.forEach((index) => suppressedIndexes.add(index)); - suppressedIndexes.delete(resultIndex); - refreshOpenCallIds(openCallIndexes, coalesced, resultIndex); - continue; - } - if (unresolvedCallIds.size === 1) { - const callId = unresolvedCallIds.values().next().value; - const previousIndex = callId ? openCallIndexes.get(callId) : undefined; - const previous = previousIndex === undefined ? undefined : coalesced[previousIndex]; - if (previousIndex !== undefined && previous && unresolvedToolCallIds(previous).size === 1) { - coalesced[previousIndex] = item; - refreshOpenCallIds(openCallIndexes, coalesced, previousIndex); - continue; - } - } - - coalesced.push(item); - if (unresolvedCallIds.size > 0) { - const callIndex = coalesced.length - 1; - for (const callId of unresolvedCallIds) { - openCallIndexes.set(callId, callIndex); - } - continue; - } - if (isToolTimelineItem(item)) { - // Orphan results keep the window open for later siblings. - const callId = resolveToolResultCallId(item); - if (callId) { - openResultIndexes.set(callId, openResultIndexes.get(callId) ?? coalesced.length - 1); - } - continue; - } - // Any other content (user text, assistant reply, dividers) closes the run. - openCallIndexes.clear(); - openResultIndexes.clear(); - } - return coalesced.filter((_, index) => !suppressedIndexes.has(index)); -} type RenderChatItem = ChatItem | MessageGroup; export type StreamRunRenderItem = { @@ -730,6 +392,31 @@ export function collapseCompletedTurnWork( export type CompletedTurnRenderItem = TurnRenderItem | WorkGroupRenderItem; +// Runs whose transcript shows any reply/stream content keep their activity +// separate per run (one run, one response); only fully reply-less runs — e.g. +// heartbeat wakes that just call their response tool — may pool across runs. +function runIdsWithVisibleReplies(items: CompletedTurnRenderItem[]): Set { + const replyRunIds = new Set(); + for (const item of items) { + if (item.kind === "stream-run") { + if (item.runId) { + replyRunIds.add(item.runId); + } + continue; + } + if (item.kind !== "group" || item.runId === undefined) { + continue; + } + // Tool-group text is the tool's own output shown inside the card, never a + // reply; assistant/user text is the run's visible response. + const includeText = item.role.toLowerCase() !== "tool"; + if (item.isStreaming || groupHasVisibleReplyContent(item, includeText)) { + replyRunIds.add(item.runId); + } + } + return replyRunIds; +} + /** Presentation-only rollup for tool groups separated by projected turn boundaries. */ export function coalesceActivityRuns( items: CompletedTurnRenderItem[], @@ -738,6 +425,22 @@ export function coalesceActivityRuns( if (opts.searchActive) { return items; } + const replyRunIds = runIdsWithVisibleReplies(items); + // A group is its run's entire visible outcome when the run never produced a + // reply. Consecutive such runs (heartbeats, cron wakes) collapse into one + // activity rollup instead of stacking identical rows down the transcript. + const isReplyLessRunActivity = (group: MessageGroup): boolean => { + const role = group.role.toLowerCase(); + return ( + !group.isStreaming && + group.runId !== undefined && + !replyRunIds.has(group.runId) && + (role === "tool" || (role === "assistant" && !assistantGroupIsForwardedBoundary(group))) && + // includeText=false: any assistant text already marked the run as replied + // above; here only non-tool blocks (media/attachments) block pooling. + !groupHasVisibleReplyContent(group, false) + ); + }; const result: Array = []; let groups: MessageGroup[] = []; const flush = () => { @@ -751,8 +454,14 @@ export function coalesceActivityRuns( groups = []; }; for (const item of items) { - if (item.kind === "group" && item.role.toLowerCase() === "tool") { - if (groups.length > 0 && groups[0]?.runId !== item.runId) { + const replyLessRunActivity = item.kind === "group" && isReplyLessRunActivity(item); + if (item.kind === "group" && (item.role.toLowerCase() === "tool" || replyLessRunActivity)) { + const tail = groups[groups.length - 1]; + if ( + tail && + tail.runId !== item.runId && + !(replyLessRunActivity && isReplyLessRunActivity(tail)) + ) { flush(); } groups.push(item); diff --git a/ui/src/pages/chat/chat-thread.test.ts b/ui/src/pages/chat/chat-thread.test.ts index 019e6dd659e2..f7cf34e6bb0e 100644 --- a/ui/src/pages/chat/chat-thread.test.ts +++ b/ui/src/pages/chat/chat-thread.test.ts @@ -1170,12 +1170,82 @@ describe("coalesceActivityRuns", () => { expect(appended.key).toBe(initial.key); }); - it("keeps adjacent tool activity from different runs separate", () => { + it("keeps adjacent tool activity separate when a run has a visible reply", () => { const groups = projectedToolGroups(); const first = { ...groups[0]!, runId: "run-1" }; const second = { ...groups[1]!, runId: "run-2" }; + const reply: MessageGroup = { + kind: "group", + key: "group:assistant:reply", + role: "assistant", + messages: [{ key: "assistant:reply", message: assistantMessage("Done.", 3_500) }], + timestamp: 3_500, + isStreaming: false, + runId: "run-2", + }; - expect(coalesceActivityRuns([first, second])).toEqual([first, second]); + expect(coalesceActivityRuns([first, second, reply])).toEqual([first, second, reply]); + }); + + it("pools consecutive reply-less runs' activity into one rollup", () => { + const groups = projectedToolGroups(); + const runs = groups.map((group, index) => + Object.assign({}, group, { runId: `run-${index + 1}` }), + ); + const projected = coalesceActivityRuns(runs); + const run = requireActivityRun(projected[0]); + + expect(projected).toHaveLength(1); + expect(run.groups).toEqual(runs); + }); + + it("pools reply-less assistant tool activity like heartbeat wakes", () => { + const heartbeatGroup = (index: number): MessageGroup => ({ + kind: "group", + key: `group:assistant:hb-${index}`, + role: "assistant", + messages: [ + { + key: `hb-${index}`, + message: assistantMessage( + [ + { + type: "toolCall", + id: `hb-call-${index}`, + name: "heartbeat_respond", + arguments: {}, + }, + { type: "toolResult", id: `hb-call-${index}`, name: "heartbeat_respond", text: "ok" }, + ], + 1_000 * index, + { runId: `hb-run-${index}` }, + ), + }, + ], + timestamp: 1_000 * index, + isStreaming: false, + runId: `hb-run-${index}`, + }); + const beats = [heartbeatGroup(1), heartbeatGroup(2), heartbeatGroup(3)]; + const projected = coalesceActivityRuns(beats); + const run = requireActivityRun(projected[0]); + + expect(projected).toHaveLength(1); + expect(run.groups).toEqual(beats); + }); + + it("keeps a live run's activity out of the reply-less pool", () => { + const groups = projectedToolGroups(); + const first = { ...groups[0]!, runId: "run-1" }; + const live = { ...groups[1]!, runId: "run-2" }; + const streamRun = { + kind: "stream-run" as const, + key: "stream-run:live", + runId: "run-2", + parts: [], + }; + + expect(coalesceActivityRuns([first, live, streamRun])).toEqual([first, live, streamRun]); }); it("treats every non-tool item as a hard presentation boundary", () => { diff --git a/ui/src/pages/chat/chat-tool-activity-coalesce.ts b/ui/src/pages/chat/chat-tool-activity-coalesce.ts new file mode 100644 index 000000000000..a879df6c255e --- /dev/null +++ b/ui/src/pages/chat/chat-tool-activity-coalesce.ts @@ -0,0 +1,351 @@ +// Pairs assistant tool-call transcript items with their tool-result siblings so +// each call renders as one complete card instead of a call row plus a bare +// result row. Split from chat-thread-grouping.ts, which owns row grouping. +import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import { + isToolCallContentType, + isToolResultContentType, +} from "../../../../src/chat/tool-content.js"; +import type { ChatItem, ToolCard } from "../../lib/chat/chat-types.ts"; +import { normalizeRoleForGrouping } from "../../lib/chat/message-normalizer.ts"; +import { extractToolCardsCached } from "../../lib/chat/tool-cards.ts"; +import { resolveMessageToolUseId, resolveToolBlockId } from "./chat-thread-items.ts"; +import { safeNormalizeMessage } from "./chat-turn-boundary.ts"; + +function mergeToolCallResultPair(callItem: ChatItem, resultItem: ChatItem): ChatItem | null { + if (callItem.kind !== "message" || resultItem.kind !== "message") { + return null; + } + const callMessage = asRecord(callItem.message); + const resultMessage = asRecord(resultItem.message); + if (!callMessage || !resultMessage) { + return null; + } + const callRole = typeof callMessage.role === "string" ? callMessage.role.toLowerCase() : ""; + const normalizedResult = safeNormalizeMessage(resultItem.message); + const resultRole = normalizedResult ? normalizeRoleForGrouping(normalizedResult.role) : "unknown"; + if (callRole !== "assistant" || resultRole !== "tool" || !Array.isArray(callMessage.content)) { + return null; + } + const hasToolCallBlock = callMessage.content.some((block) => + isToolCallContentType(asRecord(block)?.type), + ); + if (!hasToolCallBlock) { + return null; + } + + const callCards = extractToolCardsCached(callItem.message, `${callItem.key}:activity-call`); + const resultCards = extractToolCardsCached( + resultItem.message, + `${resultItem.key}:activity-result`, + ); + if (callCards.length === 0 || resultCards.length === 0) { + return null; + } + const rawResultContent = Array.isArray(resultMessage.content) ? resultMessage.content : []; + if (rawResultContent.some((block) => isToolCallContentType(asRecord(block)?.type))) { + return null; + } + const resultOnlyContent = rawResultContent.filter( + (block) => !isToolCallContentType(asRecord(block)?.type), + ); + const hasToolResultBlock = resultOnlyContent.some((block) => + isToolResultContentType(asRecord(block)?.type), + ); + const hasToolResult = + hasToolResultBlock || + resultCards.some((card) => card.outputText !== undefined || card.isError !== undefined); + if (!hasToolResult) { + return null; + } + + const unresolvedCallIds = unresolvedToolCallIds(callItem); + const matchedResults = new Map(); + for (const resultCard of resultCards) { + const callId = resultCard.callId; + if (!callId || !unresolvedCallIds.has(callId) || matchedResults.has(callId)) { + return null; + } + const callCard = callCards.find((card) => card.callId === callId); + if (!callCard) { + return null; + } + const resultName = resultCard.name === "tool" ? callCard.name : resultCard.name; + if ( + normalizeLowercaseStringOrEmpty(callCard.name) !== normalizeLowercaseStringOrEmpty(resultName) + ) { + return null; + } + matchedResults.set(callId, { resultCard, resultName }); + } + + const preservedResultContent = resultOnlyContent.filter( + (block) => asRecord(block)?.type !== "text", + ); + // Raw transcript result blocks usually carry the call id and tool name on the + // message, not the block. Stamp both onto the merged blocks (plus message-level + // details) so card extraction pairs them with the call instead of rendering a + // second bare "Tool" card. + const resultContent = hasToolResultBlock + ? resultOnlyContent.map((block) => { + const record = asRecord(block); + if (!record || !isToolResultContentType(record.type)) { + return block; + } + const callId = resolveToolBlockId(record, resultMessage); + const matched = callId ? matchedResults.get(callId) : undefined; + if (!matched) { + return block; + } + const stamped: Record = Object.assign({}, record); + stamped.id = callId; + stamped.name = + typeof record.name === "string" && record.name.trim() ? record.name : matched.resultName; + if (record.details === undefined && resultMessage.details !== undefined) { + stamped.details = resultMessage.details; + } + if ( + record.isError === undefined && + record.is_error === undefined && + matched.resultCard.isError !== undefined + ) { + stamped.isError = matched.resultCard.isError; + } + return stamped; + }) + : (() => { + const [matched] = matchedResults.values(); + if (!matched) { + return preservedResultContent; + } + return [ + { + type: "tool_result", + id: matched.resultCard.callId, + name: matched.resultName, + text: matched.resultCard.outputText ?? "", + ...(matched.resultCard.details !== undefined + ? { details: matched.resultCard.details } + : {}), + ...(matched.resultCard.isError !== undefined + ? { isError: matched.resultCard.isError } + : {}), + }, + ...preservedResultContent, + ]; + })(); + return { + ...callItem, + message: { + ...callMessage, + content: [...callMessage.content, ...resultContent], + }, + }; +} + +function unresolvedToolCallIds(item: ChatItem): Set { + const unresolved = new Set(); + if (item.kind !== "message") { + return unresolved; + } + const message = asRecord(item.message); + if ( + !message || + typeof message.role !== "string" || + message.role.toLowerCase() !== "assistant" || + !Array.isArray(message.content) + ) { + return unresolved; + } + for (const block of message.content) { + const record = asRecord(block); + if (!record) { + continue; + } + const callId = resolveToolBlockId(record, message); + if (!callId) { + continue; + } + if (isToolCallContentType(record.type)) { + unresolved.add(callId); + } else if (isToolResultContentType(record.type)) { + unresolved.delete(callId); + } + } + return unresolved; +} + +function isToolTimelineItem(item: ChatItem): boolean { + if (item.kind !== "message") { + return false; + } + const normalized = safeNormalizeMessage(item.message); + return normalized ? normalizeRoleForGrouping(normalized.role) === "tool" : false; +} + +function splitBundledToolResultItems(item: ChatItem): ChatItem[] { + if (item.kind !== "message") { + return [item]; + } + const message = asRecord(item.message); + if (!message || !Array.isArray(message.content) || message.content.length < 2) { + return [item]; + } + const blocksByCallId = new Map(); + for (const block of message.content) { + const record = asRecord(block); + if (!record || !isToolResultContentType(record.type)) { + return [item]; + } + const callId = resolveToolBlockId(record, message); + if (!callId) { + return [item]; + } + const blocks = blocksByCallId.get(callId) ?? []; + blocks.push(block); + blocksByCallId.set(callId, blocks); + } + if (blocksByCallId.size < 2) { + return [item]; + } + return Array.from(blocksByCallId.values(), (content, index) => ({ + ...item, + key: `${item.key}:result:${index}`, + message: { ...message, content }, + })); +} + +function resolveToolResultCallId(item: ChatItem): string | undefined { + if (item.kind !== "message") { + return undefined; + } + const message = asRecord(item.message); + if (!message) { + return undefined; + } + const content = Array.isArray(message.content) ? message.content : []; + if (content.some((block) => isToolCallContentType(asRecord(block)?.type))) { + return undefined; + } + const resultIds = new Set(); + for (const block of content) { + const record = asRecord(block); + if (record && isToolResultContentType(record.type)) { + const callId = resolveToolBlockId(record, message); + if (callId) { + resultIds.add(callId); + } + } + } + const resultId = resultIds.values().next().value; + return resultIds.size > 1 ? undefined : (resultId ?? resolveMessageToolUseId(message)); +} + +function refreshOpenCallIds( + openCallIndexes: Map, + coalesced: ChatItem[], + callIndex: number, +) { + for (const [callId, index] of openCallIndexes) { + if (index === callIndex) { + openCallIndexes.delete(callId); + } + } + for (const callId of unresolvedToolCallIds(coalesced[callIndex]!)) { + openCallIndexes.set(callId, callIndex); + } +} + +export function coalesceToolActivityMessages(items: ChatItem[]): ChatItem[] { + const coalesced: ChatItem[] = []; + // Defer backward-pair removal so all call-id indexes stay stable. + const suppressedIndexes = new Set(); + // Parallel calls can outnumber any fixed lookback window, so each unresolved + // call id owns its current transcript item until a non-tool boundary. + const openCallIndexes = new Map(); + // Keep earlier result slots by call id so later calls can restore complete cards. + const openResultIndexes = new Map(); + for (const item of items) { + const resultItems = splitBundledToolResultItems(item); + const unmatchedResultItems: ChatItem[] = []; + for (const resultItem of resultItems) { + const callId = resolveToolResultCallId(resultItem); + const callIndex = callId ? openCallIndexes.get(callId) : undefined; + const callItem = callIndex === undefined ? undefined : coalesced[callIndex]; + const merged = + callIndex === undefined || !callItem ? null : mergeToolCallResultPair(callItem, resultItem); + if (!merged || callIndex === undefined) { + unmatchedResultItems.push(resultItem); + continue; + } + coalesced[callIndex] = merged; + refreshOpenCallIds(openCallIndexes, coalesced, callIndex); + } + const hasMergedResult = unmatchedResultItems.length < resultItems.length; + if (hasMergedResult || resultItems.length > 1) { + const orphanResults = hasMergedResult ? unmatchedResultItems : resultItems; + for (const orphanResult of orphanResults) { + const callId = resolveToolResultCallId(orphanResult); + if (callId) { + openResultIndexes.set(callId, openResultIndexes.get(callId) ?? coalesced.length); + } + coalesced.push(orphanResult); + } + continue; + } + + const unresolvedCallIds = unresolvedToolCallIds(item); + let backwardMerged = item; + const matchedResultIndexes: number[] = []; + for (const callId of unresolvedCallIds) { + const resultIndex = openResultIndexes.get(callId); + const orphanResult = resultIndex === undefined ? undefined : coalesced[resultIndex]; + const merged = orphanResult ? mergeToolCallResultPair(backwardMerged, orphanResult) : null; + if (merged && resultIndex !== undefined) { + backwardMerged = merged; + matchedResultIndexes.push(resultIndex); + openResultIndexes.delete(callId); + } + } + if (matchedResultIndexes.length > 0) { + const resultIndex = Math.min(...matchedResultIndexes); + coalesced[resultIndex] = backwardMerged; + matchedResultIndexes.forEach((index) => suppressedIndexes.add(index)); + suppressedIndexes.delete(resultIndex); + refreshOpenCallIds(openCallIndexes, coalesced, resultIndex); + continue; + } + if (unresolvedCallIds.size === 1) { + const callId = unresolvedCallIds.values().next().value; + const previousIndex = callId ? openCallIndexes.get(callId) : undefined; + const previous = previousIndex === undefined ? undefined : coalesced[previousIndex]; + if (previousIndex !== undefined && previous && unresolvedToolCallIds(previous).size === 1) { + coalesced[previousIndex] = item; + refreshOpenCallIds(openCallIndexes, coalesced, previousIndex); + continue; + } + } + + coalesced.push(item); + if (unresolvedCallIds.size > 0) { + const callIndex = coalesced.length - 1; + for (const callId of unresolvedCallIds) { + openCallIndexes.set(callId, callIndex); + } + continue; + } + if (isToolTimelineItem(item)) { + // Orphan results keep the window open for later siblings. + const callId = resolveToolResultCallId(item); + if (callId) { + openResultIndexes.set(callId, openResultIndexes.get(callId) ?? coalesced.length - 1); + } + continue; + } + // Any other content (user text, assistant reply, dividers) closes the run. + openCallIndexes.clear(); + openResultIndexes.clear(); + } + return coalesced.filter((_, index) => !suppressedIndexes.has(index)); +}