feat(app): add tool call detail levels (#2031)

* feat(app): compact tool call sequences

* feat(app): summarize compact tool activity

* feat(app): add tool call detail levels

* fix(app): stabilize compact tool groups

* fix(app): address compact tool review feedback
This commit is contained in:
Matt Cowger
2026-07-14 18:55:06 +08:00
committed by GitHub
parent 3b078240a8
commit 81113d1582
16 changed files with 1488 additions and 13 deletions
@@ -0,0 +1,264 @@
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
@@ -0,0 +1,325 @@
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,
};
}