Update files

This commit is contained in:
Mohamed Boudra
2026-02-08 22:37:52 +07:00
parent 8317b0d89b
commit 0176614467
36 changed files with 4098 additions and 5114 deletions
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import { afterEach, describe, expect, test, vi } from "vitest";
import { DaemonClient, type DaemonTransport } from "./daemon-client";
@@ -49,6 +50,18 @@ function createMockTransport() {
};
}
function loadLegacySnapshotFixture(): unknown {
const url = new URL("../shared/__fixtures__/legacy-agent-stream-snapshot-inProgress.json", import.meta.url);
return JSON.parse(readFileSync(url, "utf8"));
}
function wrapSessionMessage(message: unknown): string {
return JSON.stringify({
type: "session",
message,
});
}
describe("DaemonClient", () => {
const clients: DaemonClient[] = [];
@@ -171,4 +184,122 @@ describe("DaemonClient", () => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
test("parses agent_stream tool_call payloads (including legacy inProgress) without crashing", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const received: unknown[] = [];
const unsubscribe = client.on("agent_stream", (msg) => {
received.push(msg);
});
mock.triggerMessage(
wrapSessionMessage({
type: "agent_stream",
payload: {
agentId: "agent_cli",
timestamp: "2026-02-08T20:20:00.000Z",
event: {
type: "timeline",
provider: "codex",
item: {
type: "tool_call",
callId: "call_cli_stream",
name: "shell",
status: "inProgress",
input: { command: "pwd" },
},
},
},
})
);
unsubscribe();
expect(received).toHaveLength(1);
const streamMsg = received[0] as {
payload: {
event: {
type: "timeline";
item: {
type: "tool_call";
status: string;
error: unknown;
output: unknown;
};
};
};
};
expect(streamMsg.payload.event.item.status).toBe("running");
expect(streamMsg.payload.event.item.error).toBeNull();
expect(streamMsg.payload.event.item.output).toBeNull();
expect(logger.warn).not.toHaveBeenCalled();
});
test("parses agent_stream_snapshot tool_call payloads without crashing", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const received: unknown[] = [];
const unsubscribe = client.on("agent_stream_snapshot", (msg) => {
received.push(msg);
});
const snapshot = loadLegacySnapshotFixture();
mock.triggerMessage(wrapSessionMessage(snapshot));
unsubscribe();
expect(received).toHaveLength(1);
const snapshotMsg = received[0] as {
payload: {
events: Array<{
event: {
type: "timeline";
item: {
type: "tool_call";
status: string;
error: unknown;
output: unknown;
};
};
}>;
};
};
const firstTimeline = snapshotMsg.payload.events[0]?.event;
expect(firstTimeline?.type).toBe("timeline");
if (firstTimeline?.type === "timeline" && firstTimeline.item.type === "tool_call") {
expect(firstTimeline.item.status).toBe("running");
expect(firstTimeline.item.error).toBeNull();
expect(firstTimeline.item.output).toBeNull();
}
expect(logger.warn).not.toHaveBeenCalled();
});
});
@@ -1,590 +1,158 @@
import { describe, test, expect } from "vitest";
import { describe, expect, it } from "vitest";
import { curateAgentActivity } from "./activity-curator.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
function toolCallItem(params: {
callId: string;
name: string;
status?: "running" | "completed" | "failed" | "canceled";
input?: unknown | null;
output?: unknown | null;
error?: unknown;
metadata?: Record<string, unknown>;
detail?: Extract<AgentTimelineItem, { type: "tool_call" }>['detail'];
}): Extract<AgentTimelineItem, { type: "tool_call" }> {
const status = params.status ?? "completed";
return {
type: "tool_call",
callId: params.callId,
name: params.name,
status,
input: params.input ?? null,
output: params.output ?? null,
error: status === "failed" ? params.error ?? { message: "failed" } : null,
metadata: params.metadata,
detail: params.detail,
};
}
describe("curateAgentActivity", () => {
describe("serializes all timeline item types", () => {
test("serializes user_message", () => {
const timeline: AgentTimelineItem[] = [
{ type: "user_message", text: "Hello, can you help me?" },
];
it("renders user/assistant/reasoning entries", () => {
const timeline: AgentTimelineItem[] = [
{ type: "user_message", text: "Hello" },
{ type: "assistant_message", text: "Hi" },
{ type: "reasoning", text: "Thinking" },
];
const result = curateAgentActivity(timeline);
const result = curateAgentActivity(timeline);
expect(result).toBe("[User] Hello, can you help me?");
});
test("serializes assistant_message", () => {
const timeline: AgentTimelineItem[] = [
{ type: "assistant_message", text: "I can help you with that." },
];
const result = curateAgentActivity(timeline);
expect(result).toBe("I can help you with that.");
});
test("serializes reasoning as [Thought]", () => {
const timeline: AgentTimelineItem[] = [
{ type: "reasoning", text: "The user wants to understand X." },
];
const result = curateAgentActivity(timeline);
expect(result).toBe("[Thought] The user wants to understand X.");
});
test("serializes tool_call with name", () => {
const timeline: AgentTimelineItem[] = [
{
type: "tool_call",
callId: "call-1",
name: "Read",
input: { file_path: "/src/index.ts" },
status: "completed",
},
];
const result = curateAgentActivity(timeline);
expect(result).toBe("[Read] /src/index.ts");
});
test("serializes tool_call without principal param", () => {
const timeline: AgentTimelineItem[] = [
{
type: "tool_call",
callId: "call-1",
name: "ListFiles",
input: {},
status: "completed",
},
];
const result = curateAgentActivity(timeline);
expect(result).toBe("[ListFiles]");
});
test("does not treat generic double-underscore tool names as MCP calls", () => {
const timeline: AgentTimelineItem[] = [
{
type: "tool_call",
callId: "call-1",
name: "custom__tool",
input: {},
status: "completed",
},
];
const result = curateAgentActivity(timeline);
expect(result).toBe("[custom__tool]");
});
test("serializes todo items as [Tasks]", () => {
const timeline: AgentTimelineItem[] = [
{
type: "todo",
items: [
{ text: "Read the file", completed: true },
{ text: "Fix the bug", completed: false },
{ text: "Run tests", completed: false },
],
},
];
const result = curateAgentActivity(timeline);
expect(result).toContain("[Tasks]");
expect(result).toContain("- [x] Read the file");
expect(result).toContain("- [ ] Fix the bug");
expect(result).toContain("- [ ] Run tests");
});
test("serializes error items", () => {
const timeline: AgentTimelineItem[] = [
{ type: "error", message: "File not found: /missing.ts" },
];
const result = curateAgentActivity(timeline);
expect(result).toBe("[Error] File not found: /missing.ts");
});
expect(result).toContain("[User] Hello");
expect(result).toContain("Hi");
expect(result).toContain("[Thought] Thinking");
});
describe("handles complex conversations", () => {
test("serializes full conversation with multiple item types", () => {
const timeline: AgentTimelineItem[] = [
{ type: "user_message", text: "Fix the bug in auth.ts" },
{ type: "reasoning", text: "I need to read the file first." },
{
type: "tool_call",
callId: "call-1",
name: "Read",
input: { file_path: "/src/auth.ts" },
status: "completed",
it("uses detail enrichment for tool summaries", () => {
const timeline: AgentTimelineItem[] = [
toolCallItem({
callId: "read-1",
name: "read_file",
detail: {
type: "read",
filePath: "src/index.ts",
content: "console.log('hi')",
},
{ type: "assistant_message", text: "I found the issue." },
{
type: "tool_call",
callId: "call-2",
name: "Edit",
input: { file_path: "/src/auth.ts", old_string: "bug", new_string: "fix" },
status: "completed",
}),
toolCallItem({
callId: "shell-1",
name: "shell",
detail: {
type: "shell",
command: "npm test",
output: "ok",
exitCode: 0,
},
{ type: "assistant_message", text: "The bug has been fixed." },
];
}),
];
const result = curateAgentActivity(timeline);
const result = curateAgentActivity(timeline);
expect(result).toContain("[User] Fix the bug in auth.ts");
expect(result).toContain("[Thought] I need to read the file first.");
expect(result).toContain("[Read] /src/auth.ts");
expect(result).toContain("I found the issue.");
expect(result).toContain("[Edit] /src/auth.ts");
expect(result).toContain("The bug has been fixed.");
});
test("preserves order of items", () => {
const timeline: AgentTimelineItem[] = [
{ type: "user_message", text: "Step 1" },
{ type: "assistant_message", text: "Step 2" },
{ type: "user_message", text: "Step 3" },
{ type: "assistant_message", text: "Step 4" },
];
const result = curateAgentActivity(timeline);
const lines = result.split("\n");
expect(lines[0]).toContain("Step 1");
expect(lines[1]).toContain("Step 2");
expect(lines[2]).toContain("Step 3");
expect(lines[3]).toContain("Step 4");
});
expect(result).toContain("[Read] src/index.ts");
expect(result).toContain("[Shell] npm test");
});
describe("handles edge cases", () => {
test("returns default message for empty timeline", () => {
const result = curateAgentActivity([]);
it("falls back to input json for likely external tools", () => {
const timeline: AgentTimelineItem[] = [
toolCallItem({
callId: "mcp-1",
name: "paseo__create_agent",
input: { cwd: "/tmp/repo", initialPrompt: "do the thing" },
}),
];
expect(result).toBe("No activity to display.");
});
const result = curateAgentActivity(timeline);
test("handles whitespace-only messages", () => {
const timeline: AgentTimelineItem[] = [
{ type: "user_message", text: " \n " },
{ type: "assistant_message", text: "Real message" },
];
const result = curateAgentActivity(timeline);
expect(result).toContain("Real message");
});
test("trims whitespace from messages", () => {
const timeline: AgentTimelineItem[] = [
{ type: "user_message", text: " Hello \n" },
];
const result = curateAgentActivity(timeline);
expect(result).toBe("[User] Hello");
});
expect(result).toBe(
'[paseo__create_agent] {"cwd":"/tmp/repo","initialPrompt":"do the thing"}'
);
});
describe("collapsing behavior", () => {
test("merges consecutive assistant_message items", () => {
const timeline: AgentTimelineItem[] = [
{ type: "assistant_message", text: "Part 1. " },
{ type: "assistant_message", text: "Part 2. " },
{ type: "assistant_message", text: "Part 3." },
];
it("collapses repeated tool updates by callId", () => {
const timeline: AgentTimelineItem[] = [
toolCallItem({
callId: "task-1",
name: "Task",
status: "running",
input: { description: "Investigate" },
}),
toolCallItem({
callId: "task-1",
name: "Task",
status: "running",
metadata: { subAgentActivity: "Read" },
}),
toolCallItem({
callId: "task-1",
name: "Task",
status: "running",
metadata: { subAgentActivity: "Edit" },
}),
];
const result = curateAgentActivity(timeline);
const result = curateAgentActivity(timeline);
const lines = result.split("\n");
expect(result).toBe("Part 1. Part 2. Part 3.");
});
test("merges consecutive reasoning items", () => {
const timeline: AgentTimelineItem[] = [
{ type: "reasoning", text: "First thought. " },
{ type: "reasoning", text: "Second thought." },
];
const result = curateAgentActivity(timeline);
expect(result).toBe("[Thought] First thought. Second thought.");
});
test("deduplicates tool calls by callId", () => {
const timeline: AgentTimelineItem[] = [
{
type: "tool_call",
callId: "call-1",
name: "Read",
input: { file_path: "/src/a.ts" },
status: "pending",
},
{
type: "tool_call",
callId: "call-1",
name: "Read",
input: { file_path: "/src/a.ts" },
status: "completed",
},
];
const result = curateAgentActivity(timeline);
// Should only appear once
const matches = result.match(/\[Read\]/g);
expect(matches?.length).toBe(1);
});
expect(lines.filter((line) => line.startsWith("[Task]"))).toEqual(["[Task] Edit"]);
});
describe("maxItems limit", () => {
test("respects maxItems option", () => {
const timeline: AgentTimelineItem[] = [
{ type: "user_message", text: "Message 1" },
{ type: "user_message", text: "Message 2" },
{ type: "user_message", text: "Message 3" },
{ type: "user_message", text: "Message 4" },
{ type: "user_message", text: "Message 5" },
];
it("renders todo/error/compaction entries", () => {
const timeline: AgentTimelineItem[] = [
{
type: "todo",
items: [
{ text: "One", completed: false },
{ text: "Two", completed: true },
],
},
{ type: "error", message: "boom" },
{ type: "compaction", status: "completed", trigger: "auto" },
];
const result = curateAgentActivity(timeline, { maxItems: 3 });
const result = curateAgentActivity(timeline);
// Should only have the last 3 messages
expect(result).not.toContain("Message 1");
expect(result).not.toContain("Message 2");
expect(result).toContain("Message 3");
expect(result).toContain("Message 4");
expect(result).toContain("Message 5");
});
test("uses default maxItems of 40", () => {
const timeline: AgentTimelineItem[] = [];
for (let i = 0; i < 50; i++) {
timeline.push({ type: "user_message", text: `Message ${i}` });
}
const result = curateAgentActivity(timeline);
// First 10 should be truncated
expect(result).not.toContain("Message 0");
expect(result).not.toContain("Message 9");
// Last 40 should be present
expect(result).toContain("Message 10");
expect(result).toContain("Message 49");
});
expect(result).toContain("[Tasks]");
expect(result).toContain("- [ ] One");
expect(result).toContain("- [x] Two");
expect(result).toContain("[Error] boom");
expect(result).toContain("[Compacted]");
});
describe("tool call principal extraction", () => {
test("extracts file_path from Read tool", () => {
const timeline: AgentTimelineItem[] = [
{
type: "tool_call",
callId: "1",
name: "Read",
input: { file_path: "/src/index.ts" },
status: "completed",
},
];
it("truncates to maxItems", () => {
const timeline: AgentTimelineItem[] = [
{ type: "user_message", text: "Message 1" },
{ type: "user_message", text: "Message 2" },
{ type: "user_message", text: "Message 3" },
{ type: "user_message", text: "Message 4" },
];
const result = curateAgentActivity(timeline);
const result = curateAgentActivity(timeline, { maxItems: 2 });
expect(result).toBe("[Read] /src/index.ts");
});
test("extracts command from Bash tool", () => {
const timeline: AgentTimelineItem[] = [
{
type: "tool_call",
callId: "1",
name: "Bash",
input: { command: "npm test" },
status: "completed",
},
];
const result = curateAgentActivity(timeline);
expect(result).toBe("[Shell] npm test");
});
test("extracts pattern from Glob tool", () => {
const timeline: AgentTimelineItem[] = [
{
type: "tool_call",
callId: "1",
name: "Glob",
input: { pattern: "**/*.ts" },
status: "completed",
},
];
const result = curateAgentActivity(timeline);
expect(result).toBe("[Glob] **/*.ts");
});
test("extracts pattern from Grep tool", () => {
const timeline: AgentTimelineItem[] = [
{
type: "tool_call",
callId: "1",
name: "Grep",
input: { pattern: "TODO" },
status: "completed",
},
];
const result = curateAgentActivity(timeline);
expect(result).toBe("[Grep] TODO");
});
test("shows speak tool text input", () => {
const timeline: AgentTimelineItem[] = [
{
type: "tool_call",
callId: "s1",
name: "speak",
input: { text: "hello from voice" },
status: "completed",
},
];
const result = curateAgentActivity(timeline);
expect(result).toBe('[Speak] {"text":"hello from voice"}');
});
test("shows MCP tool input JSON", () => {
const timeline: AgentTimelineItem[] = [
{
type: "tool_call",
callId: "m1",
name: "paseo__create_agent",
input: { cwd: "/tmp/repo", initialPrompt: "do the thing" },
status: "completed",
},
];
const result = curateAgentActivity(timeline);
expect(result).toBe(
'[paseo__create_agent] {"cwd":"/tmp/repo","initialPrompt":"do the thing"}'
);
});
test("shows namespaced tool input JSON regardless of prefix format", () => {
const timeline: AgentTimelineItem[] = [
{
type: "tool_call",
callId: "m2",
name: "paseo_voice.speak",
input: { text: "hello from namespaced tool" },
status: "completed",
},
];
const result = curateAgentActivity(timeline);
expect(result).toBe('[Speak] {"text":"hello from namespaced tool"}');
});
test("shows claude mcp speak tool input as Speak", () => {
const timeline: AgentTimelineItem[] = [
{
type: "tool_call",
callId: "m3",
name: "mcp__paseo_voice__speak",
input: { text: "hello from claude mcp" },
status: "completed",
},
];
const result = curateAgentActivity(timeline);
expect(result).toBe('[Speak] {"text":"hello from claude mcp"}');
});
test("extracts description from Task tool", () => {
const timeline: AgentTimelineItem[] = [
{
type: "tool_call",
callId: "task-1",
name: "Task",
input: { description: "Explore the codebase" },
status: "completed",
},
];
const result = curateAgentActivity(timeline);
expect(result).toBe("[Task] Explore the codebase");
});
expect(result).not.toContain("Message 1");
expect(result).not.toContain("Message 2");
expect(result).toContain("Message 3");
expect(result).toContain("Message 4");
});
describe("Task tool collapse with sub-agent activity", () => {
test("collapses Task metadata updates into single entry showing latest activity", () => {
const timeline: AgentTimelineItem[] = [
{ type: "user_message", text: "Investigate the bug" },
{
type: "tool_call",
callId: "task-1",
name: "Task",
input: { description: "Explore the codebase" },
status: "pending",
},
// Sub-agent activity updates (same callId, metadata-only)
{
type: "tool_call",
callId: "task-1",
name: "Task",
metadata: { subAgentActivity: "Read" },
},
{
type: "tool_call",
callId: "task-1",
name: "Task",
metadata: { subAgentActivity: "Grep" },
},
{
type: "tool_call",
callId: "task-1",
name: "Task",
metadata: { subAgentActivity: "Edit" },
},
];
const result = curateAgentActivity(timeline);
const lines = result.split("\n");
// Task should appear only once (collapsed by callId)
const taskLines = lines.filter((l) => l.includes("[Task]"));
expect(taskLines).toHaveLength(1);
// The last metadata update wins — subAgentActivity "Edit" takes priority
expect(taskLines[0]).toBe("[Task] Edit");
});
test("sub-agent tool calls do NOT appear as separate timeline entries", () => {
// This simulates the full flow: handleSidechainMessage only emits
// Task metadata updates, never individual sub-agent tool calls.
// So the timeline should only contain the Task call, not Read/Bash/Edit.
const timeline: AgentTimelineItem[] = [
{
type: "tool_call",
callId: "task-1",
name: "Task",
input: { description: "Fix the bug" },
status: "pending",
},
// These are the metadata-only updates from handleSidechainMessage
{
type: "tool_call",
callId: "task-1",
name: "Task",
metadata: { subAgentActivity: "Read" },
},
{
type: "tool_call",
callId: "task-1",
name: "Task",
metadata: { subAgentActivity: "Bash" },
},
// Task completes
{
type: "tool_call",
callId: "task-1",
name: "Task",
input: { description: "Fix the bug" },
output: { result: "Bug fixed successfully" },
status: "completed",
},
];
const result = curateAgentActivity(timeline);
const lines = result.split("\n");
// No individual Read/Bash lines — only the Task
expect(lines.filter((l) => l.includes("[Read]"))).toHaveLength(0);
expect(lines.filter((l) => l.includes("[Shell]"))).toHaveLength(0);
// One Task entry with the final completed state
const taskLines = lines.filter((l) => l.includes("[Task]"));
expect(taskLines).toHaveLength(1);
expect(taskLines[0]).toBe("[Task] Fix the bug");
});
test("multiple concurrent Task calls are tracked independently", () => {
const timeline: AgentTimelineItem[] = [
{
type: "tool_call",
callId: "task-a",
name: "Task",
input: { description: "Research API docs" },
status: "pending",
},
{
type: "tool_call",
callId: "task-b",
name: "Task",
input: { description: "Run tests" },
status: "pending",
},
// Activity updates for each
{
type: "tool_call",
callId: "task-a",
name: "Task",
metadata: { subAgentActivity: "WebFetch" },
},
{
type: "tool_call",
callId: "task-b",
name: "Task",
metadata: { subAgentActivity: "Bash" },
},
];
const result = curateAgentActivity(timeline);
const lines = result.split("\n");
const taskLines = lines.filter((l) => l.includes("[Task]"));
expect(taskLines).toHaveLength(2);
// Last update for each callId wins
expect(taskLines[0]).toBe("[Task] WebFetch");
expect(taskLines[1]).toBe("[Task] Bash");
});
});
describe("compaction", () => {
test("renders compaction as [Compacted]", () => {
const timeline: AgentTimelineItem[] = [
{ type: "assistant_message", text: "Working on it..." },
{ type: "compaction", status: "completed", trigger: "auto", preTokens: 168000 },
{ type: "assistant_message", text: "Continuing after compaction" },
];
const result = curateAgentActivity(timeline);
const lines = result.split("\n");
expect(lines).toContain("[Compacted]");
expect(lines.indexOf("[Compacted]")).toBeGreaterThan(0);
});
test("compaction flushes preceding buffers", () => {
const timeline: AgentTimelineItem[] = [
{ type: "assistant_message", text: "Before" },
{ type: "reasoning", text: "Thinking..." },
{ type: "compaction", status: "completed", trigger: "auto" },
{ type: "assistant_message", text: "After" },
];
const result = curateAgentActivity(timeline);
const lines = result.split("\n");
const compactIdx = lines.indexOf("[Compacted]");
expect(compactIdx).toBeGreaterThan(-1);
expect(lines.slice(0, compactIdx).some((l) => l.includes("Before"))).toBe(true);
expect(lines.slice(0, compactIdx).some((l) => l.includes("Thinking"))).toBe(true);
});
it("returns a default message when timeline is empty", () => {
expect(curateAgentActivity([])).toBe("No activity to display.");
});
});
@@ -1,5 +1,4 @@
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import { parseToolCallDisplay } from "../../utils/tool-call-parsers.js";
import { isLikelyExternalToolName } from "./tool-name-normalization.js";
const DEFAULT_MAX_ITEMS = 40;
@@ -45,6 +44,50 @@ function formatToolInputJson(input: unknown): string | null {
}
}
function resolveToolDisplayName(item: Extract<AgentTimelineItem, { type: "tool_call" }>): string {
switch (item.detail?.type) {
case "shell":
return "Shell";
case "read":
return "Read";
case "edit":
return "Edit";
case "write":
return "Write";
case "search":
return "Search";
default:
return item.name;
}
}
function resolveToolSummary(
item: Extract<AgentTimelineItem, { type: "tool_call" }>
): string | undefined {
if (item.name.trim().toLowerCase() === "task") {
const metadata = item.metadata as { subAgentActivity?: unknown } | undefined;
if (typeof metadata?.subAgentActivity === "string") {
const summary = metadata.subAgentActivity.trim();
if (summary.length > 0) {
return summary;
}
}
}
switch (item.detail?.type) {
case "shell":
return item.detail.command;
case "read":
case "edit":
case "write":
return item.detail.filePath;
case "search":
return item.detail.query;
default:
return undefined;
}
}
/**
* Collapse timeline items:
* - Dedupe tool calls by callId (pending/completed -> single)
@@ -86,26 +129,35 @@ function collapseTimeline(items: AgentTimelineItem[]): AgentTimelineItem[] {
flushAssistant();
flushToolCalls();
reasoningBuffer += item.text;
} else if (item.type === "tool_call" && item.callId) {
} else if (item.type === "tool_call") {
flushAssistant();
flushReasoning();
const existing = toolCallMap.get(item.callId);
if (existing && existing.type === "tool_call") {
toolCallMap.set(item.callId, {
...existing,
...item,
input: item.input ?? existing.input,
output: item.output ?? existing.output,
metadata: item.metadata,
});
if (item.status === "failed") {
toolCallMap.set(item.callId, {
...existing,
...item,
input: item.input ?? existing.input,
output: item.output ?? existing.output,
detail: item.detail ?? existing.detail,
error: item.error,
metadata: item.metadata,
});
} else {
toolCallMap.set(item.callId, {
...existing,
...item,
input: item.input ?? existing.input,
output: item.output ?? existing.output,
detail: item.detail ?? existing.detail,
error: null,
metadata: item.metadata,
});
}
} else {
toolCallMap.set(item.callId, item);
}
} else if (item.type === "tool_call") {
flushAssistant();
flushReasoning();
flushToolCalls();
result.push(item);
} else {
flushAssistant();
flushReasoning();
@@ -159,11 +211,8 @@ export function curateAgentActivity(
case "tool_call": {
flushBuffers(lines, buffers);
const inputJson = formatToolInputJson(item.input);
const { displayName, summary } = parseToolCallDisplay({
name: item.name,
input: item.input,
metadata: item.metadata,
});
const displayName = resolveToolDisplayName(item);
const summary = resolveToolSummary(item);
if (isLikelyExternalToolName(item.name) && inputJson) {
lines.push(`[${displayName}] ${inputJson}`);
break;
@@ -103,29 +103,73 @@ export type AgentUsage = {
totalCostUsd?: number;
};
/**
* Tool call kind categories for UI rendering hints.
* Derived from the tool name, not sent over the wire.
*/
export type ToolCallKind = "read" | "edit" | "execute" | "search" | "other";
export type ToolCallDetail =
| {
type: "shell";
command: string;
cwd?: string;
output?: string;
exitCode?: number | null;
}
| {
type: "read";
filePath: string;
content?: string;
offset?: number;
limit?: number;
}
| {
type: "edit";
filePath: string;
oldString?: string;
newString?: string;
unifiedDiff?: string;
}
| {
type: "write";
filePath: string;
content?: string;
}
| {
type: "search";
query: string;
};
/**
* Clean tool call structure.
* - `name`: Tool identifier (e.g., "Read", "Bash", "Edit", "shell", "read_file", "apply_patch")
* - `input`: Tool input parameters
* - `output`: Tool result
* - `error`: Error if tool failed
*/
export interface ToolCallTimelineItem {
type ToolCallBase = {
type: "tool_call";
callId: string;
name: string;
callId?: string;
status?: string;
input?: unknown;
output?: unknown;
error?: unknown;
input: unknown | null;
output: unknown | null;
detail?: ToolCallDetail;
metadata?: Record<string, unknown>;
}
};
type ToolCallRunningTimelineItem = ToolCallBase & {
status: "running";
error: null;
};
type ToolCallCompletedTimelineItem = ToolCallBase & {
status: "completed";
error: null;
};
type ToolCallFailedTimelineItem = ToolCallBase & {
status: "failed";
error: unknown;
};
type ToolCallCanceledTimelineItem = ToolCallBase & {
status: "canceled";
error: null;
};
export type ToolCallTimelineItem =
| ToolCallRunningTimelineItem
| ToolCallCompletedTimelineItem
| ToolCallFailedTimelineItem
| ToolCallCanceledTimelineItem;
export type CompactionTimelineItem = {
type: "compaction";
@@ -22,6 +22,12 @@ import {
type SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
import type { Logger } from "pino";
import {
mapClaudeCanceledToolCall,
mapClaudeCompletedToolCall,
mapClaudeFailedToolCall,
mapClaudeRunningToolCall,
} from "./claude/tool-call-mapper.js";
import type {
AgentCapabilityFlags,
@@ -177,8 +183,6 @@ type PendingPermission = {
};
type ToolUseClassification = "generic" | "command" | "file_change";
type ToolCallTimelineItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
type ToolUseCacheEntry = {
id: string;
name: string;
@@ -763,11 +767,14 @@ class ClaudeAgentSession implements AgentSession {
if (response.behavior === "allow") {
if (pending.request.kind === "plan") {
await this.setMode("acceptEdits");
this.pushToolCall({
this.pushToolCall(
mapClaudeCompletedToolCall({
name: "plan_approval",
status: "granted",
callId: pending.request.id,
});
input: pending.request.input ?? null,
output: { approved: true },
})
);
}
const result: PermissionResult = {
behavior: "allow",
@@ -1165,16 +1172,19 @@ class ClaudeAgentSession implements AgentSession {
}
this.activeSidechains.set(parentToolUseId, toolName);
return [{
type: "timeline",
item: {
type: "tool_call",
name: "Task",
callId: parentToolUseId,
metadata: { subAgentActivity: toolName },
return [
{
type: "timeline",
item: mapClaudeRunningToolCall({
name: "Task",
callId: parentToolUseId,
input: null,
output: null,
metadata: { subAgentActivity: toolName },
}),
provider: "claude",
},
provider: "claude",
}];
];
}
private translateMessageToEvents(message: SDKMessage, turnContext: TurnContext): AgentStreamEvent[] {
@@ -1428,23 +1438,23 @@ class ClaudeAgentSession implements AgentSession {
private flushPendingToolCalls() {
for (const [id, entry] of this.toolUseCache) {
if (entry.started) {
this.pushToolCall({
name: entry.name,
status: "failed",
callId: id,
input: entry.input,
error: { message: "Interrupted" },
});
this.pushToolCall(
mapClaudeCanceledToolCall({
name: entry.name,
callId: id,
input: entry.input ?? null,
output: null,
})
);
}
}
this.toolUseCache.clear();
}
private pushToolCall(
data: Omit<ToolCallTimelineItem, "type">,
item: Extract<AgentTimelineItem, { type: "tool_call" }>,
target?: AgentTimelineItem[]
) {
const item: AgentTimelineItem = { type: "tool_call", ...data };
if (target) {
target.push(item);
return;
@@ -1611,12 +1621,12 @@ class ClaudeAgentSession implements AgentSession {
entry.started = true;
this.toolUseCache.set(entry.id, entry);
this.pushToolCall(
{
mapClaudeRunningToolCall({
name: entry.name,
status: "pending",
callId: entry.id,
input: entry.input ?? this.normalizeToolInput(block.input),
},
input: entry.input ?? this.normalizeToolInput(block.input) ?? null,
output: null,
}),
items
);
}
@@ -1624,22 +1634,37 @@ class ClaudeAgentSession implements AgentSession {
private handleToolResult(block: ClaudeContentChunk, items: AgentTimelineItem[]): void {
const entry = typeof block.tool_use_id === "string" ? this.toolUseCache.get(block.tool_use_id) : undefined;
const toolName = entry?.name ?? block.tool_name ?? "tool";
const status = block.is_error ? "failed" : "completed";
const callId =
typeof block.tool_use_id === "string" && block.tool_use_id.length > 0
? block.tool_use_id
: entry?.id ?? null;
// Extract output from block.content (SDK always returns content in string form)
const output = this.buildToolOutput(block, entry);
this.pushToolCall(
{
name: toolName,
status,
callId: typeof block.tool_use_id === "string" ? block.tool_use_id : undefined,
input: entry?.input,
output,
error: block.is_error ? block : undefined,
},
items
);
if (block.is_error) {
this.pushToolCall(
mapClaudeFailedToolCall({
name: toolName,
callId,
input: entry?.input ?? null,
output: output ?? null,
error: block,
}),
items
);
} else {
this.pushToolCall(
mapClaudeCompletedToolCall({
name: toolName,
callId,
input: entry?.input ?? null,
output: output ?? null,
}),
items
);
}
if (typeof block.tool_use_id === "string") {
this.toolUseCache.delete(block.tool_use_id);
}
@@ -1866,12 +1891,14 @@ class ClaudeAgentSession implements AgentSession {
}
this.applyToolInput(entry, normalized);
this.toolUseCache.set(toolId, entry);
this.pushToolCall({
name: entry.name,
status: "pending",
callId: toolId,
input: normalized,
});
this.pushToolCall(
mapClaudeRunningToolCall({
name: entry.name,
callId: toolId,
input: normalized,
output: null,
})
);
}
private normalizeToolInput(input: unknown): AgentMetadata | null {
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
mapClaudeCompletedToolCall,
mapClaudeFailedToolCall,
mapClaudeRunningToolCall,
} from "./tool-call-mapper.js";
describe("claude tool-call mapper", () => {
it("maps running shell calls with canonical fields", () => {
const item = mapClaudeRunningToolCall({
callId: "claude-call-1",
name: "Bash",
input: { command: "pwd", cwd: "/tmp/repo" },
output: null,
});
expect(item.type).toBe("tool_call");
expect(item.status).toBe("running");
expect(item.error).toBeNull();
expect(item.callId).toBe("claude-call-1");
expect(item.input).toEqual({ command: "pwd", cwd: "/tmp/repo" });
expect(item.output).toBeNull();
expect(item.detail?.type).toBe("shell");
if (item.detail?.type === "shell") {
expect(item.detail.command).toBe("pwd");
expect(item.detail.cwd).toBe("/tmp/repo");
}
});
it("maps completed read calls with detail enrichment", () => {
const item = mapClaudeCompletedToolCall({
callId: "claude-call-2",
name: "read_file",
input: { file_path: "README.md" },
output: { content: "hello" },
});
expect(item.status).toBe("completed");
expect(item.error).toBeNull();
expect(item.callId).toBe("claude-call-2");
expect(item.input).toEqual({ file_path: "README.md" });
expect(item.output).toEqual({ content: "hello" });
expect(item.detail?.type).toBe("read");
if (item.detail?.type === "read") {
expect(item.detail.filePath).toBe("README.md");
expect(item.detail.content).toBe("hello");
}
});
it("maps failed calls with required error", () => {
const item = mapClaudeFailedToolCall({
callId: "claude-call-3",
name: "shell",
input: { command: "false" },
output: null,
error: { message: "Command failed" },
});
expect(item.status).toBe("failed");
expect(item.error).toEqual({ message: "Command failed" });
expect(item.callId).toBe("claude-call-3");
expect(item.input).toEqual({ command: "false" });
expect(item.output).toBeNull();
});
it("keeps unknown tools canonical without detail", () => {
const item = mapClaudeCompletedToolCall({
callId: "claude-call-4",
name: "my_custom_tool",
input: { foo: "bar" },
output: { ok: true },
});
expect(item.status).toBe("completed");
expect(item.error).toBeNull();
expect(item.detail).toBeUndefined();
expect(item.input).toEqual({ foo: "bar" });
expect(item.output).toEqual({ ok: true });
});
});
@@ -0,0 +1,599 @@
import { z } from "zod";
import type { ToolCallDetail, ToolCallTimelineItem } from "../../agent-sdk-types.js";
type MapperParams = {
callId?: string | null;
name: string;
input?: unknown;
output?: unknown;
metadata?: Record<string, unknown>;
};
const ClaudeMapperParamsSchema = z
.object({
callId: z.string().optional().nullable(),
name: z.string().min(1),
input: z.unknown().optional(),
output: z.unknown().optional(),
metadata: z.record(z.unknown()).optional(),
})
.passthrough();
const ClaudeFailedMapperParamsSchema = ClaudeMapperParamsSchema.extend({
error: z.unknown(),
});
const ClaudeShellToolNameSchema = z.union([
z.literal("Bash"),
z.literal("bash"),
z.literal("shell"),
z.literal("exec_command"),
]);
const ClaudeReadToolNameSchema = z.union([
z.literal("Read"),
z.literal("read"),
z.literal("read_file"),
z.literal("view_file"),
]);
const ClaudeWriteToolNameSchema = z.union([
z.literal("Write"),
z.literal("write"),
z.literal("write_file"),
z.literal("create_file"),
]);
const ClaudeEditToolNameSchema = z.union([
z.literal("Edit"),
z.literal("edit"),
z.literal("multi_edit"),
z.literal("multiedit"),
z.literal("apply_patch"),
z.literal("apply_diff"),
z.literal("str_replace_editor"),
]);
const ClaudeSearchToolNameSchema = z.union([
z.literal("WebSearch"),
z.literal("web_search"),
z.literal("websearch"),
z.literal("search"),
]);
const ClaudeFileReferenceSchema = z
.object({
file_path: z.string().optional(),
filePath: z.string().optional(),
path: z.string().optional(),
target_path: z.string().optional(),
targetPath: z.string().optional(),
})
.passthrough();
const ClaudeFileReferenceCollectionSchema = ClaudeFileReferenceSchema.extend({
files: z.array(ClaudeFileReferenceSchema).optional(),
}).passthrough();
const ClaudeTextLikeSchema = z
.object({
output: z.string().optional(),
text: z.string().optional(),
content: z.string().optional(),
})
.passthrough();
const ClaudeShellInputSchema = z
.object({
command: z.union([z.string(), z.array(z.string())]).optional(),
cmd: z.union([z.string(), z.array(z.string())]).optional(),
cwd: z.string().optional(),
directory: z.string().optional(),
})
.passthrough();
const ClaudeShellOutputObjectSchema = z
.object({
command: z.string().optional(),
output: z.string().optional(),
text: z.string().optional(),
content: z.string().optional(),
aggregated_output: z.string().optional(),
exitCode: z.number().finite().optional(),
exit_code: z.number().finite().optional(),
metadata: z
.object({
exitCode: z.number().finite().optional(),
exit_code: z.number().finite().optional(),
})
.passthrough()
.optional(),
structuredContent: ClaudeTextLikeSchema.optional(),
structured_content: ClaudeTextLikeSchema.optional(),
result: z
.object({
command: z.string().optional(),
output: z.string().optional(),
text: z.string().optional(),
content: z.string().optional(),
})
.passthrough()
.optional(),
})
.passthrough();
const ClaudeShellOutputSchema = z.union([z.string(), ClaudeShellOutputObjectSchema]);
const ClaudeReadInputSchema = ClaudeFileReferenceCollectionSchema.extend({
offset: z.number().finite().optional(),
limit: z.number().finite().optional(),
}).passthrough();
const ClaudeReadOutputSchema = z.union([
z.string(),
ClaudeFileReferenceCollectionSchema.extend({
content: z.string().optional(),
text: z.string().optional(),
output: z.string().optional(),
data: ClaudeTextLikeSchema.optional(),
structuredContent: ClaudeTextLikeSchema.optional(),
structured_content: ClaudeTextLikeSchema.optional(),
}).passthrough(),
]);
const ClaudeWriteInputSchema = ClaudeFileReferenceCollectionSchema.extend({
content: z.string().optional(),
new_content: z.string().optional(),
newContent: z.string().optional(),
}).passthrough();
const ClaudeWriteOutputSchema = ClaudeFileReferenceCollectionSchema.extend({
content: z.string().optional(),
new_content: z.string().optional(),
newContent: z.string().optional(),
}).passthrough();
const ClaudeEditInputSchema = ClaudeFileReferenceCollectionSchema.extend({
old_string: z.string().optional(),
old_str: z.string().optional(),
oldContent: z.string().optional(),
old_content: z.string().optional(),
new_string: z.string().optional(),
new_str: z.string().optional(),
newContent: z.string().optional(),
new_content: z.string().optional(),
content: z.string().optional(),
patch: z.string().optional(),
diff: z.string().optional(),
unified_diff: z.string().optional(),
unifiedDiff: z.string().optional(),
}).passthrough();
const ClaudeEditOutputSchema = ClaudeFileReferenceCollectionSchema.extend({
content: z.string().optional(),
new_content: z.string().optional(),
newContent: z.string().optional(),
patch: z.string().optional(),
diff: z.string().optional(),
unified_diff: z.string().optional(),
unifiedDiff: z.string().optional(),
files: z
.array(
ClaudeFileReferenceSchema.extend({
patch: z.string().optional(),
diff: z.string().optional(),
unified_diff: z.string().optional(),
unifiedDiff: z.string().optional(),
}).passthrough()
)
.optional(),
}).passthrough();
const ClaudeSearchInputSchema = z
.object({
query: z.string().optional(),
q: z.string().optional(),
})
.passthrough();
const ClaudeShellDetailCandidateSchema = z
.object({
name: ClaudeShellToolNameSchema,
input: z.unknown().nullable(),
output: z.unknown().nullable(),
})
.transform(({ input, output }) => resolveShellDetail(input, output));
const ClaudeReadDetailCandidateSchema = z
.object({
name: ClaudeReadToolNameSchema,
input: z.unknown().nullable(),
output: z.unknown().nullable(),
})
.transform(({ input, output }) => resolveReadDetail(input, output));
const ClaudeWriteDetailCandidateSchema = z
.object({
name: ClaudeWriteToolNameSchema,
input: z.unknown().nullable(),
output: z.unknown().nullable(),
})
.transform(({ input, output }) => resolveWriteDetail(input, output));
const ClaudeEditDetailCandidateSchema = z
.object({
name: ClaudeEditToolNameSchema,
input: z.unknown().nullable(),
output: z.unknown().nullable(),
})
.transform(({ input, output }) => resolveEditDetail(input, output));
const ClaudeSearchDetailCandidateSchema = z
.object({
name: ClaudeSearchToolNameSchema,
input: z.unknown().nullable(),
output: z.unknown().nullable(),
})
.transform(({ input }) => resolveSearchDetail(input));
const ClaudeKnownToolDetailSchema = z.union([
ClaudeShellDetailCandidateSchema,
ClaudeReadDetailCandidateSchema,
ClaudeWriteDetailCandidateSchema,
ClaudeEditDetailCandidateSchema,
ClaudeSearchDetailCandidateSchema,
]);
function hashText(value: string): string {
let hash = 0;
for (let i = 0; i < value.length; i += 1) {
hash = (hash << 5) - hash + value.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash).toString(36);
}
function coerceCallId(callId: string | null | undefined, name: string, input: unknown): string {
if (typeof callId === "string" && callId.trim().length > 0) {
return callId;
}
let serialized = "";
try {
serialized = JSON.stringify(input) ?? "";
} catch {
serialized = String(input);
}
return `claude-${hashText(`${name}:${serialized}`)}`;
}
function firstNonEmpty(...values: Array<string | undefined>): string | undefined {
return values.find((value) => typeof value === "string" && value.length > 0);
}
function commandFromValue(value: string | string[] | undefined): string | undefined {
if (typeof value === "string" && value.length > 0) {
return value;
}
if (Array.isArray(value)) {
const tokens = value.filter((token): token is string => typeof token === "string" && token.length > 0);
if (tokens.length > 0) {
return tokens.join(" ");
}
}
return undefined;
}
function resolveFilePath(value: z.infer<typeof ClaudeFileReferenceCollectionSchema>): string | undefined {
return firstNonEmpty(
value.file_path,
value.filePath,
value.path,
value.target_path,
value.targetPath,
value.files?.[0]?.path,
value.files?.[0]?.filePath,
value.files?.[0]?.file_path
);
}
function resolveShellDetail(input: unknown, output: unknown): ToolCallDetail | undefined {
const parsedInput = ClaudeShellInputSchema.safeParse(input);
const parsedOutput = ClaudeShellOutputSchema.safeParse(output);
const command =
(parsedInput.success
? commandFromValue(parsedInput.data.command) ?? commandFromValue(parsedInput.data.cmd)
: undefined) ??
(parsedOutput.success && typeof parsedOutput.data !== "string"
? firstNonEmpty(parsedOutput.data.command, parsedOutput.data.result?.command)
: undefined);
if (!command) {
return undefined;
}
const outputText =
parsedOutput.success
? typeof parsedOutput.data === "string"
? parsedOutput.data
: firstNonEmpty(
parsedOutput.data.output,
parsedOutput.data.text,
parsedOutput.data.content,
parsedOutput.data.aggregated_output,
parsedOutput.data.structuredContent?.output,
parsedOutput.data.structuredContent?.text,
parsedOutput.data.structuredContent?.content,
parsedOutput.data.structured_content?.output,
parsedOutput.data.structured_content?.text,
parsedOutput.data.structured_content?.content,
parsedOutput.data.result?.output,
parsedOutput.data.result?.text,
parsedOutput.data.result?.content
)
: undefined;
const exitCode =
parsedOutput.success && typeof parsedOutput.data !== "string"
? parsedOutput.data.exitCode ??
parsedOutput.data.exit_code ??
parsedOutput.data.metadata?.exitCode ??
parsedOutput.data.metadata?.exit_code ??
null
: null;
const cwd =
parsedInput.success
? firstNonEmpty(parsedInput.data.cwd, parsedInput.data.directory)
: undefined;
return {
type: "shell",
command,
...(cwd !== undefined ? { cwd } : {}),
...(outputText !== undefined ? { output: outputText } : {}),
...(exitCode !== null ? { exitCode } : { exitCode: null }),
};
}
function resolveReadDetail(input: unknown, output: unknown): ToolCallDetail | undefined {
const parsedInput = ClaudeReadInputSchema.safeParse(input);
const parsedOutput = ClaudeReadOutputSchema.safeParse(output);
const inputPath = parsedInput.success ? resolveFilePath(parsedInput.data) : undefined;
const outputPath =
parsedOutput.success && typeof parsedOutput.data !== "string"
? resolveFilePath(parsedOutput.data)
: undefined;
const filePath = firstNonEmpty(inputPath, outputPath);
if (!filePath) {
return undefined;
}
const content =
parsedOutput.success
? typeof parsedOutput.data === "string"
? parsedOutput.data
: firstNonEmpty(
parsedOutput.data.content,
parsedOutput.data.text,
parsedOutput.data.output,
parsedOutput.data.data?.content,
parsedOutput.data.data?.text,
parsedOutput.data.data?.output,
parsedOutput.data.structuredContent?.content,
parsedOutput.data.structuredContent?.text,
parsedOutput.data.structuredContent?.output,
parsedOutput.data.structured_content?.content,
parsedOutput.data.structured_content?.text,
parsedOutput.data.structured_content?.output
)
: undefined;
const offset = parsedInput.success ? parsedInput.data.offset : undefined;
const limit = parsedInput.success ? parsedInput.data.limit : undefined;
return {
type: "read",
filePath,
...(content !== undefined ? { content } : {}),
...(offset !== undefined ? { offset } : {}),
...(limit !== undefined ? { limit } : {}),
};
}
function resolveWriteDetail(input: unknown, output: unknown): ToolCallDetail | undefined {
const parsedInput = ClaudeWriteInputSchema.safeParse(input);
const parsedOutput = ClaudeWriteOutputSchema.safeParse(output);
const filePath = firstNonEmpty(
parsedInput.success ? resolveFilePath(parsedInput.data) : undefined,
parsedOutput.success ? resolveFilePath(parsedOutput.data) : undefined
);
if (!filePath) {
return undefined;
}
const content = firstNonEmpty(
parsedInput.success
? firstNonEmpty(parsedInput.data.content, parsedInput.data.new_content, parsedInput.data.newContent)
: undefined,
parsedOutput.success
? firstNonEmpty(parsedOutput.data.content, parsedOutput.data.new_content, parsedOutput.data.newContent)
: undefined
);
return {
type: "write",
filePath,
...(content !== undefined ? { content } : {}),
};
}
function resolveEditDetail(input: unknown, output: unknown): ToolCallDetail | undefined {
const parsedInput = ClaudeEditInputSchema.safeParse(input);
const parsedOutput = ClaudeEditOutputSchema.safeParse(output);
const filePath = firstNonEmpty(
parsedInput.success ? resolveFilePath(parsedInput.data) : undefined,
parsedOutput.success ? resolveFilePath(parsedOutput.data) : undefined
);
if (!filePath) {
return undefined;
}
const oldString = parsedInput.success
? firstNonEmpty(
parsedInput.data.old_string,
parsedInput.data.old_str,
parsedInput.data.oldContent,
parsedInput.data.old_content
)
: undefined;
const newString = firstNonEmpty(
parsedInput.success
? firstNonEmpty(
parsedInput.data.new_string,
parsedInput.data.new_str,
parsedInput.data.newContent,
parsedInput.data.new_content,
parsedInput.data.content
)
: undefined,
parsedOutput.success
? firstNonEmpty(parsedOutput.data.newContent, parsedOutput.data.new_content, parsedOutput.data.content)
: undefined
);
const unifiedDiff = firstNonEmpty(
parsedInput.success
? firstNonEmpty(
parsedInput.data.patch,
parsedInput.data.diff,
parsedInput.data.unified_diff,
parsedInput.data.unifiedDiff
)
: undefined,
parsedOutput.success
? firstNonEmpty(
parsedOutput.data.patch,
parsedOutput.data.diff,
parsedOutput.data.unified_diff,
parsedOutput.data.unifiedDiff,
parsedOutput.data.files?.[0]?.patch,
parsedOutput.data.files?.[0]?.diff,
parsedOutput.data.files?.[0]?.unified_diff,
parsedOutput.data.files?.[0]?.unifiedDiff
)
: undefined
);
return {
type: "edit",
filePath,
...(oldString !== undefined ? { oldString } : {}),
...(newString !== undefined ? { newString } : {}),
...(unifiedDiff !== undefined ? { unifiedDiff } : {}),
};
}
function resolveSearchDetail(input: unknown): ToolCallDetail | undefined {
const parsedInput = ClaudeSearchInputSchema.safeParse(input);
if (!parsedInput.success) {
return undefined;
}
const query = firstNonEmpty(parsedInput.data.query, parsedInput.data.q);
if (!query) {
return undefined;
}
return {
type: "search",
query,
};
}
function deriveDetail(name: string, input: unknown, output: unknown): ToolCallDetail | undefined {
const parsed = ClaudeKnownToolDetailSchema.safeParse({
name,
input,
output,
});
if (!parsed.success) {
return undefined;
}
return parsed.data;
}
function buildBase(params: MapperParams): {
callId: string;
name: string;
input: unknown | null;
output: unknown | null;
detail?: ToolCallDetail;
metadata?: Record<string, unknown>;
} {
const parsedParams = ClaudeMapperParamsSchema.parse(params);
const callId = coerceCallId(parsedParams.callId, parsedParams.name, parsedParams.input);
const input = parsedParams.input ?? null;
const output = parsedParams.output ?? null;
const detail = deriveDetail(parsedParams.name, input, output);
return {
callId,
name: parsedParams.name,
input,
output,
...(detail ? { detail } : {}),
...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}),
};
}
export function mapClaudeRunningToolCall(params: MapperParams): ToolCallTimelineItem {
const base = buildBase(params);
return {
type: "tool_call",
...base,
status: "running",
error: null,
};
}
export function mapClaudeCompletedToolCall(params: MapperParams): ToolCallTimelineItem {
const base = buildBase(params);
return {
type: "tool_call",
...base,
status: "completed",
error: null,
};
}
export function mapClaudeFailedToolCall(
params: MapperParams & { error: unknown }
): ToolCallTimelineItem {
const parsedParams = ClaudeFailedMapperParamsSchema.parse(params);
const base = buildBase(parsedParams);
return {
type: "tool_call",
...base,
status: "failed",
error: parsedParams.error,
};
}
export function mapClaudeCanceledToolCall(params: MapperParams): ToolCallTimelineItem {
const base = buildBase(params);
return {
type: "tool_call",
...base,
status: "canceled",
error: null,
};
}
@@ -21,7 +21,6 @@ import type {
ListModelsOptions,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
ToolCallTimelineItem,
} from "../agent-sdk-types.js";
import type { Logger } from "pino";
@@ -35,6 +34,7 @@ import path from "node:path";
import readline from "node:readline";
import { z } from "zod";
import { loadCodexPersistedTimeline } from "./codex-rollout-timeline.js";
import { mapCodexToolCallFromThreadItem } from "./codex/tool-call-mapper.js";
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
@@ -634,12 +634,6 @@ function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined {
};
}
function createToolCallTimelineItem(
data: Omit<ToolCallTimelineItem, "type">
): AgentTimelineItem {
return { type: "tool_call", ...data };
}
function extractUserText(content: unknown): string | null {
if (!Array.isArray(content)) return null;
const parts: string[] = [];
@@ -654,20 +648,6 @@ function extractUserText(content: unknown): string | null {
return parts.length > 0 ? parts.join("\n") : null;
}
function extractContentText(content: unknown): string | null {
if (!Array.isArray(content)) return null;
const parts: string[] = [];
for (const item of content) {
if (item && typeof item === "object") {
const obj = item as { text?: string };
if (typeof obj.text === "string") {
parts.push(obj.text);
}
}
}
return parts.length > 0 ? parts.join("\n") : null;
}
function parsePlanTextToTodoItems(text: string): { text: string; completed: boolean }[] {
const lines = text
.split("\n")
@@ -692,19 +672,6 @@ function planStepsToTodoItems(steps: Array<{ step: string; status: string }>): {
}));
}
function normalizeCodexFilePath(filePath: unknown, cwd: string | null | undefined): string | null {
if (typeof filePath !== "string") return null;
const trimmed = filePath.trim();
if (!trimmed) return null;
if (typeof cwd === "string" && cwd.trim().length > 0) {
const normalizedCwd = cwd.endsWith(path.sep) ? cwd : `${cwd}${path.sep}`;
if (trimmed.startsWith(normalizedCwd)) {
return trimmed.slice(normalizedCwd.length);
}
}
return trimmed;
}
function threadItemToTimeline(
item: any,
options?: { includeUserMessage?: boolean; cwd?: string | null }
@@ -734,79 +701,11 @@ function threadItemToTimeline(
const text = summary || content;
return text ? { type: "reasoning", text } : null;
}
case "commandExecution": {
const output = {
type: "command",
command: item.command,
output: item.aggregatedOutput ?? "",
exitCode: item.exitCode ?? undefined,
};
return createToolCallTimelineItem({
name: "shell",
status: item.status,
callId: item.id,
input: { command: item.command, cwd: item.cwd },
output,
});
}
case "fileChange": {
const files = Array.isArray(item.changes)
? item.changes.map((change: any) => ({
path: normalizeCodexFilePath(change.path, cwd) ?? change.path,
kind: change.kind,
}))
: [];
const outputFiles = Array.isArray(item.changes)
? item.changes.map((change: any) => ({
path: normalizeCodexFilePath(change.path, cwd) ?? change.path,
patch:
typeof change.diff === "string"
? truncateUtf8Bytes(change.diff, MAX_FILE_PATCH_BYTES).text
: change.diff,
kind: change.kind,
}))
: [];
return createToolCallTimelineItem({
name: "apply_patch",
status: item.status,
callId: item.id,
input: { files },
output: { files: outputFiles },
});
}
case "mcpToolCall": {
if (item.tool === "read_file") {
const pathValue = item.arguments?.path ?? item.arguments?.file_path ?? null;
const content = extractContentText(item.result?.content) ?? "";
return createToolCallTimelineItem({
name: "read_file",
status: item.status,
callId: item.id,
input: pathValue ? { path: pathValue } : item.arguments,
output: pathValue
? { type: "read_file", path: pathValue, content }
: item.result ?? undefined,
error: item.error ?? undefined,
});
}
return createToolCallTimelineItem({
name: `${item.server}.${item.tool}`,
status: item.status,
callId: item.id,
input: item.arguments,
output: item.result ?? undefined,
error: item.error ?? undefined,
});
}
case "webSearch": {
return createToolCallTimelineItem({
name: "web_search",
status: "completed",
callId: item.id,
input: { query: item.query },
output: item.action ?? undefined,
});
}
case "commandExecution":
case "fileChange":
case "mcpToolCall":
case "webSearch":
return mapCodexToolCallFromThreadItem(item, { cwd });
default:
return null;
}
@@ -856,21 +755,6 @@ function normalizeImageData(mimeType: string, data: string): ImageDataPayload {
return { mimeType, data };
}
function truncateUtf8Bytes(text: string, maxBytes: number): { text: string; truncated: boolean } {
if (maxBytes <= 0) {
return { text: "", truncated: text.length > 0 };
}
const bytes = Buffer.byteLength(text, "utf8");
if (bytes <= maxBytes) {
return { text, truncated: false };
}
const buffer = Buffer.from(text, "utf8");
const sliced = buffer.subarray(0, maxBytes);
return { text: sliced.toString("utf8"), truncated: true };
}
const MAX_FILE_PATCH_BYTES = 128 * 1024;
const ThreadStartedNotificationSchema = z.object({
thread: z.object({ id: z.string() }).passthrough(),
}).passthrough();
@@ -6,6 +6,7 @@ import { z } from "zod";
import type { Logger } from "pino";
import type { AgentTimelineItem } from "../agent-sdk-types.js";
import { mapCodexRolloutToolCall } from "./codex/tool-call-mapper.js";
const MAX_ROLLOUT_SEARCH_DEPTH = 4;
@@ -434,14 +435,12 @@ export async function parseRolloutFile(
? [record.item]
: record.kind === "call"
? [
{
type: "tool_call",
mapCodexRolloutToolCall({
callId: record.callId ?? null,
name: record.name,
callId: record.callId,
status: "completed",
input: record.input,
output: record.callId ? outputsByCallId.get(record.callId) : undefined,
},
input: record.input ?? null,
output: record.callId ? outputsByCallId.get(record.callId) ?? null : null,
}),
]
: []
);
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import {
mapCodexRolloutToolCall,
mapCodexToolCallFromThreadItem,
} from "./tool-call-mapper.js";
describe("codex tool-call mapper", () => {
it("maps commandExecution start into running canonical call", () => {
const item = mapCodexToolCallFromThreadItem({
type: "commandExecution",
id: "codex-call-1",
status: "running",
command: "pwd",
cwd: "/tmp/repo",
});
expect(item).toBeTruthy();
expect(item?.status).toBe("running");
expect(item?.error).toBeNull();
expect(item?.callId).toBe("codex-call-1");
expect(item?.name).toBe("shell");
expect(item?.input).toEqual({ command: "pwd", cwd: "/tmp/repo" });
});
it("maps mcp read_file completion with detail", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "mcpToolCall",
id: "codex-call-2",
status: "completed",
tool: "read_file",
arguments: { path: "/tmp/repo/README.md" },
result: { content: "hello" },
},
{ cwd: "/tmp/repo" }
);
expect(item).toBeTruthy();
expect(item?.status).toBe("completed");
expect(item?.error).toBeNull();
expect(item?.callId).toBe("codex-call-2");
expect(item?.name).toBe("read_file");
expect(item?.detail?.type).toBe("read");
if (item?.detail?.type === "read") {
expect(item.detail.filePath).toBe("README.md");
expect(item.detail.content).toBe("hello");
}
});
it("maps failed tool calls with required error", () => {
const item = mapCodexToolCallFromThreadItem({
type: "mcpToolCall",
id: "codex-call-3",
status: "failed",
server: "custom",
tool: "run",
arguments: { foo: "bar" },
result: null,
error: { message: "boom" },
});
expect(item).toBeTruthy();
expect(item?.status).toBe("failed");
expect(item?.error).toEqual({ message: "boom" });
expect(item?.callId).toBe("codex-call-3");
});
it("keeps unknown tools canonical without detail", () => {
const item = mapCodexRolloutToolCall({
callId: "codex-call-4",
name: "my_custom_tool",
input: { foo: "bar" },
output: { ok: true },
});
expect(item.status).toBe("completed");
expect(item.error).toBeNull();
expect(item.detail).toBeUndefined();
expect(item.callId).toBe("codex-call-4");
expect(item.input).toEqual({ foo: "bar" });
expect(item.output).toEqual({ ok: true });
});
});
@@ -0,0 +1,875 @@
import { z } from "zod";
import type { ToolCallDetail, ToolCallTimelineItem } from "../../agent-sdk-types.js";
type CodexMapperOptions = { cwd?: string | null };
const FAILED_STATUSES = new Set(["failed", "error", "errored", "rejected", "denied"]);
const CANCELED_STATUSES = new Set(["canceled", "cancelled", "interrupted", "aborted"]);
const COMPLETED_STATUSES = new Set(["completed", "complete", "done", "success", "succeeded"]);
const CodexRolloutToolCallParamsSchema = z
.object({
callId: z.string().optional().nullable(),
name: z.string().min(1),
input: z.unknown().optional(),
output: z.unknown().optional(),
error: z.unknown().optional(),
})
.passthrough();
const CodexFileReferenceSchema = z
.object({
file_path: z.string().optional(),
filePath: z.string().optional(),
path: z.string().optional(),
target_path: z.string().optional(),
targetPath: z.string().optional(),
})
.passthrough();
const CodexFileReferenceCollectionSchema = CodexFileReferenceSchema.extend({
files: z.array(CodexFileReferenceSchema).optional(),
}).passthrough();
const CodexTextLikeSchema = z
.object({
output: z.string().optional(),
text: z.string().optional(),
content: z.string().optional(),
})
.passthrough();
const CodexShellInputSchema = z
.object({
command: z.union([z.string(), z.array(z.string())]).optional(),
cmd: z.union([z.string(), z.array(z.string())]).optional(),
cwd: z.string().optional(),
directory: z.string().optional(),
})
.passthrough();
const CodexShellOutputObjectSchema = z
.object({
command: z.string().optional(),
output: z.string().optional(),
text: z.string().optional(),
content: z.string().optional(),
exitCode: z.number().nullable().optional(),
exit_code: z.number().nullable().optional(),
metadata: z
.object({
exitCode: z.number().nullable().optional(),
exit_code: z.number().nullable().optional(),
})
.passthrough()
.optional(),
structuredContent: CodexTextLikeSchema.optional(),
structured_content: CodexTextLikeSchema.optional(),
result: CodexTextLikeSchema.optional(),
})
.passthrough();
const CodexShellOutputSchema = z.union([z.string(), CodexShellOutputObjectSchema]);
const CodexReadInputSchema = CodexFileReferenceCollectionSchema.extend({
offset: z.number().finite().optional(),
limit: z.number().finite().optional(),
}).passthrough();
const CodexReadOutputSchema = z.union([
z.string(),
CodexFileReferenceCollectionSchema.extend({
content: z.string().optional(),
text: z.string().optional(),
output: z.string().optional(),
structuredContent: CodexTextLikeSchema.optional(),
structured_content: CodexTextLikeSchema.optional(),
data: CodexTextLikeSchema.optional(),
}).passthrough(),
]);
const CodexWriteInputSchema = CodexFileReferenceCollectionSchema.extend({
content: z.string().optional(),
newContent: z.string().optional(),
new_content: z.string().optional(),
}).passthrough();
const CodexWriteOutputSchema = CodexFileReferenceCollectionSchema.extend({
content: z.string().optional(),
newContent: z.string().optional(),
new_content: z.string().optional(),
}).passthrough();
const CodexEditInputSchema = CodexFileReferenceCollectionSchema.extend({
old_string: z.string().optional(),
old_str: z.string().optional(),
oldContent: z.string().optional(),
old_content: z.string().optional(),
new_string: z.string().optional(),
new_str: z.string().optional(),
newContent: z.string().optional(),
new_content: z.string().optional(),
content: z.string().optional(),
patch: z.string().optional(),
diff: z.string().optional(),
unified_diff: z.string().optional(),
unifiedDiff: z.string().optional(),
}).passthrough();
const CodexEditOutputSchema = CodexFileReferenceCollectionSchema.extend({
patch: z.string().optional(),
diff: z.string().optional(),
unified_diff: z.string().optional(),
unifiedDiff: z.string().optional(),
files: z
.array(
CodexFileReferenceSchema.extend({
patch: z.string().optional(),
diff: z.string().optional(),
unified_diff: z.string().optional(),
unifiedDiff: z.string().optional(),
}).passthrough()
)
.optional(),
}).passthrough();
const CodexSearchInputSchema = z
.object({
query: z.string().optional(),
q: z.string().optional(),
})
.passthrough();
const CodexShellToolNameSchema = z.union([
z.literal("shell"),
z.literal("bash"),
z.literal("exec"),
z.literal("exec_command"),
z.literal("command"),
z.literal("Bash"),
]);
const CodexReadToolNameSchema = z.union([
z.literal("read"),
z.literal("read_file"),
]);
const CodexWriteToolNameSchema = z.union([
z.literal("write"),
z.literal("write_file"),
z.literal("create_file"),
]);
const CodexEditToolNameSchema = z.union([
z.literal("edit"),
z.literal("apply_patch"),
]);
const CodexSearchToolNameSchema = z.union([
z.literal("web_search"),
z.literal("search"),
]);
const CodexBuiltinToolNameSchema = z.enum([
"shell",
"bash",
"exec",
"exec_command",
"command",
"read",
"read_file",
"write",
"write_file",
"create_file",
"edit",
"apply_patch",
"web_search",
"search",
]);
const CodexShellDetailCandidateSchema = z
.object({
name: CodexShellToolNameSchema,
input: z.unknown().nullable(),
output: z.unknown().nullable(),
cwd: z.string().optional().nullable(),
})
.transform(({ input, output }) => resolveShellDetail(input, output));
const CodexReadDetailCandidateSchema = z
.object({
name: CodexReadToolNameSchema,
input: z.unknown().nullable(),
output: z.unknown().nullable(),
cwd: z.string().optional().nullable(),
})
.transform(({ input, output, cwd }) => resolveReadDetail(input, output, { cwd }));
const CodexWriteDetailCandidateSchema = z
.object({
name: CodexWriteToolNameSchema,
input: z.unknown().nullable(),
output: z.unknown().nullable(),
cwd: z.string().optional().nullable(),
})
.transform(({ input, output, cwd }) => resolveWriteDetail(input, output, { cwd }));
const CodexEditDetailCandidateSchema = z
.object({
name: CodexEditToolNameSchema,
input: z.unknown().nullable(),
output: z.unknown().nullable(),
cwd: z.string().optional().nullable(),
})
.transform(({ input, output, cwd }) => resolveEditDetail(input, output, { cwd }));
const CodexSearchDetailCandidateSchema = z
.object({
name: CodexSearchToolNameSchema,
input: z.unknown().nullable(),
output: z.unknown().nullable(),
cwd: z.string().optional().nullable(),
})
.transform(({ input }) => resolveSearchDetail(input));
const CodexKnownToolDetailSchema = z.union([
CodexShellDetailCandidateSchema,
CodexReadDetailCandidateSchema,
CodexWriteDetailCandidateSchema,
CodexEditDetailCandidateSchema,
CodexSearchDetailCandidateSchema,
]);
const CodexCommandExecutionItemSchema = z
.object({
type: z.literal("commandExecution"),
id: z.string().optional(),
status: z.string().optional(),
error: z.unknown().optional(),
command: z.union([z.string(), z.array(z.string())]).optional(),
cwd: z.string().optional(),
aggregatedOutput: z.string().optional(),
exitCode: z.number().nullable().optional(),
})
.passthrough();
const CodexFileChangeItemSchema = z
.object({
type: z.literal("fileChange"),
id: z.string().optional(),
status: z.string().optional(),
error: z.unknown().optional(),
changes: z
.array(
z
.object({
path: z.string().optional(),
kind: z.string().optional(),
diff: z.string().optional(),
})
.passthrough()
)
.optional(),
})
.passthrough();
const CodexMcpToolCallItemSchema = z
.object({
type: z.literal("mcpToolCall"),
id: z.string().optional(),
callID: z.string().optional(),
call_id: z.string().optional(),
status: z.string().optional(),
error: z.unknown().optional(),
tool: z.string().optional(),
server: z.string().optional(),
arguments: z.unknown().optional(),
result: z.unknown().optional(),
})
.passthrough();
const CodexWebSearchItemSchema = z
.object({
type: z.literal("webSearch"),
id: z.string().optional(),
status: z.string().optional(),
error: z.unknown().optional(),
query: z.string().optional(),
action: z.unknown().optional(),
})
.passthrough();
const CodexThreadItemSchema = z.discriminatedUnion("type", [
CodexCommandExecutionItemSchema,
CodexFileChangeItemSchema,
CodexMcpToolCallItemSchema,
CodexWebSearchItemSchema,
]);
function hashText(value: string): string {
let hash = 0;
for (let i = 0; i < value.length; i += 1) {
hash = (hash << 5) - hash + value.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash).toString(36);
}
function coerceCallId(raw: string | null | undefined, name: string, input: unknown): string {
if (typeof raw === "string" && raw.trim().length > 0) {
return raw;
}
let serialized = "";
try {
serialized = JSON.stringify(input) ?? "";
} catch {
serialized = String(input);
}
return `codex-${hashText(`${name}:${serialized}`)}`;
}
function normalizeCodexFilePath(filePath: string | undefined, cwd: string | null | undefined): string | undefined {
if (typeof filePath !== "string") {
return undefined;
}
const trimmed = filePath.trim();
if (!trimmed) {
return undefined;
}
if (typeof cwd === "string" && cwd.length > 0) {
const prefix = cwd.endsWith("/") ? cwd : `${cwd}/`;
if (trimmed.startsWith(prefix)) {
return trimmed.slice(prefix.length) || ".";
}
}
return trimmed;
}
function firstNonEmpty(...values: Array<string | undefined>): string | undefined {
return values.find((value) => typeof value === "string" && value.length > 0);
}
function commandFromValue(value: string | string[] | undefined): string | undefined {
if (typeof value === "string" && value.length > 0) {
return value;
}
if (Array.isArray(value)) {
const tokens = value.filter((token): token is string => typeof token === "string" && token.length > 0);
if (tokens.length > 0) {
return tokens.join(" ");
}
}
return undefined;
}
function resolveFilePath(
value: z.infer<typeof CodexFileReferenceCollectionSchema>,
cwd: string | null | undefined
): string | undefined {
return normalizeCodexFilePath(
firstNonEmpty(
value.file_path,
value.filePath,
value.path,
value.target_path,
value.targetPath,
value.files?.[0]?.path,
value.files?.[0]?.filePath,
value.files?.[0]?.file_path
),
cwd
);
}
function resolveStatus(rawStatus: string | undefined, error: unknown, output: unknown): ToolCallTimelineItem["status"] {
if (error !== undefined && error !== null) {
return "failed";
}
if (typeof rawStatus === "string") {
const normalized = rawStatus.trim().toLowerCase();
if (normalized.length > 0) {
if (FAILED_STATUSES.has(normalized)) {
return "failed";
}
if (CANCELED_STATUSES.has(normalized)) {
return "canceled";
}
if (COMPLETED_STATUSES.has(normalized)) {
return "completed";
}
return "running";
}
}
return output !== null && output !== undefined ? "completed" : "running";
}
function resolveShellDetail(input: unknown, output: unknown): ToolCallDetail | undefined {
const parsedInput = CodexShellInputSchema.safeParse(input);
const parsedOutput = CodexShellOutputSchema.safeParse(output);
const command =
(parsedInput.success
? commandFromValue(parsedInput.data.command) ?? commandFromValue(parsedInput.data.cmd)
: undefined) ??
(parsedOutput.success && typeof parsedOutput.data !== "string"
? parsedOutput.data.command
: undefined);
if (!command) {
return undefined;
}
const cwd = parsedInput.success
? firstNonEmpty(parsedInput.data.cwd, parsedInput.data.directory)
: undefined;
const outputText =
parsedOutput.success
? typeof parsedOutput.data === "string"
? parsedOutput.data
: firstNonEmpty(
parsedOutput.data.output,
parsedOutput.data.text,
parsedOutput.data.content,
parsedOutput.data.structuredContent?.output,
parsedOutput.data.structuredContent?.text,
parsedOutput.data.structuredContent?.content,
parsedOutput.data.structured_content?.output,
parsedOutput.data.structured_content?.text,
parsedOutput.data.structured_content?.content,
parsedOutput.data.result?.output,
parsedOutput.data.result?.text,
parsedOutput.data.result?.content
)
: undefined;
const exitCode =
parsedOutput.success && typeof parsedOutput.data !== "string"
? parsedOutput.data.exitCode ??
parsedOutput.data.exit_code ??
parsedOutput.data.metadata?.exitCode ??
parsedOutput.data.metadata?.exit_code ??
null
: null;
return {
type: "shell",
command,
...(cwd !== undefined ? { cwd } : {}),
...(outputText !== undefined ? { output: outputText } : {}),
...(exitCode !== null ? { exitCode } : { exitCode: null }),
};
}
function resolveReadDetail(input: unknown, output: unknown, options?: CodexMapperOptions): ToolCallDetail | undefined {
const parsedInput = CodexReadInputSchema.safeParse(input);
const parsedOutput = CodexReadOutputSchema.safeParse(output);
const filePath = firstNonEmpty(
parsedInput.success ? resolveFilePath(parsedInput.data, options?.cwd) : undefined,
parsedOutput.success && typeof parsedOutput.data !== "string"
? resolveFilePath(parsedOutput.data, options?.cwd)
: undefined
);
if (!filePath) {
return undefined;
}
const content =
parsedOutput.success
? typeof parsedOutput.data === "string"
? parsedOutput.data
: firstNonEmpty(
parsedOutput.data.content,
parsedOutput.data.text,
parsedOutput.data.output,
parsedOutput.data.structuredContent?.content,
parsedOutput.data.structuredContent?.text,
parsedOutput.data.structuredContent?.output,
parsedOutput.data.structured_content?.content,
parsedOutput.data.structured_content?.text,
parsedOutput.data.structured_content?.output,
parsedOutput.data.data?.content,
parsedOutput.data.data?.text,
parsedOutput.data.data?.output
)
: undefined;
return {
type: "read",
filePath,
...(content !== undefined ? { content } : {}),
...(parsedInput.success && parsedInput.data.offset !== undefined ? { offset: parsedInput.data.offset } : {}),
...(parsedInput.success && parsedInput.data.limit !== undefined ? { limit: parsedInput.data.limit } : {}),
};
}
function resolveWriteDetail(input: unknown, output: unknown, options?: CodexMapperOptions): ToolCallDetail | undefined {
const parsedInput = CodexWriteInputSchema.safeParse(input);
const parsedOutput = CodexWriteOutputSchema.safeParse(output);
const filePath = firstNonEmpty(
parsedInput.success ? resolveFilePath(parsedInput.data, options?.cwd) : undefined,
parsedOutput.success ? resolveFilePath(parsedOutput.data, options?.cwd) : undefined
);
if (!filePath) {
return undefined;
}
const content = firstNonEmpty(
parsedInput.success
? firstNonEmpty(parsedInput.data.content, parsedInput.data.newContent, parsedInput.data.new_content)
: undefined,
parsedOutput.success
? firstNonEmpty(parsedOutput.data.content, parsedOutput.data.newContent, parsedOutput.data.new_content)
: undefined
);
return {
type: "write",
filePath,
...(content !== undefined ? { content } : {}),
};
}
function resolveEditDetail(input: unknown, output: unknown, options?: CodexMapperOptions): ToolCallDetail | undefined {
const parsedInput = CodexEditInputSchema.safeParse(input);
const parsedOutput = CodexEditOutputSchema.safeParse(output);
const filePath = firstNonEmpty(
parsedInput.success ? resolveFilePath(parsedInput.data, options?.cwd) : undefined,
parsedOutput.success ? resolveFilePath(parsedOutput.data, options?.cwd) : undefined
);
if (!filePath) {
return undefined;
}
const oldString = parsedInput.success
? firstNonEmpty(
parsedInput.data.old_string,
parsedInput.data.old_str,
parsedInput.data.oldContent,
parsedInput.data.old_content
)
: undefined;
const newString = parsedInput.success
? firstNonEmpty(
parsedInput.data.new_string,
parsedInput.data.new_str,
parsedInput.data.newContent,
parsedInput.data.new_content,
parsedInput.data.content
)
: undefined;
const unifiedDiff = firstNonEmpty(
parsedInput.success
? firstNonEmpty(
parsedInput.data.patch,
parsedInput.data.diff,
parsedInput.data.unified_diff,
parsedInput.data.unifiedDiff
)
: undefined,
parsedOutput.success
? firstNonEmpty(
parsedOutput.data.patch,
parsedOutput.data.diff,
parsedOutput.data.unified_diff,
parsedOutput.data.unifiedDiff,
parsedOutput.data.files?.[0]?.patch,
parsedOutput.data.files?.[0]?.diff,
parsedOutput.data.files?.[0]?.unified_diff,
parsedOutput.data.files?.[0]?.unifiedDiff
)
: undefined
);
return {
type: "edit",
filePath,
...(oldString !== undefined ? { oldString } : {}),
...(newString !== undefined ? { newString } : {}),
...(unifiedDiff !== undefined ? { unifiedDiff } : {}),
};
}
function resolveSearchDetail(input: unknown): ToolCallDetail | undefined {
const parsedInput = CodexSearchInputSchema.safeParse(input);
if (!parsedInput.success) {
return undefined;
}
const query = firstNonEmpty(parsedInput.data.query, parsedInput.data.q);
if (!query) {
return undefined;
}
return {
type: "search",
query,
};
}
function deriveDetail(name: string, input: unknown, output: unknown, options?: CodexMapperOptions): ToolCallDetail | undefined {
const parsed = CodexKnownToolDetailSchema.safeParse({
name,
input,
output,
cwd: options?.cwd ?? null,
});
if (!parsed.success) {
return undefined;
}
return parsed.data;
}
function buildToolCall(
params: {
callId: string;
name: string;
status: ToolCallTimelineItem["status"];
input: unknown | null;
output: unknown | null;
error: unknown | null;
metadata?: Record<string, unknown>;
},
options?: CodexMapperOptions
): ToolCallTimelineItem {
const detail = deriveDetail(params.name, params.input, params.output, options);
if (params.status === "failed") {
return {
type: "tool_call",
callId: params.callId,
name: params.name,
status: "failed",
input: params.input,
output: params.output,
error: params.error ?? { message: "Tool call failed" },
...(detail ? { detail } : {}),
...(params.metadata ? { metadata: params.metadata } : {}),
};
}
return {
type: "tool_call",
callId: params.callId,
name: params.name,
status: params.status,
input: params.input,
output: params.output,
error: null,
...(detail ? { detail } : {}),
...(params.metadata ? { metadata: params.metadata } : {}),
};
}
function buildMcpToolName(server: string | undefined, tool: string): string {
const trimmedTool = tool.trim();
if (!trimmedTool) {
return "tool";
}
const builtin = CodexBuiltinToolNameSchema.safeParse(trimmedTool);
if (builtin.success) {
return builtin.data;
}
const trimmedServer = typeof server === "string" ? server.trim() : "";
if (trimmedServer.length > 0) {
return `${trimmedServer}.${trimmedTool}`;
}
return trimmedTool;
}
function toNullableObject(value: Record<string, unknown>): Record<string, unknown> | null {
return Object.keys(value).length > 0 ? value : null;
}
function mapCommandExecutionItem(
item: z.infer<typeof CodexCommandExecutionItemSchema>,
options?: CodexMapperOptions
): ToolCallTimelineItem {
const command = commandFromValue(item.command);
const input = toNullableObject({
...(command !== undefined ? { command } : {}),
...(item.cwd !== undefined ? { cwd: item.cwd } : {}),
});
const output =
item.aggregatedOutput !== undefined || item.exitCode !== undefined
? {
...(command !== undefined ? { command } : {}),
...(item.aggregatedOutput !== undefined ? { output: item.aggregatedOutput } : {}),
...(item.exitCode !== undefined ? { exitCode: item.exitCode } : {}),
}
: null;
const name = "shell";
const callId = coerceCallId(item.id, name, input);
const error = item.error ?? null;
const status = resolveStatus(item.status, error, output);
return buildToolCall(
{
callId,
name,
status,
input,
output,
error,
},
options
);
}
function mapFileChangeItem(
item: z.infer<typeof CodexFileChangeItemSchema>,
options?: CodexMapperOptions
): ToolCallTimelineItem {
const changes = item.changes ?? [];
const files = changes.map((change) => ({
...(normalizeCodexFilePath(change.path, options?.cwd) !== undefined
? { path: normalizeCodexFilePath(change.path, options?.cwd) }
: {}),
...(change.kind !== undefined ? { kind: change.kind } : {}),
}));
const outputFiles = changes.map((change) => ({
...(normalizeCodexFilePath(change.path, options?.cwd) !== undefined
? { path: normalizeCodexFilePath(change.path, options?.cwd) }
: {}),
...(change.diff !== undefined ? { patch: change.diff } : {}),
...(change.kind !== undefined ? { kind: change.kind } : {}),
}));
const input = toNullableObject({ ...(files.length > 0 ? { files } : {}) });
const output = toNullableObject({ ...(outputFiles.length > 0 ? { files: outputFiles } : {}) });
const name = "apply_patch";
const callId = coerceCallId(item.id, name, input);
const error = item.error ?? null;
const status = resolveStatus(item.status, error, output);
return buildToolCall(
{
callId,
name,
status,
input,
output,
error,
},
options
);
}
function mapMcpToolCallItem(
item: z.infer<typeof CodexMcpToolCallItemSchema>,
options?: CodexMapperOptions
): ToolCallTimelineItem {
const tool = item.tool?.trim() || "tool";
const name = buildMcpToolName(item.server, tool);
const input = item.arguments ?? null;
const output = item.result ?? null;
const error = item.error ?? null;
const callId = coerceCallId(item.id ?? item.callID ?? item.call_id, name, input);
const status = resolveStatus(item.status, error, output);
return buildToolCall(
{
callId,
name,
status,
input,
output,
error,
},
options
);
}
function mapWebSearchItem(
item: z.infer<typeof CodexWebSearchItemSchema>,
options?: CodexMapperOptions
): ToolCallTimelineItem {
const input = item.query !== undefined ? { query: item.query } : null;
const output = item.action ?? null;
const name = "web_search";
const callId = coerceCallId(item.id, name, input);
const error = item.error ?? null;
const status = resolveStatus(item.status ?? "completed", error, output);
return buildToolCall(
{
callId,
name,
status,
input,
output,
error,
},
options
);
}
function createCodexThreadItemToTimelineSchema(options?: CodexMapperOptions) {
return CodexThreadItemSchema.transform((item): ToolCallTimelineItem => {
switch (item.type) {
case "commandExecution":
return mapCommandExecutionItem(item, options);
case "fileChange":
return mapFileChangeItem(item, options);
case "mcpToolCall":
return mapMcpToolCallItem(item, options);
case "webSearch":
return mapWebSearchItem(item, options);
default: {
const exhaustiveCheck: never = item;
throw new Error(`Unhandled Codex thread item type: ${String(exhaustiveCheck)}`);
}
}
});
}
export function mapCodexToolCallFromThreadItem(
item: unknown,
options?: CodexMapperOptions
): ToolCallTimelineItem | null {
const parsed = createCodexThreadItemToTimelineSchema(options).safeParse(item);
if (!parsed.success) {
return null;
}
return parsed.data;
}
export function mapCodexRolloutToolCall(params: {
callId?: string | null;
name: string;
input?: unknown;
output?: unknown;
error?: unknown;
}): ToolCallTimelineItem {
const parsed = CodexRolloutToolCallParamsSchema.parse(params);
const input = parsed.input ?? null;
const output = parsed.output ?? null;
const error = parsed.error ?? null;
const status = resolveStatus("completed", error, output);
const callId = coerceCallId(parsed.callId, parsed.name, input);
return buildToolCall({
callId,
name: parsed.name,
status,
input,
output,
error,
});
}
@@ -26,6 +26,7 @@ import type {
McpServerConfig,
PersistedAgentDescriptor,
} from "../agent-sdk-types.js";
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
@@ -620,15 +621,14 @@ class OpenCodeAgentSession implements AgentSession {
yield {
type: "timeline",
provider: "opencode",
item: {
type: "tool_call",
name: toolName,
item: mapOpencodeToolCall({
toolName,
callId: toolPart.callID ?? toolPart.id,
status: this.mapToolState(state?.status),
status: state?.status,
input: state?.input,
output: state?.output,
error: state?.error,
},
}),
};
}
}
@@ -895,15 +895,14 @@ class OpenCodeAgentSession implements AgentSession {
events.push({
type: "timeline",
provider: "opencode",
item: {
type: "tool_call",
name: toolName,
callId: part.callID as string | undefined,
status: this.mapToolState(status),
item: mapOpencodeToolCall({
toolName,
callId: (part.callID as string | undefined) ?? (part.id as string | undefined),
status,
input,
output,
error,
},
}),
});
}
} else if (partType === "step-finish") {
@@ -983,21 +982,6 @@ class OpenCodeAgentSession implements AgentSession {
return events;
}
private mapToolState(state?: string): string {
switch (state) {
case "pending":
return "pending";
case "running":
return "running";
case "complete":
return "completed";
case "error":
return "failed";
default:
return "pending";
}
}
private extractAndResetUsage(): AgentUsage | undefined {
const usage = this.accumulatedUsage;
this.accumulatedUsage = {};
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { mapOpencodeToolCall } from "./tool-call-mapper.js";
describe("opencode tool-call mapper", () => {
it("maps running shell calls", () => {
const item = mapOpencodeToolCall({
toolName: "shell",
callId: "opencode-call-1",
status: "running",
input: { command: "pwd", cwd: "/tmp/repo" },
output: null,
});
expect(item.status).toBe("running");
expect(item.error).toBeNull();
expect(item.callId).toBe("opencode-call-1");
expect(item.detail?.type).toBe("shell");
if (item.detail?.type === "shell") {
expect(item.detail.command).toBe("pwd");
}
});
it("maps completed read calls", () => {
const item = mapOpencodeToolCall({
toolName: "read_file",
callId: "opencode-call-2",
status: "complete",
input: { file_path: "README.md" },
output: { content: "hello" },
});
expect(item.status).toBe("completed");
expect(item.error).toBeNull();
expect(item.callId).toBe("opencode-call-2");
expect(item.detail?.type).toBe("read");
if (item.detail?.type === "read") {
expect(item.detail.filePath).toBe("README.md");
expect(item.detail.content).toBe("hello");
}
});
it("maps failed calls with required error", () => {
const item = mapOpencodeToolCall({
toolName: "shell",
callId: "opencode-call-3",
status: "error",
input: { command: "false" },
output: null,
error: "command failed",
});
expect(item.status).toBe("failed");
expect(item.error).toBe("command failed");
expect(item.callId).toBe("opencode-call-3");
});
it("keeps unknown tools canonical without detail", () => {
const item = mapOpencodeToolCall({
toolName: "my_custom_tool",
callId: "opencode-call-4",
status: "completed",
input: { foo: "bar" },
output: { ok: true },
});
expect(item.status).toBe("completed");
expect(item.error).toBeNull();
expect(item.detail).toBeUndefined();
expect(item.input).toEqual({ foo: "bar" });
expect(item.output).toEqual({ ok: true });
});
});
@@ -0,0 +1,561 @@
import { z } from "zod";
import type { ToolCallDetail, ToolCallTimelineItem } from "../../agent-sdk-types.js";
type OpencodeToolCallParams = {
toolName: string;
callId?: string | null;
status?: unknown;
input?: unknown;
output?: unknown;
error?: unknown;
metadata?: Record<string, unknown>;
};
const FAILED_STATUSES = new Set(["error", "failed", "failure"]);
const CANCELED_STATUSES = new Set(["canceled", "cancelled", "aborted", "interrupted"]);
const COMPLETED_STATUSES = new Set(["complete", "completed", "success", "succeeded", "done"]);
const OpencodeToolCallParamsSchema = z
.object({
toolName: z.string().min(1),
callId: z.string().optional().nullable(),
status: z.unknown().optional(),
input: z.unknown().optional(),
output: z.unknown().optional(),
error: z.unknown().optional(),
metadata: z.record(z.unknown()).optional(),
})
.passthrough();
const OpencodeShellToolNameSchema = z.union([
z.literal("shell"),
z.literal("bash"),
z.literal("exec_command"),
]);
const OpencodeReadToolNameSchema = z.union([
z.literal("read"),
z.literal("read_file"),
]);
const OpencodeWriteToolNameSchema = z.union([
z.literal("write"),
z.literal("write_file"),
z.literal("create_file"),
]);
const OpencodeEditToolNameSchema = z.union([
z.literal("edit"),
z.literal("apply_patch"),
z.literal("apply_diff"),
]);
const OpencodeSearchToolNameSchema = z.union([
z.literal("search"),
z.literal("web_search"),
]);
const OpencodeFileReferenceSchema = z
.object({
file_path: z.string().optional(),
filePath: z.string().optional(),
path: z.string().optional(),
target_path: z.string().optional(),
targetPath: z.string().optional(),
})
.passthrough();
const OpencodeFileReferenceCollectionSchema = OpencodeFileReferenceSchema.extend({
files: z.array(OpencodeFileReferenceSchema).optional(),
}).passthrough();
const OpencodeTextLikeSchema = z
.object({
output: z.string().optional(),
text: z.string().optional(),
content: z.string().optional(),
})
.passthrough();
const OpencodeShellInputSchema = z
.object({
command: z.union([z.string(), z.array(z.string())]).optional(),
cmd: z.union([z.string(), z.array(z.string())]).optional(),
cwd: z.string().optional(),
directory: z.string().optional(),
})
.passthrough();
const OpencodeShellOutputObjectSchema = z
.object({
command: z.string().optional(),
output: z.string().optional(),
text: z.string().optional(),
content: z.string().optional(),
exitCode: z.number().nullable().optional(),
exit_code: z.number().nullable().optional(),
metadata: z
.object({
exitCode: z.number().nullable().optional(),
exit_code: z.number().nullable().optional(),
})
.passthrough()
.optional(),
structuredContent: OpencodeTextLikeSchema.optional(),
structured_content: OpencodeTextLikeSchema.optional(),
result: OpencodeTextLikeSchema.optional(),
})
.passthrough();
const OpencodeShellOutputSchema = z.union([z.string(), OpencodeShellOutputObjectSchema]);
const OpencodeReadInputSchema = OpencodeFileReferenceCollectionSchema.extend({
offset: z.number().finite().optional(),
limit: z.number().finite().optional(),
}).passthrough();
const OpencodeReadOutputSchema = z.union([
z.string(),
OpencodeFileReferenceCollectionSchema.extend({
content: z.string().optional(),
text: z.string().optional(),
output: z.string().optional(),
structuredContent: OpencodeTextLikeSchema.optional(),
structured_content: OpencodeTextLikeSchema.optional(),
data: OpencodeTextLikeSchema.optional(),
}).passthrough(),
]);
const OpencodeWriteInputSchema = OpencodeFileReferenceCollectionSchema.extend({
content: z.string().optional(),
newContent: z.string().optional(),
new_content: z.string().optional(),
}).passthrough();
const OpencodeWriteOutputSchema = OpencodeFileReferenceCollectionSchema.extend({
content: z.string().optional(),
newContent: z.string().optional(),
new_content: z.string().optional(),
}).passthrough();
const OpencodeEditInputSchema = OpencodeFileReferenceCollectionSchema.extend({
old_string: z.string().optional(),
old_str: z.string().optional(),
oldContent: z.string().optional(),
old_content: z.string().optional(),
new_string: z.string().optional(),
new_str: z.string().optional(),
newContent: z.string().optional(),
new_content: z.string().optional(),
content: z.string().optional(),
patch: z.string().optional(),
diff: z.string().optional(),
unified_diff: z.string().optional(),
unifiedDiff: z.string().optional(),
}).passthrough();
const OpencodeEditOutputSchema = OpencodeFileReferenceCollectionSchema.extend({
patch: z.string().optional(),
diff: z.string().optional(),
unified_diff: z.string().optional(),
unifiedDiff: z.string().optional(),
files: z
.array(
OpencodeFileReferenceSchema.extend({
patch: z.string().optional(),
diff: z.string().optional(),
unified_diff: z.string().optional(),
unifiedDiff: z.string().optional(),
}).passthrough()
)
.optional(),
}).passthrough();
const OpencodeSearchInputSchema = z
.object({
query: z.string().optional(),
q: z.string().optional(),
})
.passthrough();
const OpencodeShellDetailCandidateSchema = z
.object({
toolName: OpencodeShellToolNameSchema,
input: z.unknown().nullable(),
output: z.unknown().nullable(),
})
.transform(({ input, output }) => resolveShellDetail(input, output));
const OpencodeReadDetailCandidateSchema = z
.object({
toolName: OpencodeReadToolNameSchema,
input: z.unknown().nullable(),
output: z.unknown().nullable(),
})
.transform(({ input, output }) => resolveReadDetail(input, output));
const OpencodeWriteDetailCandidateSchema = z
.object({
toolName: OpencodeWriteToolNameSchema,
input: z.unknown().nullable(),
output: z.unknown().nullable(),
})
.transform(({ input, output }) => resolveWriteDetail(input, output));
const OpencodeEditDetailCandidateSchema = z
.object({
toolName: OpencodeEditToolNameSchema,
input: z.unknown().nullable(),
output: z.unknown().nullable(),
})
.transform(({ input, output }) => resolveEditDetail(input, output));
const OpencodeSearchDetailCandidateSchema = z
.object({
toolName: OpencodeSearchToolNameSchema,
input: z.unknown().nullable(),
output: z.unknown().nullable(),
})
.transform(({ input }) => resolveSearchDetail(input));
const OpencodeKnownToolDetailSchema = z.union([
OpencodeShellDetailCandidateSchema,
OpencodeReadDetailCandidateSchema,
OpencodeWriteDetailCandidateSchema,
OpencodeEditDetailCandidateSchema,
OpencodeSearchDetailCandidateSchema,
]);
function firstNonEmpty(...values: Array<string | undefined>): string | undefined {
return values.find((value) => typeof value === "string" && value.length > 0);
}
function commandFromValue(value: string | string[] | undefined): string | undefined {
if (typeof value === "string" && value.length > 0) {
return value;
}
if (Array.isArray(value)) {
const tokens = value.filter((token): token is string => typeof token === "string" && token.length > 0);
if (tokens.length > 0) {
return tokens.join(" ");
}
}
return undefined;
}
function resolveFilePath(value: z.infer<typeof OpencodeFileReferenceCollectionSchema>): string | undefined {
return firstNonEmpty(
value.file_path,
value.filePath,
value.path,
value.target_path,
value.targetPath,
value.files?.[0]?.path,
value.files?.[0]?.filePath,
value.files?.[0]?.file_path
);
}
function hashText(value: string): string {
let hash = 0;
for (let i = 0; i < value.length; i += 1) {
hash = (hash << 5) - hash + value.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash).toString(36);
}
function coerceCallId(callId: string | null | undefined, toolName: string, input: unknown): string {
if (typeof callId === "string" && callId.trim().length > 0) {
return callId;
}
let serialized = "";
try {
serialized = JSON.stringify(input) ?? "";
} catch {
serialized = String(input);
}
return `opencode-${hashText(`${toolName}:${serialized}`)}`;
}
function resolveStatus(rawStatus: unknown, error: unknown, output: unknown): ToolCallTimelineItem["status"] {
if (error !== null && error !== undefined) {
return "failed";
}
if (typeof rawStatus === "string") {
const normalized = rawStatus.trim().toLowerCase();
if (normalized.length > 0) {
if (FAILED_STATUSES.has(normalized)) {
return "failed";
}
if (CANCELED_STATUSES.has(normalized)) {
return "canceled";
}
if (COMPLETED_STATUSES.has(normalized)) {
return "completed";
}
return "running";
}
}
return output !== null && output !== undefined ? "completed" : "running";
}
function resolveShellDetail(input: unknown, output: unknown): ToolCallDetail | undefined {
const parsedInput = OpencodeShellInputSchema.safeParse(input);
const parsedOutput = OpencodeShellOutputSchema.safeParse(output);
const command =
(parsedInput.success
? commandFromValue(parsedInput.data.command) ?? commandFromValue(parsedInput.data.cmd)
: undefined) ??
(parsedOutput.success && typeof parsedOutput.data !== "string"
? parsedOutput.data.command
: undefined);
if (!command) {
return undefined;
}
const cwd = parsedInput.success
? firstNonEmpty(parsedInput.data.cwd, parsedInput.data.directory)
: undefined;
const outputText =
parsedOutput.success
? typeof parsedOutput.data === "string"
? parsedOutput.data
: firstNonEmpty(
parsedOutput.data.output,
parsedOutput.data.text,
parsedOutput.data.content,
parsedOutput.data.structuredContent?.output,
parsedOutput.data.structuredContent?.text,
parsedOutput.data.structuredContent?.content,
parsedOutput.data.structured_content?.output,
parsedOutput.data.structured_content?.text,
parsedOutput.data.structured_content?.content,
parsedOutput.data.result?.output,
parsedOutput.data.result?.text,
parsedOutput.data.result?.content
)
: undefined;
const exitCode =
parsedOutput.success && typeof parsedOutput.data !== "string"
? parsedOutput.data.exitCode ?? parsedOutput.data.exit_code ?? parsedOutput.data.metadata?.exitCode ?? parsedOutput.data.metadata?.exit_code ?? null
: null;
return {
type: "shell",
command,
...(cwd !== undefined ? { cwd } : {}),
...(outputText !== undefined ? { output: outputText } : {}),
...(exitCode !== null ? { exitCode } : { exitCode: null }),
};
}
function resolveReadDetail(input: unknown, output: unknown): ToolCallDetail | undefined {
const parsedInput = OpencodeReadInputSchema.safeParse(input);
const parsedOutput = OpencodeReadOutputSchema.safeParse(output);
const filePath = firstNonEmpty(
parsedInput.success ? resolveFilePath(parsedInput.data) : undefined,
parsedOutput.success && typeof parsedOutput.data !== "string"
? resolveFilePath(parsedOutput.data)
: undefined
);
if (!filePath) {
return undefined;
}
const content =
parsedOutput.success
? typeof parsedOutput.data === "string"
? parsedOutput.data
: firstNonEmpty(
parsedOutput.data.content,
parsedOutput.data.text,
parsedOutput.data.output,
parsedOutput.data.structuredContent?.content,
parsedOutput.data.structuredContent?.text,
parsedOutput.data.structuredContent?.output,
parsedOutput.data.structured_content?.content,
parsedOutput.data.structured_content?.text,
parsedOutput.data.structured_content?.output,
parsedOutput.data.data?.content,
parsedOutput.data.data?.text,
parsedOutput.data.data?.output
)
: undefined;
return {
type: "read",
filePath,
...(content !== undefined ? { content } : {}),
...(parsedInput.success && parsedInput.data.offset !== undefined ? { offset: parsedInput.data.offset } : {}),
...(parsedInput.success && parsedInput.data.limit !== undefined ? { limit: parsedInput.data.limit } : {}),
};
}
function resolveWriteDetail(input: unknown, output: unknown): ToolCallDetail | undefined {
const parsedInput = OpencodeWriteInputSchema.safeParse(input);
const parsedOutput = OpencodeWriteOutputSchema.safeParse(output);
const filePath = firstNonEmpty(
parsedInput.success ? resolveFilePath(parsedInput.data) : undefined,
parsedOutput.success ? resolveFilePath(parsedOutput.data) : undefined
);
if (!filePath) {
return undefined;
}
const content = firstNonEmpty(
parsedInput.success
? firstNonEmpty(parsedInput.data.content, parsedInput.data.newContent, parsedInput.data.new_content)
: undefined,
parsedOutput.success
? firstNonEmpty(parsedOutput.data.content, parsedOutput.data.newContent, parsedOutput.data.new_content)
: undefined
);
return {
type: "write",
filePath,
...(content !== undefined ? { content } : {}),
};
}
function resolveEditDetail(input: unknown, output: unknown): ToolCallDetail | undefined {
const parsedInput = OpencodeEditInputSchema.safeParse(input);
const parsedOutput = OpencodeEditOutputSchema.safeParse(output);
const filePath = firstNonEmpty(
parsedInput.success ? resolveFilePath(parsedInput.data) : undefined,
parsedOutput.success ? resolveFilePath(parsedOutput.data) : undefined
);
if (!filePath) {
return undefined;
}
const oldString = parsedInput.success
? firstNonEmpty(
parsedInput.data.old_string,
parsedInput.data.old_str,
parsedInput.data.oldContent,
parsedInput.data.old_content
)
: undefined;
const newString = parsedInput.success
? firstNonEmpty(
parsedInput.data.new_string,
parsedInput.data.new_str,
parsedInput.data.newContent,
parsedInput.data.new_content,
parsedInput.data.content
)
: undefined;
const unifiedDiff = firstNonEmpty(
parsedInput.success
? firstNonEmpty(
parsedInput.data.patch,
parsedInput.data.diff,
parsedInput.data.unified_diff,
parsedInput.data.unifiedDiff
)
: undefined,
parsedOutput.success
? firstNonEmpty(
parsedOutput.data.patch,
parsedOutput.data.diff,
parsedOutput.data.unified_diff,
parsedOutput.data.unifiedDiff,
parsedOutput.data.files?.[0]?.patch,
parsedOutput.data.files?.[0]?.diff,
parsedOutput.data.files?.[0]?.unified_diff,
parsedOutput.data.files?.[0]?.unifiedDiff
)
: undefined
);
return {
type: "edit",
filePath,
...(oldString !== undefined ? { oldString } : {}),
...(newString !== undefined ? { newString } : {}),
...(unifiedDiff !== undefined ? { unifiedDiff } : {}),
};
}
function resolveSearchDetail(input: unknown): ToolCallDetail | undefined {
const parsedInput = OpencodeSearchInputSchema.safeParse(input);
if (!parsedInput.success) {
return undefined;
}
const query = firstNonEmpty(parsedInput.data.query, parsedInput.data.q);
if (!query) {
return undefined;
}
return {
type: "search",
query,
};
}
function deriveDetail(toolName: string, input: unknown, output: unknown): ToolCallDetail | undefined {
const parsed = OpencodeKnownToolDetailSchema.safeParse({
toolName,
input,
output,
});
if (!parsed.success) {
return undefined;
}
return parsed.data;
}
export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTimelineItem {
const parsedParams = OpencodeToolCallParamsSchema.parse(params);
const input = parsedParams.input ?? null;
const output = parsedParams.output ?? null;
const status = resolveStatus(parsedParams.status, parsedParams.error, output);
const callId = coerceCallId(parsedParams.callId, parsedParams.toolName, input);
const detail = deriveDetail(parsedParams.toolName, input, output);
if (status === "failed") {
return {
type: "tool_call",
callId,
name: parsedParams.toolName,
status: "failed",
input,
output,
error: parsedParams.error ?? { message: "Tool call failed" },
...(detail ? { detail } : {}),
...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}),
};
}
return {
type: "tool_call",
callId,
name: parsedParams.toolName,
status,
input,
output,
error: null,
...(detail ? { detail } : {}),
...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}),
};
}
+11
View File
@@ -1929,6 +1929,8 @@ export class Session {
worktreePath: worktree.worktreePath,
branchName: worktree.branchName,
},
output: null,
error: null,
});
if (!started) {
return;
@@ -1945,6 +1947,10 @@ export class Session {
name: "paseo_worktree_setup",
callId,
status: "completed",
input: {
worktreePath: worktree.worktreePath,
branchName: worktree.branchName,
},
output: {
worktreePath: worktree.worktreePath,
commands: results.map((result) => ({
@@ -1954,6 +1960,7 @@ export class Session {
output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(),
})),
},
error: null,
});
} catch (error: any) {
if (error instanceof WorktreeSetupError) {
@@ -1965,6 +1972,10 @@ export class Session {
name: "paseo_worktree_setup",
callId,
status: "failed",
input: {
worktreePath: worktree.worktreePath,
branchName: worktree.branchName,
},
output: {
worktreePath: worktree.worktreePath,
commands: results.map((result) => ({
@@ -246,7 +246,9 @@ class FakeAgentSession implements AgentSession {
name: tool.name,
callId,
status: "running",
input: tool.input ?? undefined,
input: tool.input ?? null,
output: null,
error: null,
},
};
await this.appendHistoryEvent(toolRunning);
@@ -325,8 +327,9 @@ class FakeAgentSession implements AgentSession {
name: tool.name,
callId,
status: "completed",
input: tool.input ?? undefined,
input: tool.input ?? null,
output: toolOutput ?? { ok: true },
error: null,
},
};
await this.appendHistoryEvent(toolCompleted);
@@ -0,0 +1,24 @@
{
"type": "agent_stream_snapshot",
"payload": {
"agentId": "agent_fixture_legacy",
"events": [
{
"timestamp": "2026-02-08T20:00:00.000Z",
"event": {
"type": "timeline",
"provider": "codex",
"item": {
"type": "tool_call",
"callId": "call_fixture_legacy",
"name": "shell",
"status": "inProgress",
"input": {
"command": "pwd"
}
}
}
}
]
}
}
@@ -0,0 +1,84 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
AgentStreamMessageSchema,
AgentStreamSnapshotMessageSchema,
WSOutboundMessageSchema,
} from "./messages.js";
function loadFixture(name: string): unknown {
const url = new URL(`./__fixtures__/${name}`, import.meta.url);
return JSON.parse(readFileSync(url, "utf8"));
}
describe("shared messages stream parsing", () => {
it("parses legacy inProgress tool_call snapshots and normalizes status", () => {
const fixture = loadFixture("legacy-agent-stream-snapshot-inProgress.json");
const parsed = AgentStreamSnapshotMessageSchema.parse(fixture);
const first = parsed.payload.events[0]?.event;
expect(first?.type).toBe("timeline");
if (first?.type === "timeline" && first.item.type === "tool_call") {
expect(first.item.status).toBe("running");
expect(first.item.error).toBeNull();
expect(first.item.output).toBeNull();
}
});
it("parses representative agent_stream tool_call event", () => {
const parsed = AgentStreamMessageSchema.parse({
type: "agent_stream",
payload: {
agentId: "agent_live",
timestamp: "2026-02-08T20:10:00.000Z",
event: {
type: "timeline",
provider: "claude",
item: {
type: "tool_call",
callId: "call_live",
name: "shell",
status: "running",
input: { command: "ls" },
output: null,
error: null,
detail: {
type: "shell",
command: "ls",
},
},
},
},
});
expect(parsed.payload.event.type).toBe("timeline");
if (parsed.payload.event.type === "timeline") {
expect(parsed.payload.event.item.type).toBe("tool_call");
if (parsed.payload.event.item.type === "tool_call") {
expect(parsed.payload.event.item.status).toBe("running");
}
}
});
it("parses websocket envelope for agent_stream_snapshot with legacy status", () => {
const fixture = loadFixture("legacy-agent-stream-snapshot-inProgress.json") as {
type: "agent_stream_snapshot";
payload: unknown;
};
const wrapped = WSOutboundMessageSchema.parse({
type: "session",
message: fixture,
});
if (wrapped.type === "session" && wrapped.message.type === "agent_stream_snapshot") {
const first = wrapped.message.payload.events[0]?.event;
expect(first?.type).toBe("timeline");
if (first?.type === "timeline" && first.item.type === "tool_call") {
expect(first.item.status).toBe("running");
expect(first.item.error).toBeNull();
}
}
});
});
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import { AgentTimelineItemPayloadSchema } from "./messages.js";
function canonicalBase() {
return {
type: "tool_call" as const,
callId: "call_123",
name: "shell",
input: { command: "pwd" },
output: null,
};
}
describe("shared messages tool_call schema", () => {
it("parses each status-discriminated tool_call variant at runtime", () => {
const running = AgentTimelineItemPayloadSchema.parse({
...canonicalBase(),
status: "running",
error: null,
detail: {
type: "shell",
command: "pwd",
},
});
const completed = AgentTimelineItemPayloadSchema.parse({
...canonicalBase(),
status: "completed",
error: null,
output: { output: "/tmp/repo" },
});
const failed = AgentTimelineItemPayloadSchema.parse({
...canonicalBase(),
status: "failed",
error: { message: "command failed" },
});
const canceled = AgentTimelineItemPayloadSchema.parse({
...canonicalBase(),
status: "canceled",
error: null,
});
expect(running.type).toBe("tool_call");
expect(completed.type).toBe("tool_call");
expect(failed.type).toBe("tool_call");
expect(canceled.type).toBe("tool_call");
});
it("rejects non-recoverable invalid tool_call payloads", () => {
const missingCallId = AgentTimelineItemPayloadSchema.safeParse({
type: "tool_call",
name: "shell",
status: "running",
input: { command: "pwd" },
output: null,
error: null,
});
const unknownStatus = AgentTimelineItemPayloadSchema.safeParse({
...canonicalBase(),
status: "mystery_status",
error: null,
});
expect(missingCallId.success).toBe(false);
expect(unknownStatus.success).toBe(false);
});
it("normalizes recoverable legacy status/error combinations", () => {
const completedWithError = AgentTimelineItemPayloadSchema.safeParse({
...canonicalBase(),
status: "completed",
error: { message: "unexpected" },
});
const failedWithoutError = AgentTimelineItemPayloadSchema.safeParse({
...canonicalBase(),
status: "failed",
error: null,
});
const missingOutput = AgentTimelineItemPayloadSchema.safeParse({
type: "tool_call",
callId: "call_missing_output",
name: "shell",
status: "running",
input: { command: "pwd" },
error: null,
});
expect(completedWithError.success).toBe(true);
expect(failedWithoutError.success).toBe(true);
expect(missingOutput.success).toBe(true);
if (completedWithError.success && completedWithError.data.type === "tool_call") {
expect(completedWithError.data.error).toBeNull();
}
if (failedWithoutError.success && failedWithoutError.data.type === "tool_call") {
expect(failedWithoutError.data.error).toEqual({ message: "Tool call failed" });
}
if (missingOutput.success && missingOutput.data.type === "tool_call") {
expect(missingOutput.data.output).toBeNull();
}
});
});
+147 -12
View File
@@ -10,6 +10,8 @@ import type {
AgentPersistenceHandle,
AgentRuntimeInfo,
AgentTimelineItem,
ToolCallDetail,
ToolCallTimelineItem,
AgentUsage,
} from "../server/agent/agent-sdk-types.js";
@@ -147,8 +149,150 @@ export type StructuredToolResult =
| { type: "file_read"; filePath: string; content: string }
| { type: "generic"; data: unknown };
export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem> =
z.discriminatedUnion("type", [
const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUnion("type", [
z.object({
type: z.literal("shell"),
command: z.string(),
cwd: z.string().optional(),
output: z.string().optional(),
exitCode: z.number().nullable().optional(),
}),
z.object({
type: z.literal("read"),
filePath: z.string(),
content: z.string().optional(),
offset: z.number().optional(),
limit: z.number().optional(),
}),
z.object({
type: z.literal("edit"),
filePath: z.string(),
oldString: z.string().optional(),
newString: z.string().optional(),
unifiedDiff: z.string().optional(),
}),
z.object({
type: z.literal("write"),
filePath: z.string(),
content: z.string().optional(),
}),
z.object({
type: z.literal("search"),
query: z.string(),
}),
]);
const NonUndefinedUnknownSchema = z.union([
z.null(),
z.boolean(),
z.number(),
z.string(),
z.array(z.unknown()),
z.object({}).passthrough(),
]);
const NonNullUnknownSchema = z.union([
z.boolean(),
z.number(),
z.string(),
z.array(z.unknown()),
z.object({}).passthrough(),
]);
const ToolCallBasePayloadSchema = z.object({
type: z.literal("tool_call"),
callId: z.string(),
name: z.string(),
input: NonUndefinedUnknownSchema,
output: NonUndefinedUnknownSchema,
detail: ToolCallDetailPayloadSchema.optional(),
metadata: z.record(z.unknown()).optional(),
});
const ToolCallRunningPayloadSchema = ToolCallBasePayloadSchema.extend({
status: z.literal("running"),
error: z.null(),
});
const ToolCallCompletedPayloadSchema = ToolCallBasePayloadSchema.extend({
status: z.literal("completed"),
error: z.null(),
});
const ToolCallFailedPayloadSchema = ToolCallBasePayloadSchema.extend({
status: z.literal("failed"),
error: NonNullUnknownSchema,
});
const ToolCallCanceledPayloadSchema = ToolCallBasePayloadSchema.extend({
status: z.literal("canceled"),
error: z.null(),
});
const LEGACY_TOOL_CALL_STATUS_MAP: Record<string, ToolCallTimelineItem["status"]> = {
inprogress: "running",
in_progress: "running",
started: "running",
complete: "completed",
done: "completed",
success: "completed",
errored: "failed",
error: "failed",
cancelled: "canceled",
};
function normalizeLegacyToolCallPayload(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const record = value as Record<string, unknown>;
if (record.type !== "tool_call") {
return value;
}
const normalized: Record<string, unknown> = { ...record };
const rawStatus = typeof record.status === "string" ? record.status.trim() : "";
if (rawStatus.length > 0) {
const statusKey = rawStatus.toLowerCase().replace(/[\s-]+/g, "_");
const mappedStatus = LEGACY_TOOL_CALL_STATUS_MAP[statusKey];
if (mappedStatus) {
normalized.status = mappedStatus;
}
}
if (!("input" in normalized)) {
normalized.input = null;
}
if (!("output" in normalized)) {
normalized.output = null;
}
if (normalized.status === "failed") {
if (normalized.error === undefined || normalized.error === null) {
normalized.error = { message: "Tool call failed" };
}
} else if (normalized.error === undefined || normalized.error !== null) {
normalized.error = null;
}
return normalized;
}
const ToolCallTimelineItemPayloadSchema: z.ZodType<ToolCallTimelineItem, z.ZodTypeDef, unknown> = z.preprocess(
normalizeLegacyToolCallPayload,
z.union([
ToolCallRunningPayloadSchema,
ToolCallCompletedPayloadSchema,
ToolCallFailedPayloadSchema,
ToolCallCanceledPayloadSchema,
])
);
export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem, z.ZodTypeDef, unknown> =
z.union([
z.object({
type: z.literal("user_message"),
text: z.string(),
@@ -162,16 +306,7 @@ export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem> =
type: z.literal("reasoning"),
text: z.string(),
}),
z.object({
type: z.literal("tool_call"),
name: z.string(),
callId: z.string().optional(),
status: z.string().optional(),
input: z.unknown().optional(),
output: z.unknown().optional(),
error: z.unknown().optional(),
metadata: z.record(z.unknown()).optional(),
}),
ToolCallTimelineItemPayloadSchema,
z.object({
type: z.literal("todo"),
items: z.array(
@@ -1,331 +1,32 @@
import { describe, expect, test } from "vitest";
import { describe, expect, it } from "vitest";
import {
stripShellWrapperPrefix,
normalizeToolDisplayName,
extractTodos,
stripCwdPrefix,
parseToolCallDisplay,
stripShellWrapperPrefix,
} from "./tool-call-parsers.js";
describe("stripShellWrapperPrefix", () => {
test("strips /bin/zsh -lc cd path && prefix", () => {
const command = "/bin/zsh -lc cd /Users/me/dev/blankpage/editor && npm run format";
expect(stripShellWrapperPrefix(command)).toBe("npm run format");
describe("tool-call-parsers utilities", () => {
it("strips cwd prefixes", () => {
expect(stripCwdPrefix("/tmp/repo/src/index.ts", "/tmp/repo")).toBe("src/index.ts");
expect(stripCwdPrefix("/tmp/repo", "/tmp/repo")).toBe(".");
});
test("strips /bin/zsh -lc \"cd path &&\" wrapper", () => {
const command = '/bin/zsh -lc "cd /Users/me/dev/blankpage/editor && npm run format"';
expect(stripShellWrapperPrefix(command)).toBe("npm run format");
it("strips shell wrapper prefixes", () => {
const wrapped = '/bin/zsh -lc "cd /tmp/repo && npm test"';
expect(stripShellWrapperPrefix(wrapped)).toBe("npm test");
});
test("strips /bin/zsh -c cd path && prefix", () => {
const command = "/bin/zsh -c cd /path/to/project && git status";
expect(stripShellWrapperPrefix(command)).toBe("git status");
});
it("extracts todo entries", () => {
expect(
extractTodos({
todos: [
{ content: "Task 1", status: "pending" },
{ content: "Task 2", status: "completed" },
],
})
).toHaveLength(2);
test("strips /bin/bash -lc cd path && prefix", () => {
const command = "/bin/bash -lc cd /home/user/project && npm test";
expect(stripShellWrapperPrefix(command)).toBe("npm test");
});
test("strips /bin/sh -c cd path && prefix", () => {
const command = "/bin/sh -c cd /tmp && ls -la";
expect(stripShellWrapperPrefix(command)).toBe("ls -la");
});
test("returns command unchanged when no prefix", () => {
const command = "npm run build";
expect(stripShellWrapperPrefix(command)).toBe("npm run build");
});
test("strips shell prefix even without cd", () => {
const command = "/bin/zsh -lc npm run build";
expect(stripShellWrapperPrefix(command)).toBe("npm run build");
});
test("strips when cd path includes spaces in quotes", () => {
const command = '/bin/zsh -lc cd "/path with spaces" && npm test';
expect(stripShellWrapperPrefix(command)).toBe("npm test");
});
test("strips /bin/zsh -lc with quoted complex command", () => {
const command =
'/bin/zsh -lc "adb shell pm list packages | rg \'sh\\.paseo(\\.dev)?\' && adb shell cmd package resolve-activity --brief sh.paseo.dev | tail -n 1"';
expect(stripShellWrapperPrefix(command)).toBe(
"adb shell pm list packages | rg 'sh\\.paseo(\\.dev)?' && adb shell cmd package resolve-activity --brief sh.paseo.dev | tail -n 1"
);
});
});
describe("normalizeToolDisplayName", () => {
test("normalizes plain speak name", () => {
expect(normalizeToolDisplayName("speak")).toBe("Speak");
});
test("normalizes codex namespaced speak name", () => {
expect(normalizeToolDisplayName("paseo_voice.speak")).toBe("Speak");
});
test("normalizes claude mcp speak name", () => {
expect(normalizeToolDisplayName("mcp__paseo_voice__speak")).toBe("Speak");
});
test("keeps non-speak names unchanged", () => {
expect(normalizeToolDisplayName("paseo__create_agent")).toBe(
"paseo__create_agent"
);
});
});
describe("stripCwdPrefix", () => {
test("strips cwd prefix from file path", () => {
expect(stripCwdPrefix("/Users/dev/project/src/file.ts", "/Users/dev/project")).toBe("src/file.ts");
});
test("returns . for exact cwd match", () => {
expect(stripCwdPrefix("/Users/dev/project", "/Users/dev/project")).toBe(".");
});
test("returns path unchanged when no cwd prefix", () => {
expect(stripCwdPrefix("/other/path/file.ts", "/Users/dev/project")).toBe("/other/path/file.ts");
});
test("returns path unchanged when no cwd provided", () => {
expect(stripCwdPrefix("/Users/dev/project/file.ts")).toBe("/Users/dev/project/file.ts");
});
});
describe("parseToolCallDisplay", () => {
describe("summary (was extractPrincipalParam)", () => {
test("extracts and strips shell wrapper from command string", () => {
const result = parseToolCallDisplay({
provider: "claude",
name: "Bash",
input: { command: "/bin/zsh -lc cd /Users/dev/project && npm run format" },
});
expect(result.summary).toBe("npm run format");
});
test("extracts and strips shell wrapper from command array", () => {
const result = parseToolCallDisplay({
name: "shell",
input: { command: ["/bin/bash", "-lc", "cd /path && git status"] },
});
expect(result.summary).toBe("git status");
});
test("extracts plain command without shell wrapper", () => {
const result = parseToolCallDisplay({
name: "Bash",
input: { command: "npm run build" },
});
expect(result.summary).toBe("npm run build");
});
test("falls back to output command when shell input is missing", () => {
const result = parseToolCallDisplay({
name: "Bash",
output: { type: "command", command: "pwd", output: "/tmp" },
});
expect(result.summary).toBe("pwd");
});
test("extracts file_path and strips cwd", () => {
const result = parseToolCallDisplay({
name: "Read",
input: { file_path: "/Users/dev/project/src/file.ts" },
cwd: "/Users/dev/project",
});
expect(result.summary).toBe("src/file.ts");
});
test("falls back to read output path when input is missing", () => {
const result = parseToolCallDisplay({
provider: "codex",
name: "Read",
output: {
type: "file_read",
filePath: "/Users/dev/project/src/file.ts",
content: "hello",
},
cwd: "/Users/dev/project",
});
expect(result.summary).toBe("src/file.ts");
});
test("falls back to edit output path when input is missing", () => {
const result = parseToolCallDisplay({
name: "Edit",
output: {
type: "file_edit",
filePath: "/Users/dev/project/src/file.ts",
oldContent: "a",
newContent: "b",
},
cwd: "/Users/dev/project",
});
expect(result.summary).toBe("src/file.ts");
});
test("extracts pattern without modification", () => {
const result = parseToolCallDisplay({
name: "Grep",
input: { pattern: "*.ts" },
});
expect(result.summary).toBe("*.ts");
});
test("extracts query without modification", () => {
const result = parseToolCallDisplay({
name: "WebSearch",
input: { query: "search term" },
});
expect(result.summary).toBe("search term");
});
test("extracts url without modification", () => {
const result = parseToolCallDisplay({
name: "WebFetch",
input: { url: "https://example.com" },
});
expect(result.summary).toBe("https://example.com");
});
test("returns undefined summary for empty input", () => {
const result = parseToolCallDisplay({ name: "Unknown", input: {} });
expect(result.summary).toBeUndefined();
});
test("extracts description from Task tool", () => {
const result = parseToolCallDisplay({
name: "Task",
input: { description: "Explore the codebase" },
});
expect(result.summary).toBe("Explore the codebase");
});
});
describe("summary from metadata", () => {
test("subAgentActivity in metadata takes priority for Task", () => {
const result = parseToolCallDisplay({
name: "Task",
input: { description: "Explore codebase" },
metadata: { subAgentActivity: "Read" },
});
expect(result.summary).toBe("Read");
});
test("metadata merges with input for summary parsing", () => {
const result = parseToolCallDisplay({
name: "Task",
input: {},
metadata: { subAgentActivity: "Bash" },
});
expect(result.summary).toBe("Bash");
});
test("non-Task tools keep their parsed summary even when metadata is present", () => {
const result = parseToolCallDisplay({
name: "Bash",
input: { command: "pwd" },
metadata: { subAgentActivity: "Read" },
});
expect(result.summary).toBe("pwd");
});
});
describe("kind", () => {
test("Read -> read", () => {
expect(parseToolCallDisplay({ name: "Read" }).kind).toBe("read");
});
test("read_file -> read", () => {
expect(parseToolCallDisplay({ name: "read_file" }).kind).toBe("read");
});
test("Edit -> edit", () => {
expect(parseToolCallDisplay({ name: "Edit" }).kind).toBe("edit");
});
test("Write -> edit", () => {
expect(parseToolCallDisplay({ name: "Write" }).kind).toBe("edit");
});
test("apply_patch -> edit", () => {
expect(parseToolCallDisplay({ name: "apply_patch" }).kind).toBe("edit");
});
test("Bash -> execute", () => {
expect(parseToolCallDisplay({ name: "Bash" }).kind).toBe("execute");
});
test("shell -> execute", () => {
expect(parseToolCallDisplay({ name: "shell" }).kind).toBe("execute");
});
test("Grep -> search", () => {
expect(parseToolCallDisplay({ name: "Grep" }).kind).toBe("search");
});
test("Glob -> search", () => {
expect(parseToolCallDisplay({ name: "Glob" }).kind).toBe("search");
});
test("thinking -> thinking", () => {
expect(parseToolCallDisplay({ name: "thinking" }).kind).toBe("thinking");
});
test("unknown tool -> tool", () => {
expect(parseToolCallDisplay({ name: "MyCustomTool" }).kind).toBe("tool");
});
});
describe("displayName", () => {
test("shell -> Shell", () => {
expect(parseToolCallDisplay({ name: "shell" }).displayName).toBe("Shell");
});
test("Bash -> Shell", () => {
expect(parseToolCallDisplay({ name: "Bash" }).displayName).toBe("Shell");
});
test("read_file -> Read", () => {
expect(parseToolCallDisplay({ name: "read_file" }).displayName).toBe("Read");
});
test("apply_patch -> Edit", () => {
expect(parseToolCallDisplay({ name: "apply_patch" }).displayName).toBe("Edit");
});
test("preserves unknown tool names", () => {
expect(parseToolCallDisplay({ name: "MyCustomTool" }).displayName).toBe("MyCustomTool");
});
test("normalizes speak variants", () => {
expect(parseToolCallDisplay({ name: "paseo_voice.speak" }).displayName).toBe("Speak");
expect(parseToolCallDisplay({ name: "mcp__paseo_voice__speak" }).displayName).toBe("Speak");
});
});
describe("errorText", () => {
test("formats string error", () => {
const result = parseToolCallDisplay({
name: "Bash",
error: "something broke",
});
expect(result.errorText).toBe("something broke");
});
test("extracts content from tool_result error", () => {
const result = parseToolCallDisplay({
name: "Bash",
error: { type: "tool_result", content: "Exit code 1", is_error: true },
});
expect(result.errorText).toBe("Exit code 1");
});
test("returns undefined for no error", () => {
const result = parseToolCallDisplay({ name: "Read" });
expect(result.errorText).toBeUndefined();
});
expect(extractTodos({ plan: [] })).toEqual([]);
});
});
File diff suppressed because it is too large Load Diff