Files
paseo/packages/app/src/tool-calls/detail-level/projection.test.ts
T
Mohamed BoudraandGitHub 9423b091b9 fix(app): keep tool call summaries neutral (#2090)
Individual tool outcomes remain visible in the expanded list. The aggregate header no longer treats a handled tool result as a failure of the overall work.
2026-07-15 18:34:30 +02:00

461 lines
15 KiB
TypeScript

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";
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("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("builds a loading aggregate for a one-call run", () => {
const call = toolCall("1", { type: "shell", command: "one" }, { status: "running" });
const result = project({ level: "overview", head: [call], isTurnActive: true });
const group = result.groupsByHostId.get(call.id);
if (!group) {
throw new Error("Expected an overview group");
}
expect(group).toMatchObject({
isLoading: true,
summary: { commandCount: 1 },
});
});
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 },
});
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 },
});
});
it("keeps a running overview group 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 },
isLoading: true,
summary: { commandCount: 4 },
});
});
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(continued.groupsByHostId.get("1")?.run).toMatchObject({
latest: nextCall,
isSealed: false,
});
expect(ended.groupsByHostId.get("1")?.run.isSealed).toBe(true);
});
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),
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({
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);
});
});