fix(ui): pool reply-less wake activity into one chat rollup (#128442)

Consecutive runs whose entire visible outcome is tool activity (heartbeat
wakes, cron ticks) now collapse into a single expandable activity row
instead of stacking identical rows down the transcript. Runs with a
visible reply keep per-run activity separation. Group summaries reuse the
standalone row's tool display label, so the rollup reads
"Used Heartbeat Respond ×N". Splits tool call/result pairing out of
chat-thread-grouping.ts into chat-tool-activity-coalesce.ts (max-lines).
This commit is contained in:
Peter Steinberger
2026-08-25 21:15:01 -07:00
committed by GitHub
parent 3a37a12d89
commit b545224b33
7 changed files with 576 additions and 349 deletions
@@ -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<Record<string, unknown>> {
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");
});
});
});
+4 -4
View File
@@ -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",
+4 -1
View File
@@ -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);
}
}
}
+2 -1
View File
@@ -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";
+50 -341
View File
@@ -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<ChatItem | MessageGroup>
}
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<string, { resultCard: ToolCard; resultName: string }>();
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<string, unknown> = 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<string> {
const unresolved = new Set<string>();
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<string, unknown[]>();
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<string>();
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<string, number>,
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<number>();
// 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<string, number>();
// Keep earlier result slots by call id so later calls can restore complete cards.
const openResultIndexes = new Map<string, number>();
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<string> {
const replyRunIds = new Set<string>();
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<CompletedTurnRenderItem | ActivityRunRenderItem> = [];
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);
+72 -2
View File
@@ -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", () => {
@@ -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<string, { resultCard: ToolCard; resultName: string }>();
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<string, unknown> = 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<string> {
const unresolved = new Set<string>();
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<string, unknown[]>();
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<string>();
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<string, number>,
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<number>();
// 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<string, number>();
// Keep earlier result slots by call id so later calls can restore complete cards.
const openResultIndexes = new Map<string, number>();
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));
}