Refine tool call summaries and scrolling (#2069)

* feat(app): refine tool call summaries and scrolling

Keep summary groups stable from the first tool call, preserve live activity feedback, and scroll expanded groups to the latest entry. Consolidate web scrollbar styling around the native browser mechanism.

* refactor(app): reuse tool call height constant

* fix(app): preserve tool call summary state

* fix(app): refresh web tool groups on expand

* fix(app): preserve grouped tool errors
This commit is contained in:
Mohamed Boudra
2026-07-14 21:16:29 +02:00
committed by GitHub
parent d781b68711
commit 1b8af2e58e
55 changed files with 1484 additions and 2488 deletions
@@ -0,0 +1,228 @@
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import type { StreamItem, ToolCallItem } from "@/types/stream";
export interface ToolCallDescriptor {
detail: ToolCallDetail;
name: string;
status: "executing" | "running" | "completed" | "failed" | "canceled";
error: unknown;
metadata?: Record<string, unknown>;
}
export interface ToolCallRun {
id: string;
calls: readonly ToolCallItem[];
latest: ToolCallItem;
isSealed: boolean;
}
export interface GroupedHistory<TGroup> {
tail: StreamItem[];
groupsByHostId: Map<string, TGroup>;
pendingCalls: readonly ToolCallItem[];
}
export interface GroupedToolCalls<TGroup> {
tail: StreamItem[];
head: StreamItem[];
groupsByHostId: ToolCallGroupLookup<TGroup>;
historyGroupUpdatesByHostId: ToolCallGroupLookup<TGroup>;
}
export interface ToolCallGroupLookup<TGroup> {
readonly size: number;
get(id: string): TGroup | undefined;
has(id: string): boolean;
}
const EMPTY_GROUPS = new Map<string, never>();
export function describeToolCall(item: ToolCallItem): ToolCallDescriptor {
if (item.payload.source === "agent") {
const { data } = item.payload;
return {
detail: data.detail,
name: data.name,
status: data.status,
error: data.error,
metadata: data.metadata,
};
}
const { data } = item.payload;
return {
detail: {
type: "unknown",
input: data.arguments ?? null,
output: data.result ?? null,
},
name: data.toolName,
status: data.status,
error: data.error,
};
}
export function isGroupableToolCall(item: StreamItem): item is ToolCallItem {
if (item.kind !== "tool_call") {
return false;
}
const descriptor = describeToolCall(item);
return descriptor.detail.type !== "plan" && descriptor.name.trim().toLowerCase() !== "speak";
}
function createRun(calls: readonly ToolCallItem[], isSealed: boolean): ToolCallRun {
const first = calls[0];
const latest = calls.at(-1);
if (!first || !latest) {
throw new Error("Cannot group an empty tool call run");
}
return { id: first.id, calls, latest, isSealed };
}
function createHost(run: ToolCallRun): ToolCallItem {
if (run.calls.length === 1) {
return run.latest;
}
return { ...run.latest, id: run.id };
}
function isRunning(call: ToolCallItem): boolean {
const status = describeToolCall(call).status;
return status === "running" || status === "executing";
}
function appendRun<TGroup>(input: {
calls: readonly ToolCallItem[];
isSealed: boolean;
output: StreamItem[];
groups: Map<string, TGroup>;
buildGroup: (run: ToolCallRun) => TGroup;
}): void {
if (input.calls.length === 0) {
return;
}
const run = createRun(input.calls, input.isSealed);
const host = createHost(run);
input.output.push(host);
input.groups.set(host.id, input.buildGroup(run));
}
export function prepareGroupedHistory<TGroup>(input: {
tail: StreamItem[];
buildGroup: (run: ToolCallRun) => TGroup;
}): GroupedHistory<TGroup> {
const output: StreamItem[] = [];
const groups = new Map<string, TGroup>();
let pending: ToolCallItem[] = [];
for (const item of input.tail) {
if (isGroupableToolCall(item)) {
pending.push(item);
continue;
}
appendRun({
calls: pending,
isSealed: true,
output,
groups,
buildGroup: input.buildGroup,
});
pending = [];
output.push(item);
}
appendRun({
calls: pending,
isSealed: true,
output,
groups,
buildGroup: input.buildGroup,
});
return {
tail: groups.size > 0 ? output : input.tail,
groupsByHostId: groups,
pendingCalls: pending,
};
}
export function groupLiveToolCalls<TGroup>(input: {
history: GroupedHistory<TGroup>;
head: StreamItem[];
isTurnActive: boolean;
buildGroup: (run: ToolCallRun) => TGroup;
}): GroupedToolCalls<TGroup> {
const head: StreamItem[] = [];
const liveGroups = new Map<string, TGroup>();
let pending = [...input.history.pendingCalls];
let hostPlacement: "history" | "head" | null = pending.length > 0 ? "history" : null;
let pendingIncludesHead = false;
const flush = (isSealed: boolean) => {
if (pending.length === 0) {
return;
}
const run = createRun(pending, isSealed);
if (hostPlacement === "head") {
head.push(createHost(run));
}
if (hostPlacement === "head" || pendingIncludesHead || !isSealed) {
liveGroups.set(run.id, input.buildGroup(run));
}
pending = [];
hostPlacement = null;
pendingIncludesHead = false;
};
for (const item of input.head) {
if (isGroupableToolCall(item)) {
if (pending.length === 0) {
hostPlacement = "head";
}
pending.push(item);
pendingIncludesHead = true;
continue;
}
flush(true);
head.push(item);
}
// Tool calls live in retained tail rather than the streaming head. The agent
// lifecycle snapshot can still be idle while a newly received tool call is
// already running, so its direct timeline status is the authoritative start
// signal. The lifecycle state continues to keep completed calls live between
// sequential tool updates.
const trailingRunIsActive = input.isTurnActive || pending.some(isRunning);
flush(!trailingRunIsActive);
if (liveGroups.size === 0) {
return {
tail: input.history.tail,
head: input.head,
groupsByHostId: input.history.groupsByHostId,
historyGroupUpdatesByHostId: EMPTY_GROUPS,
};
}
if (input.history.groupsByHostId.size === 0) {
return {
tail: input.history.tail,
head,
groupsByHostId: liveGroups,
historyGroupUpdatesByHostId: EMPTY_GROUPS,
};
}
const groupsByHostId = new Map(input.history.groupsByHostId);
let historyGroupUpdatesByHostId: Map<string, TGroup> | null = null;
for (const [id, group] of liveGroups) {
groupsByHostId.set(id, group);
if (input.history.groupsByHostId.has(id)) {
historyGroupUpdatesByHostId ??= new Map();
historyGroupUpdatesByHostId.set(id, group);
}
}
return {
tail: input.history.tail,
head,
groupsByHostId,
historyGroupUpdatesByHostId: historyGroupUpdatesByHostId ?? EMPTY_GROUPS,
};
}
@@ -0,0 +1,92 @@
import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization";
import { describeToolCall, type ToolCallRun } from "../grouping";
const DIRECT_PASEO_TOOL_PREFIX = "paseo_";
const DIRECT_SEARCH_TOOL_SUFFIX_PATTERN = /(?:^|[_.:/])(?:web_search|llm_context)$/;
export interface OverviewSummary {
editedFileCount: number;
commandCount: number;
readFileCount: number;
searchCount: number;
otherToolCount: number;
paseoCallCount: number;
}
export interface OverviewToolCallGroup {
mode: "overview";
run: ToolCallRun;
summary: OverviewSummary;
failedCount: number;
isLoading: boolean;
}
export type OverviewHeader = { kind: "latest"; call: ToolCallRun["latest"] } | { kind: "summary" };
export function resolveOverviewHeader(
group: OverviewToolCallGroup,
expanded: boolean,
): OverviewHeader {
if (group.run.calls.length === 1) {
return { kind: "latest", call: group.run.latest };
}
if (expanded || group.run.isSealed) {
return { kind: "summary" };
}
return { kind: "latest", call: group.run.latest };
}
function isPaseoCall(name: string, normalizedName: string): boolean {
return isPaseoToolName(name) || normalizedName.startsWith(DIRECT_PASEO_TOOL_PREFIX);
}
function isSearchCall(name: string): boolean {
return DIRECT_SEARCH_TOOL_SUFFIX_PATTERN.test(name);
}
export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup {
const editedFiles = new Set<string>();
const readFiles = new Set<string>();
let failedCount = 0;
let isLoading = false;
let commandCount = 0;
let searchCount = 0;
let otherToolCount = 0;
let paseoCallCount = 0;
for (const call of run.calls) {
const descriptor = describeToolCall(call);
const normalizedName = descriptor.name.trim().toLowerCase();
failedCount += descriptor.status === "failed" ? 1 : 0;
isLoading ||= descriptor.status === "running" || descriptor.status === "executing";
if (isPaseoCall(descriptor.name, normalizedName)) {
paseoCallCount += 1;
} else if (descriptor.detail.type === "edit" || descriptor.detail.type === "write") {
editedFiles.add(descriptor.detail.filePath);
} else if (descriptor.detail.type === "shell") {
commandCount += 1;
} else if (descriptor.detail.type === "read") {
readFiles.add(descriptor.detail.filePath);
} else if (descriptor.detail.type === "search" || isSearchCall(normalizedName)) {
searchCount += 1;
} else {
otherToolCount += 1;
}
}
const summary = {
editedFileCount: editedFiles.size,
commandCount,
readFileCount: readFiles.size,
searchCount,
otherToolCount,
paseoCallCount,
};
return {
mode: "overview",
run,
failedCount,
isLoading,
summary,
};
}
@@ -0,0 +1,173 @@
import { memo, useCallback, useMemo, useRef, type ReactNode } from "react";
import { ScrollView } from "react-native";
import { useTranslation } from "react-i18next";
import { Wrench } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { ExpandableBadge } from "@/components/message";
import { ToolCallDetailsContent } from "@/components/tool-call-details";
import { useToolCallSheet } from "@/components/tool-call-sheet";
import { buildToolCallPresentation } from "@/tool-calls/presentation";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import { describeToolCall } from "../grouping";
import { resolveOverviewHeader, type OverviewSummary, type OverviewToolCallGroup } from "./model";
interface OverviewGroupProps {
group: OverviewToolCallGroup;
expanded: boolean;
isCompact: boolean;
isLastInSequence: boolean;
onExpandedChange: (groupId: string, expanded: boolean) => void;
cwd?: string;
onOpenFilePath?: (filePath: string) => void;
children: ReactNode;
}
const TOOL_CALL_GROUP_MAX_HEIGHT = 400;
function joinSummaryParts(parts: string[], conjunction: string): string {
if (parts.length === 0) {
return "";
}
let joined = parts[0] ?? "";
if (parts.length === 2) {
joined = `${parts[0]} ${conjunction} ${parts[1]}`;
} else if (parts.length > 2) {
joined = `${parts.slice(0, -1).join(", ")}, ${conjunction} ${parts.at(-1)}`;
}
const firstCharacter = joined[0];
return firstCharacter ? `${firstCharacter.toLocaleUpperCase()}${joined.slice(1)}` : joined;
}
function useOverviewSummary(summary: OverviewSummary): string {
const { t } = useTranslation();
return useMemo(() => {
const parts: string[] = [];
const entries = [
[summary.editedFileCount, "toolCallGroup.editedFiles"],
[summary.commandCount, "toolCallGroup.commands"],
[summary.readFileCount, "toolCallGroup.readFiles"],
[summary.searchCount, "toolCallGroup.searches"],
[summary.otherToolCount, "toolCallGroup.otherTools"],
[summary.paseoCallCount, "toolCallGroup.paseoCalls"],
] as const;
for (const [count, key] of entries) {
if (count > 0) {
parts.push(t(`${key}.${count === 1 ? "one" : "other"}`, { count }));
}
}
return joinSummaryParts(parts, t("toolCallGroup.and"));
}, [summary, t]);
}
export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView({
group,
expanded,
isCompact,
isLastInSequence,
onExpandedChange,
cwd,
onOpenFilePath,
children,
}: OverviewGroupProps) {
const { t } = useTranslation();
const { openToolCall } = useToolCallSheet();
const scrollRef = useRef<ScrollView>(null);
const aggregateSummary = useOverviewSummary(group.summary);
const header = resolveOverviewHeader(group, expanded);
const latestCall = header.kind === "latest" ? header.call : group.run.latest;
const latest = useMemo(() => {
const descriptor = describeToolCall(latestCall);
return {
detail: descriptor.detail,
presentation: buildToolCallPresentation({
toolName: descriptor.name,
status: descriptor.status,
error: descriptor.error,
detail: descriptor.detail,
metadata: descriptor.metadata,
cwd,
resolveIcon: resolveToolCallIcon,
}),
};
}, [cwd, latestCall]);
const showsLatest = header.kind === "latest";
const opensSingleCallSheet = isCompact && group.run.calls.length === 1;
const openLatestFile = useMemo(() => {
const path = latest.presentation.openFilePath;
if (!showsLatest || !path || !onOpenFilePath) {
return undefined;
}
return () => onOpenFilePath(path);
}, [showsLatest, latest.presentation.openFilePath, onOpenFilePath]);
const failedSummary =
group.failedCount > 0 ? t("toolCallGroup.failed", { count: group.failedCount }) : undefined;
const scrollToLatest = useCallback(() => {
scrollRef.current?.scrollToEnd({ animated: false });
}, []);
const toggle = useCallback(() => {
if (opensSingleCallSheet) {
openToolCall({
displayName: latest.presentation.displayName,
summary: latest.presentation.summary,
detail: latest.detail,
errorText: latest.presentation.errorText,
icon: latest.presentation.icon,
showLoadingSkeleton: latest.presentation.isLoadingDetails,
});
return;
}
onExpandedChange(group.run.id, !expanded);
}, [expanded, group.run.id, latest, onExpandedChange, openToolCall, opensSingleCallSheet]);
const renderDetails = useCallback(() => {
if (group.run.calls.length === 1) {
return (
<ToolCallDetailsContent
detail={latest.detail}
errorText={latest.presentation.errorText}
maxHeight={TOOL_CALL_GROUP_MAX_HEIGHT}
showLoadingSkeleton={latest.presentation.isLoadingDetails}
/>
);
}
return (
<ScrollView
ref={scrollRef}
style={styles.scroll}
contentContainerStyle={styles.content}
nestedScrollEnabled
showsVerticalScrollIndicator
onContentSizeChange={scrollToLatest}
>
{children}
</ScrollView>
);
}, [children, group.run.calls.length, latest, scrollToLatest]);
const canExpand = group.run.calls.length > 1 || latest.presentation.canOpenDetails;
return (
<ExpandableBadge
testID="tool-call-group"
label={showsLatest ? latest.presentation.displayName : aggregateSummary}
secondaryLabel={showsLatest ? latest.presentation.summary : failedSummary}
icon={showsLatest ? latest.presentation.icon : Wrench}
isLoading={group.isLoading}
isError={group.failedCount > 0}
isExpanded={opensSingleCallSheet ? false : expanded}
isLastInSequence={isLastInSequence}
onToggle={canExpand ? toggle : undefined}
onOpenFile={openLatestFile}
renderDetails={canExpand && !opensSingleCallSheet ? renderDetails : undefined}
borderlessWhenExpanded
/>
);
});
const styles = StyleSheet.create((theme) => ({
scroll: {
maxHeight: TOOL_CALL_GROUP_MAX_HEIGHT,
},
content: {
paddingTop: theme.spacing[1],
paddingHorizontal: 13,
},
}));
@@ -0,0 +1,501 @@
import { describe, expect, it } from "vitest";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import type { StreamItem, ToolCallItem } from "@/types/stream";
import {
prepareToolCallHistory,
projectToolCallDetailLevel,
type PreparedToolCallHistory,
type ToolCallDetailLevel,
} from "./projection";
import { resolveOverviewHeader } from "./overview/model";
type AssistantMessageItem = Extract<StreamItem, { kind: "assistant_message" }>;
function toolCall(
id: string,
detail: ToolCallDetail,
options: {
name?: string;
status?: "running" | "completed" | "failed" | "canceled";
} = {},
): ToolCallItem {
return {
kind: "tool_call",
id,
timestamp: new Date(`2026-01-01T00:00:${id.padStart(2, "0")}.000Z`),
payload: {
source: "agent",
data: {
provider: "claude",
callId: id,
name: options.name ?? detail.type,
status: options.status ?? "completed",
error: options.status === "failed" ? "boom" : null,
detail,
},
},
};
}
function assistant(id: string): AssistantMessageItem {
return {
kind: "assistant_message",
id,
text: id,
timestamp: new Date("2026-01-01T00:01:00.000Z"),
};
}
function project(input: {
level: ToolCallDetailLevel;
tail?: StreamItem[];
head?: StreamItem[];
isTurnActive?: boolean;
preparedHistory?: PreparedToolCallHistory | null;
}) {
const tail = input.tail ?? [];
return projectToolCallDetailLevel({
level: input.level,
tail,
head: input.head ?? [],
preparedHistory: input.preparedHistory ?? prepareToolCallHistory(input.level, tail),
isTurnActive: input.isTurnActive ?? false,
});
}
describe("tool call detail-level projection", () => {
it("passes detailed timelines through without grouping work", () => {
const tail = [toolCall("1", { type: "shell", command: "one" })];
const head = [toolCall("2", { type: "shell", command: "two" })];
const prepared = prepareToolCallHistory("detailed", tail);
const result = project({ level: "detailed", tail, head, preparedHistory: prepared });
expect(prepared).toBeNull();
expect(result.tail).toBe(tail);
expect(result.head).toBe(head);
expect(result.groupsByHostId.size).toBe(0);
});
it("keeps one stable overview host as a run grows", () => {
const firstCall = toolCall("1", { type: "shell", command: "one" });
const secondCall = toolCall("2", { type: "read", filePath: "/repo/a.ts" });
const prepared = prepareToolCallHistory("overview", []);
const single = project({
level: "overview",
head: [firstCall],
isTurnActive: true,
preparedHistory: prepared,
});
expect(single.head).toEqual([firstCall]);
expect(single.groupsByHostId.get(firstCall.id)?.run).toMatchObject({
calls: [firstCall],
latest: firstCall,
isSealed: false,
});
const grouped = project({
level: "overview",
head: [firstCall, secondCall],
isTurnActive: true,
preparedHistory: prepared,
});
expect(grouped.head).toEqual([
expect.objectContaining({ id: firstCall.id, timestamp: secondCall.timestamp }),
]);
expect(grouped.groupsByHostId.get(firstCall.id)?.run).toMatchObject({
calls: [firstCall, secondCall],
latest: secondCall,
isSealed: false,
});
});
it("shows the latest call while collapsed and the summary while expanded", () => {
const calls = [
toolCall("1", { type: "shell", command: "one" }),
toolCall("2", { type: "read", filePath: "/repo/a.ts" }, { status: "running" }),
];
const result = project({
level: "overview",
head: calls,
isTurnActive: true,
});
const group = result.groupsByHostId.get("1");
if (!group) {
throw new Error("Expected an overview group");
}
expect(resolveOverviewHeader(group, false)).toEqual({ kind: "latest", call: calls[1] });
expect(resolveOverviewHeader(group, true)).toEqual({ kind: "summary" });
});
it("keeps a parallel group loading while any call is still running", () => {
const calls = [
toolCall("1", { type: "shell", command: "slow" }, { status: "running" }),
toolCall("2", { type: "shell", command: "done" }),
];
const result = project({ level: "overview", head: calls, isTurnActive: true });
expect(result.groupsByHostId.get("1")?.isLoading).toBe(true);
});
it("keeps a one-call run presented as its tool call", () => {
const call = toolCall("1", { type: "shell", command: "one" });
const result = project({ level: "overview", head: [call] });
const group = result.groupsByHostId.get(call.id);
if (!group) {
throw new Error("Expected an overview group");
}
expect(resolveOverviewHeader(group, false)).toEqual({ kind: "latest", call });
expect(resolveOverviewHeader(group, true)).toEqual({ kind: "latest", call });
});
it("keeps an active overview group on its latest call until a visible boundary arrives", () => {
const calls = [
toolCall("1", { type: "shell", command: "one" }),
toolCall("2", { type: "read", filePath: "/repo/a.ts" }),
toolCall("3", { type: "read", filePath: "/repo/b.ts" }),
toolCall("4", { type: "edit", filePath: "/repo/a.ts" }),
];
const prepared = prepareToolCallHistory("overview", []);
const active = project({
level: "overview",
head: calls,
isTurnActive: true,
preparedHistory: prepared,
});
const activeGroup = active.groupsByHostId.get("1");
expect(activeGroup).toMatchObject({
mode: "overview",
run: { id: "1", latest: calls[3], isSealed: false },
});
expect(resolveOverviewHeader(activeGroup!, false)).toEqual({
kind: "latest",
call: calls[3],
});
const boundary = assistant("answer");
const sealed = project({
level: "overview",
head: [...calls, boundary],
isTurnActive: true,
preparedHistory: prepared,
});
expect(sealed.groupsByHostId.get("1")).toMatchObject({
mode: "overview",
run: { latest: calls[3], isSealed: true },
summary: { editedFileCount: 1, readFileCount: 2, commandCount: 1 },
});
expect(resolveOverviewHeader(sealed.groupsByHostId.get("1")!, false)).toEqual({
kind: "summary",
});
});
it("keeps a running overview header live before the agent lifecycle catches up", () => {
const calls = ["1", "2", "3", "4"].map((id) =>
toolCall(id, { type: "shell", command: id }, { status: "running" }),
);
const result = project({
level: "overview",
tail: calls,
isTurnActive: false,
});
expect(result.groupsByHostId.get("1")).toMatchObject({
run: { latest: calls[3], isSealed: false },
});
expect(resolveOverviewHeader(result.groupsByHostId.get("1")!, false)).toEqual({
kind: "latest",
call: calls[3],
});
});
it("seals the trailing overview group only when the turn ends", () => {
const calls = ["1", "2", "3", "4"].map((id) => toolCall(id, { type: "shell", command: id }));
const prepared = prepareToolCallHistory("overview", []);
const betweenCalls = project({
level: "overview",
head: calls,
isTurnActive: true,
preparedHistory: prepared,
});
const nextCall = toolCall("5", { type: "read", filePath: "/repo/a.ts" });
const continued = project({
level: "overview",
head: [...calls, nextCall],
isTurnActive: true,
preparedHistory: prepared,
});
const ended = project({
level: "overview",
head: [...calls, nextCall],
isTurnActive: false,
preparedHistory: prepared,
});
expect(betweenCalls.groupsByHostId.get("1")?.run.isSealed).toBe(false);
expect(resolveOverviewHeader(betweenCalls.groupsByHostId.get("1")!, false)).toEqual({
kind: "latest",
call: calls[3],
});
expect(continued.groupsByHostId.get("1")?.run).toMatchObject({
latest: nextCall,
isSealed: false,
});
expect(resolveOverviewHeader(continued.groupsByHostId.get("1")!, false)).toEqual({
kind: "latest",
call: nextCall,
});
expect(ended.groupsByHostId.get("1")?.run.isSealed).toBe(true);
expect(resolveOverviewHeader(ended.groupsByHostId.get("1")!, false)).toEqual({
kind: "summary",
});
});
it("builds overview summaries without category-specific presentation data", () => {
const calls = [
toolCall("1", { type: "read", filePath: "/repo/src/a.ts" }),
toolCall("2", { type: "read", filePath: "/repo/src/b.ts" }),
toolCall("3", { type: "shell", command: "npm test" }),
toolCall("4", { type: "edit", filePath: "/repo/src/a.ts" }, { status: "failed" }),
];
const overview = project({ level: "overview", head: calls });
expect(overview.groupsByHostId.get("1")).toEqual({
mode: "overview",
run: expect.any(Object),
failedCount: 1,
isLoading: false,
summary: {
editedFileCount: 1,
commandCount: 1,
readFileCount: 2,
searchCount: 0,
otherToolCount: 0,
paseoCallCount: 0,
},
});
});
it("distinguishes reads, searches, and other tools in overview", () => {
const calls = [
toolCall("1", { type: "read", filePath: "/repo/src/a.ts" }),
toolCall("2", { type: "read", filePath: "C:\\repo\\src\\beta.ts" }),
toolCall("3", { type: "fetch", url: "https://github.com/org/repo" }),
toolCall(
"4",
{ type: "search", query: "paseo", toolName: "web_search" },
{ status: "failed" },
),
toolCall("5", { type: "fetch", url: "not a url" }),
];
const result = project({ level: "overview", head: calls });
expect(result.groupsByHostId.get("1")).toMatchObject({
failedCount: 1,
summary: {
editedFileCount: 0,
commandCount: 0,
readFileCount: 2,
searchCount: 1,
otherToolCount: 2,
},
});
});
it("counts unique edited files and every shell command in overview", () => {
const calls = [
toolCall("1", { type: "edit", filePath: "/repo/a.ts" }),
toolCall("2", { type: "edit", filePath: "/repo/a.ts" }),
toolCall("3", { type: "write", filePath: "/repo/b.ts" }),
toolCall("4", { type: "shell", command: "npm test" }),
toolCall("5", { type: "shell", command: "npm run lint" }),
toolCall("6", { type: "read", filePath: "/repo/c.ts" }),
];
const result = project({ level: "overview", head: calls });
expect(result.groupsByHostId.get("1")).toMatchObject({
summary: {
editedFileCount: 2,
commandCount: 2,
readFileCount: 1,
otherToolCount: 0,
},
});
});
it("counts Paseo calls separately from other tools", () => {
const calls = [
toolCall("1", { type: "unknown", input: null, output: null }, { name: "paseo.list_agents" }),
toolCall(
"2",
{ type: "unknown", input: null, output: null },
{ name: "mcp__paseo__list_worktrees" },
),
toolCall("3", { type: "fetch", url: "https://paseo.sh" }),
toolCall("4", { type: "fetch", url: "https://github.com/getpaseo" }),
];
const result = project({ level: "overview", head: calls });
expect(result.groupsByHostId.get("1")).toMatchObject({
summary: { otherToolCount: 2, paseoCallCount: 2 },
});
});
it("classifies direct Brave search and Paseo runtime tool names", () => {
const unknownDetail = { type: "unknown" as const, input: null, output: null };
const calls = [
toolCall("1", unknownDetail, { name: "brave-search_brave_web_search" }),
toolCall("2", unknownDetail, { name: "brave-search_brave_llm_context" }),
toolCall("3", unknownDetail, { name: "paseo_list_providers" }),
toolCall("4", unknownDetail, { name: "paseo_list_worktrees" }),
toolCall("5", unknownDetail, { name: "paseo_list_worktrees" }),
toolCall("6", unknownDetail, { name: "mcp__exa__web_search" }),
];
const result = project({ level: "overview", head: calls });
expect(result.groupsByHostId.get("1")).toMatchObject({
summary: { searchCount: 3, otherToolCount: 0, paseoCallCount: 3 },
});
});
it("reuses prepared history and sealed group models across live-head updates", () => {
const historicalCalls = ["1", "2", "3", "4"].map((id) =>
toolCall(id, { type: "shell", command: id }),
);
const tail = [...historicalCalls, assistant("boundary")];
const prepared = prepareToolCallHistory("overview", tail);
if (!prepared) {
throw new Error("Overview history must be prepared");
}
expect(prepared.grouped.tail).toEqual([
expect.objectContaining({ id: "1", timestamp: historicalCalls[3]?.timestamp }),
tail[4],
]);
const first = project({
level: "overview",
tail,
head: [toolCall("5", { type: "read", filePath: "/repo/a.ts" })],
isTurnActive: true,
preparedHistory: prepared,
});
const second = project({
level: "overview",
tail,
head: [
toolCall("5", { type: "read", filePath: "/repo/a.ts" }),
toolCall("6", { type: "read", filePath: "/repo/b.ts" }),
],
isTurnActive: true,
preparedHistory: prepared,
});
expect(first.tail).toBe(prepared.grouped.tail);
expect(second.tail).toBe(prepared.grouped.tail);
expect(first.groupsByHostId.get("1")).toBe(prepared.grouped.groupsByHostId.get("1"));
expect(second.groupsByHostId.get("1")).toBe(prepared.grouped.groupsByHostId.get("1"));
expect(first.historyGroupUpdatesByHostId.size).toBe(0);
expect(second.historyGroupUpdatesByHostId).toBe(first.historyGroupUpdatesByHostId);
expect(second.groupsByHostId.get("5")?.run.calls).toHaveLength(2);
});
it("preserves projected history identity during assistant-only head updates", () => {
const trailingCalls = [
toolCall("1", { type: "shell", command: "one" }),
toolCall("2", { type: "read", filePath: "/repo/a.ts" }),
];
const tail = [assistant("before"), ...trailingCalls];
const prepared = prepareToolCallHistory("overview", tail);
if (!prepared) {
throw new Error("Overview history must be prepared");
}
const firstHead = [assistant("answer")];
const secondHead = [{ ...firstHead[0], text: "answer grows" }];
const first = project({
level: "overview",
tail,
head: firstHead,
isTurnActive: true,
preparedHistory: prepared,
});
const second = project({
level: "overview",
tail,
head: secondHead,
isTurnActive: true,
preparedHistory: prepared,
});
expect(first.tail).toBe(prepared.grouped.tail);
expect(second.tail).toBe(prepared.grouped.tail);
expect(first.groupsByHostId).toBe(prepared.grouped.groupsByHostId);
expect(second.groupsByHostId).toBe(prepared.grouped.groupsByHostId);
expect(first.historyGroupUpdatesByHostId.size).toBe(0);
expect(second.historyGroupUpdatesByHostId).toBe(first.historyGroupUpdatesByHostId);
});
it("forms one group across the retained-history and live-head boundary", () => {
const tail = [
assistant("before"),
toolCall("1", { type: "shell", command: "one" }),
toolCall("2", { type: "shell", command: "two" }),
];
const head = [
toolCall("3", { type: "read", filePath: "/repo/a.ts" }),
toolCall("4", { type: "edit", filePath: "/repo/a.ts" }, { status: "running" }),
];
const result = project({ level: "overview", tail, head, isTurnActive: true });
expect(result.tail).toEqual([
tail[0],
expect.objectContaining({ id: "1", timestamp: tail[2]?.timestamp }),
]);
expect(result.head).toEqual([]);
expect(result.groupsByHostId.get("1")?.run).toMatchObject({
calls: [...tail.slice(1), ...head],
latest: head[1],
isSealed: false,
});
expect(result.historyGroupUpdatesByHostId.get("1")).toBe(result.groupsByHostId.get("1"));
});
it("keeps a trailing history-only group in the retained segment", () => {
const tail = ["1", "2", "3", "4"].map((id) => toolCall(id, { type: "shell", command: id }));
const result = project({ level: "overview", tail, isTurnActive: false });
expect(result.tail).toEqual([
expect.objectContaining({ id: "1", timestamp: tail[3]?.timestamp }),
]);
expect(result.head).toEqual([]);
expect(result.groupsByHostId.get("1")?.run.isSealed).toBe(true);
});
it("hosts single calls while leaving plans and spoken messages ungrouped", () => {
const singleCall = toolCall("1", { type: "shell", command: "one" });
const plan = toolCall("2", { type: "plan", text: "Plan" });
const speak = toolCall(
"3",
{ type: "unknown", input: "Hello", output: null },
{ name: "speak" },
);
const result = project({ level: "overview", head: [singleCall, plan, speak] });
expect(result.head).toEqual([singleCall, plan, speak]);
expect(result.groupsByHostId.get(singleCall.id)?.run.calls).toEqual([singleCall]);
expect(result.groupsByHostId.size).toBe(1);
});
});
@@ -0,0 +1,60 @@
import type { StreamItem } from "@/types/stream";
import type { ToolCallDetailLevel } from "@/hooks/use-settings/storage";
import {
groupLiveToolCalls,
prepareGroupedHistory,
type GroupedHistory,
type GroupedToolCalls,
} from "./grouping";
import { buildOverviewGroup, type OverviewToolCallGroup } from "./overview/model";
export type { ToolCallDetailLevel } from "@/hooks/use-settings/storage";
export type ToolCallDetailGroup = OverviewToolCallGroup;
export interface PreparedToolCallHistory {
mode: "overview";
grouped: GroupedHistory<ToolCallDetailGroup>;
}
export interface ToolCallDetailProjection extends GroupedToolCalls<ToolCallDetailGroup> {}
const EMPTY_TOOL_CALL_GROUPS = new Map<string, ToolCallDetailGroup>();
export function prepareToolCallHistory(
level: ToolCallDetailLevel,
tail: StreamItem[],
): PreparedToolCallHistory | null {
if (level === "detailed") {
return null;
}
return {
mode: "overview",
grouped: prepareGroupedHistory({ tail, buildGroup: buildOverviewGroup }),
};
}
export function projectToolCallDetailLevel(input: {
level: ToolCallDetailLevel;
tail: StreamItem[];
head: StreamItem[];
preparedHistory: PreparedToolCallHistory | null;
isTurnActive: boolean;
}): ToolCallDetailProjection {
if (input.level === "detailed") {
return {
tail: input.tail,
head: input.head,
groupsByHostId: EMPTY_TOOL_CALL_GROUPS,
historyGroupUpdatesByHostId: EMPTY_TOOL_CALL_GROUPS,
};
}
if (!input.preparedHistory || input.preparedHistory.mode !== input.level) {
throw new Error(`Missing prepared ${input.level} tool call history`);
}
return groupLiveToolCalls({
history: input.preparedHistory.grouped,
head: input.head,
isTurnActive: input.isTurnActive,
buildGroup: buildOverviewGroup,
});
}
@@ -1,264 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import type { StreamItem, ToolCallItem } from "@/types/stream";
import { compactToolCallRuns } from "./grouping";
function toolCall(
id: string,
detail: ToolCallDetail,
options: {
name?: string;
status?: "running" | "completed" | "failed" | "canceled";
} = {},
): ToolCallItem {
return {
kind: "tool_call",
id,
timestamp: new Date(`2026-01-01T00:00:${id.padStart(2, "0")}.000Z`),
payload: {
source: "agent",
data: {
provider: "claude",
callId: id,
name: options.name ?? detail.type,
status: options.status ?? "completed",
error: options.status === "failed" ? "boom" : null,
detail,
},
},
};
}
function assistant(id: string): StreamItem {
return {
kind: "assistant_message",
id,
text: id,
timestamp: new Date("2026-01-01T00:01:00.000Z"),
};
}
describe("compactToolCallRuns", () => {
it("preserves the original arrays when compaction is disabled", () => {
const tail = [toolCall("1", { type: "shell", command: "one" })];
const head = [toolCall("2", { type: "shell", command: "two" })];
const result = compactToolCallRuns({ tail, head, enabled: false });
expect(result.tail).toBe(tail);
expect(result.head).toBe(head);
expect(result.groupsByHostId.size).toBe(0);
});
it("replaces four contiguous calls with a stable first-call host and latest timestamp", () => {
const calls = [
toolCall("1", { type: "shell", command: "one" }),
toolCall("2", { type: "shell", command: "two" }),
toolCall("3", { type: "read", filePath: "/repo/src/a.ts" }),
toolCall("4", { type: "edit", filePath: "/repo/src/a.ts" }),
];
const result = compactToolCallRuns({ tail: calls, head: [], enabled: true });
expect(result.tail).toHaveLength(1);
expect(result.tail[0]).toMatchObject({ id: "1", timestamp: calls[3]?.timestamp });
expect(result.head).toEqual([]);
expect(result.groupsByHostId.get("1")?.id).toBe("1");
expect(result.groupsByHostId.get("1")?.calls).toEqual(calls);
expect(result.groupsByHostId.get("1")).toMatchObject({
editedFileCount: 1,
commandCount: 2,
readFileCount: 1,
otherToolCount: 0,
categories: [
{ key: "shell", label: "Shell", callCount: 2, resources: [] },
{ key: "read", label: "Read", callCount: 1, resources: ["a.ts"] },
{ key: "edit", label: "Edit", callCount: 1, resources: ["a.ts"] },
],
});
const nextCall = toolCall("5", { type: "shell", command: "five" }, { status: "running" });
const updated = compactToolCallRuns({
tail: [...calls, nextCall],
head: [],
enabled: true,
});
expect(updated.tail[0]).toMatchObject({ id: "1", timestamp: nextCall.timestamp });
expect(updated.groupsByHostId.get("1")?.id).toBe("1");
});
it("does not compact short runs and stops at visible content boundaries", () => {
const firstRun = [
toolCall("1", { type: "shell", command: "one" }),
toolCall("2", { type: "shell", command: "two" }),
toolCall("3", { type: "shell", command: "three" }),
];
const boundary = assistant("assistant");
const secondRun = [
toolCall("4", { type: "read", filePath: "/repo/a.ts" }),
toolCall("5", { type: "read", filePath: "/repo/b.ts" }),
toolCall("6", { type: "read", filePath: "/repo/c.ts" }),
toolCall("7", { type: "read", filePath: "/repo/d.ts" }),
];
const result = compactToolCallRuns({
tail: [...firstRun, boundary, ...secondRun],
head: [],
enabled: true,
});
expect(result.tail.slice(0, -1)).toEqual([...firstRun, boundary]);
expect(result.tail.at(-1)).toMatchObject({ id: "4", timestamp: secondRun[3]?.timestamp });
expect([...result.groupsByHostId.keys()]).toEqual(["4"]);
});
it("forms one group across the history and live-head boundary", () => {
const tail = [
assistant("assistant"),
toolCall("1", { type: "shell", command: "one" }),
toolCall("2", { type: "shell", command: "two" }),
];
const head = [
toolCall("3", { type: "read", filePath: "/repo/a.ts" }),
toolCall("4", { type: "edit", filePath: "/repo/a.ts" }, { status: "running" }),
];
const result = compactToolCallRuns({ tail, head, enabled: true });
expect(result.tail).toEqual([tail[0]]);
expect(result.head).toHaveLength(1);
expect(result.head[0]).toMatchObject({ id: "1", timestamp: head[1]?.timestamp });
expect(result.groupsByHostId.get("1")?.calls).toEqual([...tail.slice(1), ...head]);
expect(result.groupsByHostId.get("1")?.isRunning).toBe(true);
});
it("separates reads and searches from other tools", () => {
const calls = [
toolCall("1", { type: "read", filePath: "/repo/src/a.ts" }),
toolCall("2", { type: "read", filePath: "C:\\repo\\src\\beta.ts" }),
toolCall("3", { type: "fetch", url: "https://github.com/org/repo" }),
toolCall(
"4",
{ type: "search", query: "paseo", toolName: "web_search" },
{ status: "failed" },
),
toolCall("5", { type: "fetch", url: "not a url" }),
];
const result = compactToolCallRuns({ tail: calls, head: [], enabled: true });
const group = result.groupsByHostId.get("1");
expect(group).toMatchObject({
failedCount: 1,
isRunning: false,
editedFileCount: 0,
commandCount: 0,
readFileCount: 2,
searchCount: 1,
otherToolCount: 2,
});
});
it("counts unique edited files while counting each shell command", () => {
const calls = [
toolCall("1", { type: "edit", filePath: "/repo/a.ts" }),
toolCall("2", { type: "edit", filePath: "/repo/a.ts" }),
toolCall("3", { type: "write", filePath: "/repo/b.ts" }),
toolCall("4", { type: "shell", command: "npm test" }),
toolCall("5", { type: "shell", command: "npm run lint" }),
toolCall("6", { type: "read", filePath: "/repo/c.ts" }),
];
const result = compactToolCallRuns({ tail: calls, head: [], enabled: true });
expect(result.groupsByHostId.get("1")).toMatchObject({
editedFileCount: 2,
commandCount: 2,
readFileCount: 1,
otherToolCount: 0,
});
});
it("counts Paseo calls separately from other tools", () => {
const calls = [
toolCall("1", { type: "unknown", input: null, output: null }, { name: "paseo.list_agents" }),
toolCall(
"2",
{ type: "unknown", input: null, output: null },
{ name: "mcp__paseo__list_worktrees" },
),
toolCall("3", { type: "fetch", url: "https://paseo.sh" }),
toolCall("4", { type: "fetch", url: "https://github.com/getpaseo" }),
];
const result = compactToolCallRuns({ tail: calls, head: [], enabled: true });
expect(result.groupsByHostId.get("1")).toMatchObject({
otherToolCount: 2,
paseoCallCount: 2,
});
});
it("classifies direct Brave and Paseo runtime tool names", () => {
const unknownDetail = { type: "unknown" as const, input: null, output: null };
const calls = [
toolCall("1", unknownDetail, { name: "brave-search_brave_web_search" }),
toolCall("2", unknownDetail, { name: "brave-search_brave_llm_context" }),
toolCall("3", unknownDetail, { name: "paseo_list_providers" }),
toolCall("4", unknownDetail, { name: "paseo_list_worktrees" }),
toolCall("5", unknownDetail, { name: "paseo_list_worktrees" }),
toolCall("6", unknownDetail, { name: "mcp__exa__web_search" }),
];
const result = compactToolCallRuns({ tail: calls, head: [], enabled: true });
expect(result.groupsByHostId.get("1")).toMatchObject({
searchCount: 3,
otherToolCount: 0,
paseoCallCount: 3,
});
});
it("keeps plan and spoken-message calls outside compact groups", () => {
const shellCalls = ["1", "2", "3", "4"].map((id) =>
toolCall(id, { type: "shell", command: id }),
);
const plan = toolCall("5", { type: "plan", text: "Plan" });
const speak = toolCall(
"6",
{ type: "unknown", input: "Hello", output: null },
{ name: "speak" },
);
const result = compactToolCallRuns({
tail: [...shellCalls, plan, speak],
head: [],
enabled: true,
});
expect(result.tail[0]).toMatchObject({ id: "1", timestamp: shellCalls[3]?.timestamp });
expect(result.tail.slice(1)).toEqual([plan, speak]);
expect(result.groupsByHostId.get("1")?.calls).toEqual(shellCalls);
});
it("deduplicates file resources by full path while displaying basenames", () => {
const calls = [
toolCall("1", { type: "read", filePath: "/repo/src/index.ts" }),
toolCall("2", { type: "read", filePath: "/repo/tests/index.ts" }),
toolCall("3", { type: "read", filePath: "/repo/src/other.ts" }),
toolCall("4", { type: "read", filePath: "/repo/src/index.ts" }),
];
const result = compactToolCallRuns({ tail: calls, head: [], enabled: true });
expect(result.groupsByHostId.get("1")).toMatchObject({
readFileCount: 3,
categories: [
{
key: "read",
resources: ["index.ts", "index.ts", "other.ts"],
},
],
});
});
});
-325
View File
@@ -1,325 +0,0 @@
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization";
import { getFileNameFromPath } from "@/attachments/utils";
import type { StreamItem, ToolCallItem } from "@/types/stream";
import { buildToolCallDisplayModel } from "@/utils/tool-call-display";
import { resolveToolCallIconName, type ToolCallIcon } from "@/utils/tool-call-icon-name";
export const MIN_COMPACT_TOOL_CALLS = 4;
const DIRECT_PASEO_TOOL_PREFIX = "paseo_";
const DIRECT_SEARCH_TOOL_SUFFIX_PATTERN = /(?:^|[_.:/])(?:web_search|llm_context)$/;
export interface ToolCallCategorySummary {
key: string;
label: string;
iconName: ToolCallIcon;
callCount: number;
failedCount: number;
runningCount: number;
resources: string[];
}
export interface CompactToolCallGroup {
id: string;
calls: ToolCallItem[];
callCount: number;
failedCount: number;
isRunning: boolean;
editedFileCount: number;
commandCount: number;
readFileCount: number;
searchCount: number;
otherToolCount: number;
paseoCallCount: number;
categories: ToolCallCategorySummary[];
}
export interface CompactToolCallRunsResult {
tail: StreamItem[];
head: StreamItem[];
groupsByHostId: Map<string, CompactToolCallGroup>;
}
interface CompactToolCallRunsInput {
tail: StreamItem[];
head: StreamItem[];
enabled: boolean;
}
interface TaggedStreamItem {
segment: "tail" | "head";
item: StreamItem;
}
interface ToolCallDescriptor {
detail: ToolCallDetail;
name: string;
status: "running" | "completed" | "failed" | "canceled";
error: unknown;
metadata?: Record<string, unknown>;
}
interface ResourceSummary {
key: string;
label: string;
}
function describeToolCall(item: ToolCallItem): ToolCallDescriptor {
if (item.payload.source === "agent") {
const { data } = item.payload;
return {
detail: data.detail,
name: data.name,
status: data.status,
error: data.error,
metadata: data.metadata,
};
}
const { data } = item.payload;
return {
detail: {
type: "unknown",
input: data.arguments ?? null,
output: data.result ?? null,
},
name: data.toolName,
status: data.status === "executing" ? "running" : data.status,
error: data.error,
};
}
function isCompactableToolCall(item: StreamItem): item is ToolCallItem {
if (item.kind !== "tool_call") {
return false;
}
const descriptor = describeToolCall(item);
return descriptor.detail.type !== "plan" && descriptor.name.trim().toLowerCase() !== "speak";
}
function isDirectPaseoToolName(name: string): boolean {
return name.startsWith(DIRECT_PASEO_TOOL_PREFIX);
}
function isDirectSearchToolName(name: string): boolean {
return DIRECT_SEARCH_TOOL_SUFFIX_PATTERN.test(name);
}
function resourceForDetail(detail: ToolCallDetail): ResourceSummary | null {
if (detail.type === "read" || detail.type === "edit" || detail.type === "write") {
return {
key: detail.filePath,
label: getFileNameFromPath(detail.filePath) ?? detail.filePath,
};
}
if (detail.type !== "fetch") {
return null;
}
try {
const hostname = new URL(detail.url).hostname || detail.url;
return { key: hostname, label: hostname };
} catch {
return { key: detail.url, label: detail.url };
}
}
function categoryIdentity(input: {
descriptor: ToolCallDescriptor;
normalizedName: string;
displayName: string;
}): { key: string; label: string; iconName: ToolCallIcon } {
if (isPaseoToolName(input.descriptor.name) || isDirectPaseoToolName(input.normalizedName)) {
return { key: "paseo", label: "Paseo", iconName: "paseo" };
}
if (
(input.descriptor.detail.type === "search" &&
input.descriptor.detail.toolName === "web_search") ||
isDirectSearchToolName(input.normalizedName)
) {
return { key: "web_search", label: "Web search", iconName: "search" };
}
if (input.descriptor.detail.type === "fetch") {
return { key: "fetch", label: "Web fetch", iconName: "search" };
}
if (input.descriptor.detail.type !== "unknown" && input.descriptor.detail.type !== "plain_text") {
return {
key: input.descriptor.detail.type,
label: input.displayName,
iconName: resolveToolCallIconName(input.descriptor.name, input.descriptor.detail),
};
}
return {
key: `tool:${input.displayName.toLowerCase()}`,
label: input.displayName,
iconName: resolveToolCallIconName(input.descriptor.name, input.descriptor.detail),
};
}
function buildCompactToolCallGroup(calls: ToolCallItem[]) {
const editedFiles = new Set<string>();
const readFiles = new Set<string>();
const categories = new Map<string, ToolCallCategorySummary>();
const categoryResourceKeys = new Map<string, Set<string>>();
let failedCount = 0;
let isRunning = false;
let commandCount = 0;
let searchCount = 0;
let otherToolCount = 0;
let paseoCallCount = 0;
for (const call of calls) {
const descriptor = describeToolCall(call);
const isFailed = descriptor.status === "failed";
const isCallRunning = descriptor.status === "running";
failedCount += isFailed ? 1 : 0;
isRunning ||= isCallRunning;
const normalizedName = descriptor.name.trim().toLowerCase();
const display = buildToolCallDisplayModel({
name: descriptor.name,
status: descriptor.status,
error: descriptor.error,
detail: descriptor.detail,
metadata: descriptor.metadata,
});
const identity = categoryIdentity({
descriptor,
normalizedName,
displayName: display.displayName,
});
let category = categories.get(identity.key);
if (!category) {
category = {
...identity,
callCount: 0,
failedCount: 0,
runningCount: 0,
resources: [],
};
categories.set(identity.key, category);
}
category.callCount += 1;
category.failedCount += isFailed ? 1 : 0;
category.runningCount += isCallRunning ? 1 : 0;
const resource = resourceForDetail(descriptor.detail);
if (resource) {
let resourceKeys = categoryResourceKeys.get(identity.key);
if (!resourceKeys) {
resourceKeys = new Set();
categoryResourceKeys.set(identity.key, resourceKeys);
}
if (!resourceKeys.has(resource.key)) {
resourceKeys.add(resource.key);
category.resources.push(resource.label);
}
}
if (isPaseoToolName(descriptor.name) || isDirectPaseoToolName(normalizedName)) {
paseoCallCount += 1;
continue;
}
if (descriptor.detail.type === "edit" || descriptor.detail.type === "write") {
editedFiles.add(descriptor.detail.filePath);
continue;
}
if (descriptor.detail.type === "shell") {
commandCount += 1;
continue;
}
if (descriptor.detail.type === "read") {
readFiles.add(descriptor.detail.filePath);
continue;
}
if (descriptor.detail.type === "search" || isDirectSearchToolName(normalizedName)) {
searchCount += 1;
continue;
}
otherToolCount += 1;
}
const firstCall = calls[0];
if (!firstCall) {
throw new Error("Cannot build an empty tool call group");
}
return {
id: firstCall.id,
calls,
callCount: calls.length,
failedCount,
isRunning,
editedFileCount: editedFiles.size,
commandCount,
readFileCount: readFiles.size,
searchCount,
otherToolCount,
paseoCallCount,
categories: [...categories.values()],
} satisfies CompactToolCallGroup;
}
export function compactToolCallRuns(input: CompactToolCallRunsInput): CompactToolCallRunsResult {
if (!input.enabled) {
return {
tail: input.tail,
head: input.head,
groupsByHostId: new Map(),
};
}
const taggedItems: TaggedStreamItem[] = [
...input.tail.map((item) => ({ segment: "tail" as const, item })),
...input.head.map((item) => ({ segment: "head" as const, item })),
];
const compactedTail: StreamItem[] = [];
const compactedHead: StreamItem[] = [];
const groupsByHostId = new Map<string, CompactToolCallGroup>();
let pendingRun: TaggedStreamItem[] = [];
const append = ({ segment, item }: TaggedStreamItem) => {
(segment === "tail" ? compactedTail : compactedHead).push(item);
};
const flushRun = () => {
if (pendingRun.length >= MIN_COMPACT_TOOL_CALLS) {
const first = pendingRun[0];
const latest = pendingRun.at(-1);
if (!first || !latest) {
throw new Error("Cannot compact an empty tool call run");
}
const calls = pendingRun.map(({ item }) => item as ToolCallItem);
const stableHost: TaggedStreamItem = {
segment: latest.segment,
item: { ...latest.item, id: first.item.id },
};
append(stableHost);
groupsByHostId.set(stableHost.item.id, buildCompactToolCallGroup(calls));
} else {
for (const entry of pendingRun) {
append(entry);
}
}
pendingRun = [];
};
for (const entry of taggedItems) {
if (isCompactableToolCall(entry.item)) {
pendingRun.push(entry);
continue;
}
flushRun();
append(entry);
}
flushRun();
if (groupsByHostId.size === 0) {
return {
tail: input.tail,
head: input.head,
groupsByHostId,
};
}
return {
tail: compactedTail,
head: compactedHead,
groupsByHostId,
};
}