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.
461 lines
15 KiB
TypeScript
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);
|
|
});
|
|
});
|