refactor: simplify tool call display and path handling
- Remove displayName from tool calls, use name directly with icon - Add medium font weight for tool call badges - Strip workspace root from file paths at parsing level using Zod schema - Add accentBorder to tool call badges and git diff file blocks - Fix user message alignment and vertical spacing - Deduplicate stripCwdPrefix into tool-call-parsers.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
4f3b2317ab
commit
66881511b6
@@ -1,7 +1,19 @@
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import type { AgentTimelineItem, ToolCallKind } from "./agent-sdk-types.js";
|
||||
|
||||
const DEFAULT_MAX_ITEMS = 40;
|
||||
|
||||
/**
|
||||
* Derive tool kind from the tool name for rendering purposes.
|
||||
*/
|
||||
function getToolKind(name: string): ToolCallKind {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower === "read" || lower === "read_file") return "read";
|
||||
if (lower === "edit" || lower === "write" || lower === "apply_patch") return "edit";
|
||||
if (lower === "bash" || lower === "shell") return "execute";
|
||||
if (lower === "grep" || lower === "glob" || lower === "web_search") return "search";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function appendText(buffer: string, text: string): string {
|
||||
const normalized = text.trim();
|
||||
if (!normalized) {
|
||||
@@ -50,6 +62,13 @@ function extractWebQuery(value: unknown): string {
|
||||
return value.query;
|
||||
}
|
||||
|
||||
function extractCommand(value: unknown): string {
|
||||
if (!isObject(value) || typeof value.command !== "string") {
|
||||
return "";
|
||||
}
|
||||
return value.command;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert normalized agent timeline items into a concise text summary.
|
||||
*/
|
||||
@@ -84,13 +103,12 @@ export function curateAgentActivity(
|
||||
break;
|
||||
case "tool_call": {
|
||||
flushBuffers(lines, buffers);
|
||||
const label =
|
||||
item.displayName ??
|
||||
(item.server && item.tool ? `${item.server}.${item.tool}` : item.tool ?? item.server ?? "Tool");
|
||||
const status = item.status ? ` ${item.status}` : "";
|
||||
if (item.kind === "execute" || item.server === "command") {
|
||||
lines.push(`[Command: ${label}]${status}`);
|
||||
} else if (item.kind === "edit" || item.server === "file_change") {
|
||||
const kind = getToolKind(item.name);
|
||||
if (kind === "execute") {
|
||||
const command = extractCommand(item.input);
|
||||
lines.push(`[Command: ${command || item.name}]${status}`);
|
||||
} else if (kind === "edit") {
|
||||
const files = extractFileChanges(item.output);
|
||||
if (files.length > 0) {
|
||||
lines.push("[File Changes]");
|
||||
@@ -98,13 +116,13 @@ export function curateAgentActivity(
|
||||
lines.push(`- (${file.kind}) ${file.path}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(`[Edit] ${label}${status}`);
|
||||
lines.push(`[Edit] ${item.name}${status}`);
|
||||
}
|
||||
} else if (item.kind === "search" || item.server === "web_search") {
|
||||
} else if (kind === "search") {
|
||||
const query = extractWebQuery(item.input);
|
||||
lines.push(`[Web Search] ${query || label}`);
|
||||
lines.push(`[Web Search] ${query || item.name}`);
|
||||
} else {
|
||||
lines.push(`[Tool ${item.server}.${item.tool}]${status}`);
|
||||
lines.push(`[Tool ${item.name}]${status}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -55,22 +55,34 @@ 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";
|
||||
|
||||
/**
|
||||
* 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: "tool_call";
|
||||
name: string;
|
||||
callId?: string;
|
||||
status?: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export type AgentTimelineItem =
|
||||
| { type: "user_message"; text: string; messageId?: string }
|
||||
| { type: "assistant_message"; text: string }
|
||||
| { type: "reasoning"; text: string }
|
||||
| {
|
||||
type: "tool_call";
|
||||
server: string;
|
||||
tool: string;
|
||||
status?: string;
|
||||
callId?: string;
|
||||
displayName?: string;
|
||||
kind?: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
| ToolCallTimelineItem
|
||||
| { type: "todo"; items: { text: string; completed: boolean }[] }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
|
||||
@@ -88,21 +88,16 @@ function extractCommandText(input: unknown): string | null {
|
||||
}
|
||||
|
||||
function isSleepCommandToolCall(item: ToolCallItem): boolean {
|
||||
const display = typeof item.displayName === "string" ? item.displayName.toLowerCase() : "";
|
||||
if (display.includes("sleep 60")) {
|
||||
return true;
|
||||
}
|
||||
const inputCommand = extractCommandText(item.input)?.toLowerCase() ?? "";
|
||||
return inputCommand.includes("sleep 60");
|
||||
}
|
||||
|
||||
function isPermissionCommandToolCall(item: ToolCallItem): boolean {
|
||||
if (item.server === "permission") {
|
||||
if (item.name === "permission_request") {
|
||||
return false;
|
||||
}
|
||||
const display = typeof item.displayName === "string" ? item.displayName.toLowerCase() : "";
|
||||
const inputCommand = extractCommandText(item.input)?.toLowerCase() ?? "";
|
||||
return display.includes("permission.txt") || inputCommand.includes("permission.txt");
|
||||
return inputCommand.includes("permission.txt");
|
||||
}
|
||||
|
||||
type AgentMcpServerHandle = {
|
||||
@@ -354,7 +349,7 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
||||
const config = buildConfig(cwd, { maxThinkingTokens: 2048 });
|
||||
const session = await client.createSession(config);
|
||||
|
||||
let pendingDisplay: string | null = null;
|
||||
let pendingCommand: string | null = null;
|
||||
const events = session.stream("Run the exact command `pwd` via Bash and stop.");
|
||||
|
||||
try {
|
||||
@@ -363,10 +358,10 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
||||
if (
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.server.toLowerCase().includes("bash") &&
|
||||
event.item.name.toLowerCase().includes("bash") &&
|
||||
event.item.status === "pending"
|
||||
) {
|
||||
pendingDisplay = event.item.displayName ?? null;
|
||||
pendingCommand = extractCommandText(event.item.input);
|
||||
}
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||
break;
|
||||
@@ -377,8 +372,8 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(pendingDisplay).toBeTruthy();
|
||||
expect(pendingDisplay?.toLowerCase()).toContain("pwd");
|
||||
expect(pendingCommand).toBeTruthy();
|
||||
expect(pendingCommand?.toLowerCase()).toContain("pwd");
|
||||
},
|
||||
150_000
|
||||
);
|
||||
@@ -418,9 +413,8 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
||||
);
|
||||
const commandEvents = toolCalls.filter(
|
||||
(item) =>
|
||||
(item.kind === "execute" || item.server === "command") &&
|
||||
typeof item.displayName === "string" &&
|
||||
!item.displayName.startsWith("permission:")
|
||||
item.name.toLowerCase().includes("bash") &&
|
||||
item.name !== "permission_request"
|
||||
);
|
||||
const fileChangeEvent = toolCalls.find((item) => {
|
||||
// Check for file changes in structured output.files array
|
||||
@@ -444,7 +438,7 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
||||
});
|
||||
|
||||
const sawPwdCommand = commandEvents.some(
|
||||
(item) => (item.displayName ?? "").toLowerCase().includes("pwd") && item.status === "completed"
|
||||
(item) => (extractCommandText(item.input) ?? "").toLowerCase().includes("pwd") && item.status === "completed"
|
||||
);
|
||||
|
||||
expect(completed).toBe(true);
|
||||
@@ -906,7 +900,8 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
||||
const liveState = hydrateStreamState(liveTimelineUpdates);
|
||||
const liveSnapshots = extractAgentToolSnapshots(liveState);
|
||||
const commandTool = liveSnapshots.find((snapshot) =>
|
||||
(snapshot.data.displayName ?? "").toLowerCase().includes("pwd")
|
||||
snapshot.data.name.toLowerCase().includes("bash") &&
|
||||
(extractCommandText(snapshot.data.input) ?? "").toLowerCase().includes("pwd")
|
||||
);
|
||||
const editTool = liveSnapshots.find((snapshot) =>
|
||||
rawContainsText(snapshot.data.result, "hydrate-proof.txt")
|
||||
@@ -953,8 +948,8 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
||||
({ live, hydrated }) => {
|
||||
expect(rawContainsText(live.result, cwd)).toBe(true);
|
||||
expect(rawContainsText(hydrated.result, cwd)).toBe(true);
|
||||
expect((live.displayName ?? "").toLowerCase()).toContain("pwd");
|
||||
expect((hydrated.displayName ?? "").toLowerCase()).toContain("pwd");
|
||||
expect((extractCommandText(live.input) ?? "").toLowerCase()).toContain("pwd");
|
||||
expect((extractCommandText(hydrated.input) ?? "").toLowerCase()).toContain("pwd");
|
||||
}
|
||||
);
|
||||
assertHydratedReplica(
|
||||
@@ -1165,8 +1160,7 @@ function buildToolSnapshotKey(data: AgentToolCallData, fallbackId: string): stri
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
const display = typeof data.displayName === "string" && data.displayName.trim().length > 0 ? data.displayName.trim() : fallbackId;
|
||||
return `${data.server}:${data.tool}:${display}`;
|
||||
return `${data.provider}:${data.name}:${fallbackId}`;
|
||||
}
|
||||
|
||||
function assertHydratedReplica(
|
||||
@@ -1179,9 +1173,7 @@ function assertHydratedReplica(
|
||||
const hydrated = hydratedMap.get(liveSnapshot.key);
|
||||
expect(hydrated).toBeTruthy();
|
||||
expect(hydrated?.status).toBe(liveSnapshot.data.status);
|
||||
expect(hydrated?.server).toBe(liveSnapshot.data.server);
|
||||
expect(hydrated?.tool).toBe(liveSnapshot.data.tool);
|
||||
expect(hydrated?.displayName).toBe(liveSnapshot.data.displayName);
|
||||
expect(hydrated?.name).toBe(liveSnapshot.data.name);
|
||||
expect(predicate(hydrated!)).toBe(true);
|
||||
if (hydrated && extraAssertions) {
|
||||
extraAssertions({ live: liveSnapshot.data, hydrated });
|
||||
|
||||
@@ -619,12 +619,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (pending.request.kind === "plan") {
|
||||
await this.setMode("acceptEdits");
|
||||
this.pushToolCall({
|
||||
server: "plan",
|
||||
tool: "plan_approval",
|
||||
name: "plan_approval",
|
||||
status: "granted",
|
||||
callId: pending.request.id,
|
||||
displayName: "Plan approved",
|
||||
kind: "plan",
|
||||
});
|
||||
}
|
||||
const result: PermissionResult = {
|
||||
@@ -634,12 +631,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
};
|
||||
pending.resolve(result);
|
||||
this.pushToolCall({
|
||||
server: "permission",
|
||||
tool: pending.request.name,
|
||||
name: pending.request.name,
|
||||
status: "granted",
|
||||
callId: pending.request.id,
|
||||
displayName: pending.request.title ?? pending.request.name,
|
||||
kind: "permission",
|
||||
input: pending.request.input,
|
||||
});
|
||||
} else {
|
||||
@@ -650,12 +644,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
};
|
||||
pending.resolve(result);
|
||||
this.pushToolCall({
|
||||
server: "permission",
|
||||
tool: pending.request.name,
|
||||
name: pending.request.name,
|
||||
status: "denied",
|
||||
callId: pending.request.id,
|
||||
displayName: pending.request.title ?? pending.request.name,
|
||||
kind: "permission",
|
||||
input: pending.request.input,
|
||||
});
|
||||
}
|
||||
@@ -1039,12 +1030,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
};
|
||||
|
||||
this.pushToolCall({
|
||||
server: "permission",
|
||||
tool: toolName,
|
||||
name: toolName,
|
||||
status: "requested",
|
||||
callId: requestId,
|
||||
displayName: request.title ?? toolName,
|
||||
kind: "permission",
|
||||
input,
|
||||
});
|
||||
|
||||
@@ -1067,12 +1055,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
cleanup();
|
||||
const error = new Error("Permission request timed out");
|
||||
this.pushToolCall({
|
||||
server: "permission",
|
||||
tool: toolName,
|
||||
name: toolName,
|
||||
status: "denied",
|
||||
callId: requestId,
|
||||
displayName: request.title ?? toolName,
|
||||
kind: "permission",
|
||||
input,
|
||||
});
|
||||
this.pushEvent({
|
||||
@@ -1138,25 +1123,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.enqueueTimeline(item);
|
||||
}
|
||||
|
||||
private getToolKind(classification?: ToolUseClassification): string | undefined {
|
||||
switch (classification) {
|
||||
case "command":
|
||||
return "execute";
|
||||
case "file_change":
|
||||
return "edit";
|
||||
case "generic":
|
||||
default:
|
||||
return "tool";
|
||||
}
|
||||
}
|
||||
|
||||
private buildToolDisplayName(entry?: ToolUseCacheEntry): string | undefined {
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
return entry.commandText ?? entry.name;
|
||||
}
|
||||
|
||||
private pushEvent(event: AgentStreamEvent) {
|
||||
if (this.eventQueue) {
|
||||
this.eventQueue.push(event);
|
||||
@@ -1315,12 +1281,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.toolUseCache.set(entry.id, entry);
|
||||
this.pushToolCall(
|
||||
{
|
||||
server: entry.server,
|
||||
tool: entry.name,
|
||||
name: entry.name,
|
||||
status: "pending",
|
||||
callId: entry.id,
|
||||
displayName: this.buildToolDisplayName(entry),
|
||||
kind: this.getToolKind(entry.classification),
|
||||
input: entry.input ?? this.normalizeToolInput(block.input),
|
||||
},
|
||||
items
|
||||
@@ -1329,8 +1292,7 @@ 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 server = entry?.server ?? block.server ?? "tool";
|
||||
const tool = entry?.name ?? block.tool_name ?? "tool";
|
||||
const toolName = entry?.name ?? block.tool_name ?? "tool";
|
||||
const status = block.is_error ? "failed" : "completed";
|
||||
|
||||
// Extract output from block.content (SDK always returns content in string form)
|
||||
@@ -1338,12 +1300,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
this.pushToolCall(
|
||||
{
|
||||
server,
|
||||
tool,
|
||||
name: toolName,
|
||||
status,
|
||||
callId: typeof block.tool_use_id === "string" ? block.tool_use_id : undefined,
|
||||
displayName: this.buildToolDisplayName(entry),
|
||||
kind: this.getToolKind(entry?.classification),
|
||||
input: entry?.input,
|
||||
output,
|
||||
error: block.is_error ? block : undefined,
|
||||
@@ -1577,12 +1536,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.applyToolInput(entry, normalized);
|
||||
this.toolUseCache.set(toolId, entry);
|
||||
this.pushToolCall({
|
||||
server: entry.server,
|
||||
tool: entry.name,
|
||||
name: entry.name,
|
||||
status: "pending",
|
||||
callId: toolId,
|
||||
displayName: this.buildToolDisplayName(entry),
|
||||
kind: this.getToolKind(entry.classification),
|
||||
input: normalized,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -250,10 +250,6 @@ function stringifyUnknown(value: unknown): string {
|
||||
}
|
||||
|
||||
function isSleepCommandToolCall(item: ToolCallItem): boolean {
|
||||
const display = typeof item.displayName === "string" ? item.displayName.toLowerCase() : "";
|
||||
if (display.includes("sleep 60")) {
|
||||
return true;
|
||||
}
|
||||
const inputText = commandTextFromInput(item.input);
|
||||
if (!inputText) {
|
||||
return false;
|
||||
|
||||
@@ -1954,13 +1954,6 @@ function shouldReportCommandError(input: {
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildFileChangeSummary(files: { path: string; kind: string }[]): string {
|
||||
if (files.length === 1) {
|
||||
return `${files[0].kind}: ${files[0].path}`;
|
||||
}
|
||||
return `${files.length} file changes`;
|
||||
}
|
||||
|
||||
function extractContentText(content: unknown): string | null {
|
||||
if (!Array.isArray(content)) {
|
||||
return null;
|
||||
@@ -3105,12 +3098,9 @@ class CodexMcpAgentSession implements AgentSession {
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
item: createToolCallTimelineItem({
|
||||
server: "permission",
|
||||
tool: pending.request.name,
|
||||
name: pending.request.name,
|
||||
status,
|
||||
callId: pending.request.id,
|
||||
displayName: pending.request.title ? pending.request.title : pending.request.name,
|
||||
kind: "permission",
|
||||
input: pending.request.input,
|
||||
}),
|
||||
});
|
||||
@@ -3415,12 +3405,9 @@ class CodexMcpAgentSession implements AgentSession {
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
item: createToolCallTimelineItem({
|
||||
server: "permission",
|
||||
tool: request.name,
|
||||
name: request.name,
|
||||
status: "requested",
|
||||
callId: request.id,
|
||||
displayName: request.title ? request.title : request.name,
|
||||
kind: "permission",
|
||||
input: request.input,
|
||||
}),
|
||||
});
|
||||
@@ -3535,12 +3522,9 @@ class CodexMcpAgentSession implements AgentSession {
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
item: createToolCallTimelineItem({
|
||||
server: "file_change",
|
||||
tool: "apply_patch",
|
||||
name: "apply_patch",
|
||||
status: success ? "completed" : "failed",
|
||||
callId,
|
||||
displayName: buildFileChangeSummary(summaryFiles),
|
||||
kind: "edit",
|
||||
input: { files: summaryFiles },
|
||||
output: { files: pendingChanges, message: parsedOutputText, success },
|
||||
}),
|
||||
@@ -3607,19 +3591,15 @@ class CodexMcpAgentSession implements AgentSession {
|
||||
if (!callId) {
|
||||
throw new Error("exec_command_begin missing call_id");
|
||||
}
|
||||
const commandText = normalizeCommand(parsedEvent.command);
|
||||
const fileRead = extractFileReadFromParsedCmd(parsedEvent.parsedCmd);
|
||||
if (fileRead) {
|
||||
this.emitEvent({
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
item: createToolCallTimelineItem({
|
||||
server: "file",
|
||||
tool: "read_file",
|
||||
name: "read_file",
|
||||
status: "running",
|
||||
callId,
|
||||
displayName: `Read: ${fileRead.name}`,
|
||||
kind: "read",
|
||||
input: { path: fileRead.path },
|
||||
}),
|
||||
});
|
||||
@@ -3628,12 +3608,9 @@ class CodexMcpAgentSession implements AgentSession {
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
item: createToolCallTimelineItem({
|
||||
server: "command",
|
||||
tool: "shell",
|
||||
name: "shell",
|
||||
status: "running",
|
||||
callId,
|
||||
displayName: commandText,
|
||||
kind: "execute",
|
||||
input: { command: parsedEvent.command, cwd: parsedEvent.cwd },
|
||||
}),
|
||||
});
|
||||
@@ -3709,12 +3686,9 @@ class CodexMcpAgentSession implements AgentSession {
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
item: createToolCallTimelineItem({
|
||||
server: "file",
|
||||
tool: "read_file",
|
||||
name: "read_file",
|
||||
status: failed ? "failed" : "completed",
|
||||
callId,
|
||||
displayName: `Read: ${fileRead.name}`,
|
||||
kind: "read",
|
||||
input: { path: fileRead.path },
|
||||
output: {
|
||||
type: "read_file",
|
||||
@@ -3749,12 +3723,9 @@ class CodexMcpAgentSession implements AgentSession {
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
item: createToolCallTimelineItem({
|
||||
server: "command",
|
||||
tool: "shell",
|
||||
name: "shell",
|
||||
status: failed ? "failed" : "completed",
|
||||
callId,
|
||||
displayName: commandText,
|
||||
kind: "execute",
|
||||
input: { command: parsedEvent.command, cwd: parsedEvent.cwd },
|
||||
output: structuredOutput,
|
||||
}),
|
||||
@@ -3791,12 +3762,9 @@ class CodexMcpAgentSession implements AgentSession {
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
item: createToolCallTimelineItem({
|
||||
server: "file_change",
|
||||
tool: "apply_patch",
|
||||
name: "apply_patch",
|
||||
status: "running",
|
||||
callId,
|
||||
displayName: buildFileChangeSummary(files),
|
||||
kind: "edit",
|
||||
input: { changes: parsedEvent.changes, files },
|
||||
}),
|
||||
});
|
||||
@@ -3852,12 +3820,9 @@ class CodexMcpAgentSession implements AgentSession {
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
item: createToolCallTimelineItem({
|
||||
server: "file_change",
|
||||
tool: "apply_patch",
|
||||
name: "apply_patch",
|
||||
status: parsedEvent.success ? "completed" : "failed",
|
||||
callId,
|
||||
displayName: buildFileChangeSummary(summaryFiles),
|
||||
kind: "edit",
|
||||
input: { files: summaryFiles },
|
||||
output,
|
||||
}),
|
||||
@@ -3881,12 +3846,9 @@ class CodexMcpAgentSession implements AgentSession {
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
item: createToolCallTimelineItem({
|
||||
server: parsedEvent.server,
|
||||
tool: parsedEvent.tool,
|
||||
name: `${parsedEvent.server}.${parsedEvent.tool}`,
|
||||
status: success ? "completed" : "failed",
|
||||
callId: parsedEvent.callId,
|
||||
displayName: `${parsedEvent.server}.${parsedEvent.tool}`,
|
||||
kind: "tool",
|
||||
input,
|
||||
output: normalizedOutput,
|
||||
}),
|
||||
@@ -4059,12 +4021,9 @@ class CodexMcpAgentSession implements AgentSession {
|
||||
commandOutput.exitCode = resolvedExitCode;
|
||||
}
|
||||
return createToolCallTimelineItem({
|
||||
server: "command",
|
||||
tool: "shell",
|
||||
name: "shell",
|
||||
status: item.status,
|
||||
callId: item.callId,
|
||||
displayName: command,
|
||||
kind: "execute",
|
||||
input: { command: item.command, cwd: item.cwd },
|
||||
output: commandOutput,
|
||||
error: item.error,
|
||||
@@ -4089,12 +4048,9 @@ class CodexMcpAgentSession implements AgentSession {
|
||||
? "running"
|
||||
: "completed";
|
||||
return createToolCallTimelineItem({
|
||||
server: "file_change",
|
||||
tool: "apply_patch",
|
||||
name: "apply_patch",
|
||||
status,
|
||||
callId: item.callId,
|
||||
displayName: buildFileChangeSummary(summaryFiles),
|
||||
kind: "edit",
|
||||
input: { files: summaryFiles },
|
||||
output: { files: changes },
|
||||
});
|
||||
@@ -4104,16 +4060,12 @@ class CodexMcpAgentSession implements AgentSession {
|
||||
return null;
|
||||
}
|
||||
const readItem = item as ReadFileThreadItem;
|
||||
const displayName = `Read ${readItem.path}`;
|
||||
const output =
|
||||
readItem.content !== undefined ? readItem.content : readItem.output;
|
||||
return createToolCallTimelineItem({
|
||||
server: "file_read",
|
||||
tool: "read_file",
|
||||
name: "read_file",
|
||||
status: readItem.status,
|
||||
callId: readItem.callId,
|
||||
displayName,
|
||||
kind: "read",
|
||||
input: readItem.input ? readItem.input : { path: readItem.path },
|
||||
output,
|
||||
});
|
||||
@@ -4123,12 +4075,9 @@ class CodexMcpAgentSession implements AgentSession {
|
||||
return null;
|
||||
}
|
||||
return createToolCallTimelineItem({
|
||||
server: item.server,
|
||||
tool: item.tool,
|
||||
name: `${item.server}.${item.tool}`,
|
||||
status: item.status,
|
||||
callId: item.callId,
|
||||
displayName: `${item.server}.${item.tool}`,
|
||||
kind: "tool",
|
||||
input: item.input,
|
||||
output: item.output,
|
||||
});
|
||||
@@ -4137,16 +4086,12 @@ class CodexMcpAgentSession implements AgentSession {
|
||||
if (eventType && eventType !== "item.completed") {
|
||||
return null;
|
||||
}
|
||||
const displayName = `Web search: ${item.query}`;
|
||||
const output =
|
||||
item.results !== undefined ? item.results : item.output;
|
||||
return createToolCallTimelineItem({
|
||||
server: "web_search",
|
||||
tool: "web_search",
|
||||
name: "web_search",
|
||||
status: item.status,
|
||||
callId: item.callId,
|
||||
displayName,
|
||||
kind: "search",
|
||||
input: item.input ? item.input : { query: item.query },
|
||||
output,
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useTempClaudeConfigDir,
|
||||
} from "./test-utils/index.js";
|
||||
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
|
||||
import type { AgentSnapshotPayload } from "./messages.js";
|
||||
import type { AgentSnapshotPayload, SessionOutboundMessage } from "./messages.js";
|
||||
|
||||
function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||
@@ -3117,82 +3117,81 @@ describe("daemon E2E", () => {
|
||||
});
|
||||
|
||||
describe("tool call structure", () => {
|
||||
// Helper to extract and dedupe tool calls by callId, keeping the last (most complete) version
|
||||
function extractToolCalls(
|
||||
queue: SessionOutboundMessage[],
|
||||
agentId: string
|
||||
): AgentTimelineItem[] {
|
||||
const byCallId = new Map<string, AgentTimelineItem>();
|
||||
const noCallId: AgentTimelineItem[] = [];
|
||||
|
||||
for (const m of queue) {
|
||||
if (
|
||||
m.type === "agent_stream" &&
|
||||
m.payload.agentId === agentId &&
|
||||
m.payload.event.type === "timeline" &&
|
||||
m.payload.event.item.type === "tool_call"
|
||||
) {
|
||||
const tc = m.payload.event.item;
|
||||
if (tc.callId) {
|
||||
byCallId.set(tc.callId, tc);
|
||||
} else {
|
||||
noCallId.push(tc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...byCallId.values(), ...noCallId];
|
||||
}
|
||||
|
||||
// Helper to log tool call structure in a consistent format
|
||||
function logToolCall(prefix: string, tc: AgentTimelineItem): void {
|
||||
if (tc.type !== "tool_call") return;
|
||||
console.log(
|
||||
`[${prefix}]`,
|
||||
JSON.stringify({
|
||||
name: tc.name,
|
||||
callId: tc.callId,
|
||||
status: tc.status,
|
||||
hasInput: tc.input !== undefined,
|
||||
hasOutput: tc.output !== undefined,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
"Claude agent tool calls have expected structure",
|
||||
"Claude agent: Read tool",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
// Create Claude agent with bypass permissions
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "claude",
|
||||
cwd,
|
||||
title: "Tool Structure Test - Claude",
|
||||
title: "Claude Read Test",
|
||||
modeId: "bypassPermissions",
|
||||
});
|
||||
|
||||
expect(agent.provider).toBe("claude");
|
||||
|
||||
ctx.client.clearMessageQueue();
|
||||
|
||||
// Prompt that triggers a Read tool call
|
||||
await ctx.client.sendMessage(
|
||||
agent.id,
|
||||
"Read the file /etc/hosts and tell me how many lines it has. Be brief."
|
||||
);
|
||||
|
||||
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||
expect(finalState.status).toBe("idle");
|
||||
|
||||
// Extract tool_call timeline items
|
||||
const queue = ctx.client.getMessageQueue();
|
||||
const toolCalls: AgentTimelineItem[] = [];
|
||||
for (const m of queue) {
|
||||
if (
|
||||
m.type === "agent_stream" &&
|
||||
m.payload.agentId === agent.id &&
|
||||
m.payload.event.type === "timeline" &&
|
||||
m.payload.event.item.type === "tool_call"
|
||||
) {
|
||||
toolCalls.push(m.payload.event.item);
|
||||
}
|
||||
}
|
||||
await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||
|
||||
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
|
||||
expect(toolCalls.length).toBeGreaterThan(0);
|
||||
|
||||
// Log and verify structure for each tool call
|
||||
for (const tc of toolCalls) {
|
||||
expect(tc.type).toBe("tool_call");
|
||||
|
||||
// Current structure has: server, tool, displayName, kind
|
||||
expect(typeof tc.server).toBe("string");
|
||||
expect(typeof tc.tool).toBe("string");
|
||||
|
||||
console.log(
|
||||
"[CLAUDE TOOL_CALL]",
|
||||
JSON.stringify({
|
||||
server: tc.server,
|
||||
tool: tc.tool,
|
||||
displayName: tc.displayName,
|
||||
kind: tc.kind,
|
||||
callId: tc.callId,
|
||||
status: tc.status,
|
||||
hasInput: tc.input !== undefined,
|
||||
hasOutput: tc.output !== undefined,
|
||||
})
|
||||
);
|
||||
logToolCall("CLAUDE_READ", tc);
|
||||
}
|
||||
|
||||
// Find a Read tool call specifically
|
||||
const readCall = toolCalls.find(
|
||||
(tc) =>
|
||||
tc.server === "Read" ||
|
||||
tc.tool === "Read" ||
|
||||
tc.displayName === "Read"
|
||||
);
|
||||
const readCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "Read");
|
||||
expect(readCall).toBeDefined();
|
||||
expect(readCall?.name).toBe("Read");
|
||||
expect(readCall?.input).toBeDefined();
|
||||
|
||||
// Cleanup
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
@@ -3200,80 +3199,209 @@ describe("daemon E2E", () => {
|
||||
);
|
||||
|
||||
test(
|
||||
"Codex agent tool calls have expected structure",
|
||||
"Claude agent: Bash tool",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
// Create Codex agent with full access
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
provider: "claude",
|
||||
cwd,
|
||||
title: "Tool Structure Test - Codex",
|
||||
modeId: "full-access",
|
||||
title: "Claude Bash Test",
|
||||
modeId: "bypassPermissions",
|
||||
});
|
||||
|
||||
expect(agent.provider).toBe("codex");
|
||||
|
||||
ctx.client.clearMessageQueue();
|
||||
|
||||
// Prompt that triggers a shell command
|
||||
await ctx.client.sendMessage(
|
||||
agent.id,
|
||||
"Run `echo hello` and tell me what it outputs. Be brief."
|
||||
);
|
||||
|
||||
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||
expect(finalState.status).toBe("idle");
|
||||
|
||||
// Extract tool_call timeline items
|
||||
const queue = ctx.client.getMessageQueue();
|
||||
const toolCalls: AgentTimelineItem[] = [];
|
||||
for (const m of queue) {
|
||||
if (
|
||||
m.type === "agent_stream" &&
|
||||
m.payload.agentId === agent.id &&
|
||||
m.payload.event.type === "timeline" &&
|
||||
m.payload.event.item.type === "tool_call"
|
||||
) {
|
||||
toolCalls.push(m.payload.event.item);
|
||||
}
|
||||
}
|
||||
await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||
|
||||
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
|
||||
expect(toolCalls.length).toBeGreaterThan(0);
|
||||
|
||||
// Log and verify structure for each tool call
|
||||
for (const tc of toolCalls) {
|
||||
expect(tc.type).toBe("tool_call");
|
||||
|
||||
// Current structure has: server, tool, displayName, kind
|
||||
expect(typeof tc.server).toBe("string");
|
||||
expect(typeof tc.tool).toBe("string");
|
||||
|
||||
console.log(
|
||||
"[CODEX TOOL_CALL]",
|
||||
JSON.stringify({
|
||||
server: tc.server,
|
||||
tool: tc.tool,
|
||||
displayName: tc.displayName,
|
||||
kind: tc.kind,
|
||||
callId: tc.callId,
|
||||
status: tc.status,
|
||||
hasInput: tc.input !== undefined,
|
||||
hasOutput: tc.output !== undefined,
|
||||
})
|
||||
);
|
||||
logToolCall("CLAUDE_BASH", tc);
|
||||
}
|
||||
|
||||
// Find a shell/execute tool call
|
||||
const shellCall = toolCalls.find(
|
||||
(tc) =>
|
||||
tc.kind === "execute" ||
|
||||
tc.server?.includes("shell") ||
|
||||
tc.displayName?.includes("echo")
|
||||
const bashCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "Bash");
|
||||
expect(bashCall).toBeDefined();
|
||||
expect(bashCall?.name).toBe("Bash");
|
||||
expect(bashCall?.input).toBeDefined();
|
||||
// Command text should be in input.command
|
||||
const bashInput = bashCall?.input as { command?: string } | undefined;
|
||||
expect(bashInput?.command).toContain("echo");
|
||||
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
180000
|
||||
);
|
||||
|
||||
test(
|
||||
"Claude agent: Edit tool",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const testFile = path.join(cwd, "test.txt");
|
||||
writeFileSync(testFile, "hello world\n");
|
||||
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "claude",
|
||||
cwd,
|
||||
title: "Claude Edit Test",
|
||||
modeId: "bypassPermissions",
|
||||
});
|
||||
|
||||
ctx.client.clearMessageQueue();
|
||||
|
||||
await ctx.client.sendMessage(
|
||||
agent.id,
|
||||
`Edit the file ${testFile} and change "hello" to "goodbye". Be brief.`
|
||||
);
|
||||
|
||||
await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||
|
||||
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
|
||||
expect(toolCalls.length).toBeGreaterThan(0);
|
||||
|
||||
for (const tc of toolCalls) {
|
||||
logToolCall("CLAUDE_EDIT", tc);
|
||||
}
|
||||
|
||||
const editCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "Edit");
|
||||
expect(editCall).toBeDefined();
|
||||
expect(editCall?.input).toBeDefined();
|
||||
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
180000
|
||||
);
|
||||
|
||||
test(
|
||||
"Codex agent: shell command",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
title: "Codex Shell Test",
|
||||
modeId: "full-access",
|
||||
});
|
||||
|
||||
ctx.client.clearMessageQueue();
|
||||
|
||||
await ctx.client.sendMessage(
|
||||
agent.id,
|
||||
"Run `echo hello` and tell me what it outputs. Be brief."
|
||||
);
|
||||
|
||||
await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||
|
||||
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
|
||||
expect(toolCalls.length).toBeGreaterThan(0);
|
||||
|
||||
for (const tc of toolCalls) {
|
||||
logToolCall("CODEX_SHELL", tc);
|
||||
}
|
||||
|
||||
const shellCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "shell");
|
||||
expect(shellCall).toBeDefined();
|
||||
expect(shellCall?.name).toBe("shell");
|
||||
expect(shellCall?.input).toBeDefined();
|
||||
// Command text should be in input.command
|
||||
const shellInput = shellCall?.input as { command?: string } | undefined;
|
||||
expect(shellInput?.command).toContain("echo");
|
||||
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
180000
|
||||
);
|
||||
|
||||
test(
|
||||
"Codex agent: file read",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
title: "Codex Read Test",
|
||||
modeId: "full-access",
|
||||
});
|
||||
|
||||
ctx.client.clearMessageQueue();
|
||||
|
||||
await ctx.client.sendMessage(
|
||||
agent.id,
|
||||
"Read the file /etc/hosts and tell me how many lines it has. Be brief."
|
||||
);
|
||||
|
||||
await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||
|
||||
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
|
||||
expect(toolCalls.length).toBeGreaterThan(0);
|
||||
|
||||
for (const tc of toolCalls) {
|
||||
logToolCall("CODEX_READ", tc);
|
||||
}
|
||||
|
||||
// Codex may use shell cat or file read
|
||||
const readCall = toolCalls.find(
|
||||
(tc) => tc.type === "tool_call" && tc.name === "read_file"
|
||||
);
|
||||
if (readCall) {
|
||||
expect(readCall.name).toBe("read_file");
|
||||
}
|
||||
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
180000
|
||||
);
|
||||
|
||||
test(
|
||||
"Codex agent: file edit",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const testFile = path.join(cwd, "test.txt");
|
||||
writeFileSync(testFile, "hello world\n");
|
||||
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
title: "Codex Edit Test",
|
||||
modeId: "full-access",
|
||||
});
|
||||
|
||||
ctx.client.clearMessageQueue();
|
||||
|
||||
await ctx.client.sendMessage(
|
||||
agent.id,
|
||||
`Edit the file ${testFile} and change "hello" to "goodbye". Be brief.`
|
||||
);
|
||||
|
||||
await ctx.client.waitForAgentIdle(agent.id, 120000);
|
||||
|
||||
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
|
||||
expect(toolCalls.length).toBeGreaterThan(0);
|
||||
|
||||
for (const tc of toolCalls) {
|
||||
logToolCall("CODEX_EDIT", tc);
|
||||
}
|
||||
|
||||
// Codex uses apply_patch for edits
|
||||
const editCall = toolCalls.find(
|
||||
(tc) => tc.type === "tool_call" && tc.name === "apply_patch"
|
||||
);
|
||||
if (editCall) {
|
||||
expect(editCall.name).toBe("apply_patch");
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
|
||||
@@ -142,12 +142,9 @@ export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem> =
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("tool_call"),
|
||||
server: z.string(),
|
||||
tool: z.string(),
|
||||
status: z.string().optional(),
|
||||
name: z.string(),
|
||||
callId: z.string().optional(),
|
||||
displayName: z.string().optional(),
|
||||
kind: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
input: z.unknown().optional(),
|
||||
output: z.unknown().optional(),
|
||||
error: z.unknown().optional(),
|
||||
|
||||
Reference in New Issue
Block a user