mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
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:
committed by
GitHub
parent
3a37a12d89
commit
b545224b33
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user