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:
Mohamed Boudra
2026-01-07 12:38:04 +07:00
co-authored by Claude Opus 4.5
parent 4f3b2317ab
commit 66881511b6
18 changed files with 475 additions and 445 deletions
@@ -283,15 +283,11 @@ export function AgentStreamView({
case "tool_call": { case "tool_call": {
const { payload } = item; const { payload } = item;
console.log("[TOOL_CALL_DEBUG]", JSON.stringify(payload, null, 2));
if (payload.source === "agent") { if (payload.source === "agent") {
const data = payload.data; const data = payload.data;
const toolLabel = data.displayName ?? `${data.server}/${data.tool}`;
content = ( content = (
<ToolCall <ToolCall
toolName={toolLabel} toolName={data.name}
kind={data.kind}
args={data.input} args={data.input}
result={data.result} result={data.result}
error={data.error} error={data.error}
@@ -299,6 +295,7 @@ export function AgentStreamView({
parsedEditEntries={data.parsedEdits} parsedEditEntries={data.parsedEdits}
parsedReadEntries={data.parsedReads} parsedReadEntries={data.parsedReads}
parsedCommandDetails={data.parsedCommand ?? null} parsedCommandDetails={data.parsedCommand ?? null}
cwd={agent.cwd}
/> />
); );
} else { } else {
@@ -315,7 +315,7 @@ const styles = StyleSheet.create((theme) => ({
overflow: "hidden", overflow: "hidden",
backgroundColor: theme.colors.muted, backgroundColor: theme.colors.muted,
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border, borderColor: theme.colors.accentBorder,
marginBottom: theme.spacing[2], marginBottom: theme.spacing[2],
}, },
fileHeader: { fileHeader: {
+50 -10
View File
@@ -29,6 +29,7 @@ import { Colors } from "@/constants/theme";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import type { TodoEntry, ThoughtStatus } from "@/types/stream"; import type { TodoEntry, ThoughtStatus } from "@/types/stream";
import type { CommandDetails, EditEntry, ReadEntry } from "@/utils/tool-call-parsers"; import type { CommandDetails, EditEntry, ReadEntry } from "@/utils/tool-call-parsers";
import { extractPrincipalParam } from "@/utils/tool-call-parsers";
import { resolveToolCallPreview } from "./tool-call-preview"; import { resolveToolCallPreview } from "./tool-call-preview";
import { useToolCallSheet } from "./tool-call-sheet"; import { useToolCallSheet } from "./tool-call-sheet";
@@ -41,15 +42,16 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
container: { container: {
flexDirection: "row", flexDirection: "row",
justifyContent: "flex-end", justifyContent: "flex-end",
marginBottom: theme.spacing[3], marginBottom: theme.spacing[4],
paddingHorizontal: theme.spacing[4], marginTop: theme.spacing[4],
paddingHorizontal: theme.spacing[2],
}, },
bubble: { bubble: {
backgroundColor: theme.colors.muted, backgroundColor: theme.colors.muted,
borderRadius: theme.borderRadius["2xl"], borderRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius.sm, borderTopRightRadius: theme.borderRadius.sm,
paddingHorizontal: theme.spacing[4], paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3], paddingVertical: theme.spacing[4],
maxWidth: "80%", maxWidth: "80%",
}, },
text: { text: {
@@ -187,7 +189,7 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
pressable: { pressable: {
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border, borderColor: theme.colors.accentBorder,
backgroundColor: theme.colors.secondary, backgroundColor: theme.colors.secondary,
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
@@ -209,13 +211,24 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
backgroundColor: "transparent", backgroundColor: "transparent",
}, },
label: { label: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
flexShrink: 0,
},
secondaryLabel: {
flex: 1, flex: 1,
color: theme.colors.mutedForeground, color: theme.colors.mutedForeground,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
marginLeft: theme.spacing[2],
},
spacer: {
flex: 1,
}, },
chevron: { chevron: {
marginLeft: theme.spacing[1], marginLeft: theme.spacing[1],
flexShrink: 0,
}, },
detailWrapper: { detailWrapper: {
marginTop: theme.spacing[2], marginTop: theme.spacing[2],
@@ -829,6 +842,7 @@ interface AgentThoughtMessageProps {
interface ExpandableBadgeProps { interface ExpandableBadgeProps {
label: string; label: string;
secondaryLabel?: string;
icon?: ComponentType<{ size?: number; color?: string }>; icon?: ComponentType<{ size?: number; color?: string }>;
isExpanded: boolean; isExpanded: boolean;
onToggle: () => void; onToggle: () => void;
@@ -839,6 +853,7 @@ interface ExpandableBadgeProps {
const ExpandableBadge = memo(function ExpandableBadge({ const ExpandableBadge = memo(function ExpandableBadge({
label, label,
secondaryLabel,
icon, icon,
isExpanded, isExpanded,
onToggle, onToggle,
@@ -914,6 +929,13 @@ const ExpandableBadge = memo(function ExpandableBadge({
<Text style={expandableBadgeStylesheet.label} numberOfLines={1}> <Text style={expandableBadgeStylesheet.label} numberOfLines={1}>
{label} {label}
</Text> </Text>
{secondaryLabel ? (
<Text style={expandableBadgeStylesheet.secondaryLabel} numberOfLines={1}>
{secondaryLabel}
</Text>
) : (
<View style={expandableBadgeStylesheet.spacer} />
)}
{hasDetails ? ( {hasDetails ? (
<ChevronRight <ChevronRight
size={14} size={14}
@@ -1084,7 +1106,6 @@ export function AgentThoughtMessage({ message, status = "ready" }: AgentThoughtM
interface ToolCallProps { interface ToolCallProps {
toolName: string; toolName: string;
kind?: string; // Optional kind for ACP tool calls
args: any; args: any;
result?: any; result?: any;
error?: any; error?: any;
@@ -1092,6 +1113,7 @@ interface ToolCallProps {
parsedEditEntries?: EditEntry[]; parsedEditEntries?: EditEntry[];
parsedReadEntries?: ReadEntry[]; parsedReadEntries?: ReadEntry[];
parsedCommandDetails?: CommandDetails | null; parsedCommandDetails?: CommandDetails | null;
cwd?: string;
} }
// Icon mapping for tool kinds // Icon mapping for tool kinds
@@ -1102,9 +1124,19 @@ const toolKindIcons: Record<string, any> = {
search: Search, search: Search,
}; };
// Derive tool kind from tool name for icon selection
function getToolKindFromName(toolName: string): string {
const lower = toolName.toLowerCase();
if (lower === "read" || lower === "read_file" || lower.startsWith("read")) 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 "tool";
}
export const ToolCall = memo(function ToolCall({ export const ToolCall = memo(function ToolCall({
toolName, toolName,
kind,
args, args,
result, result,
error, error,
@@ -1112,12 +1144,18 @@ export const ToolCall = memo(function ToolCall({
parsedEditEntries, parsedEditEntries,
parsedReadEntries, parsedReadEntries,
parsedCommandDetails, parsedCommandDetails,
cwd,
}: ToolCallProps) { }: ToolCallProps) {
const { openToolCall } = useToolCallSheet(); const { openToolCall } = useToolCallSheet();
const IconComponent = kind const kind = getToolKindFromName(toolName);
? toolKindIcons[kind.toLowerCase()] || Wrench const IconComponent = toolKindIcons[kind] || Wrench;
: Wrench;
// Extract principal param for secondary label (memoized)
const principalParam = useMemo(
() => extractPrincipalParam(args, cwd),
[args, cwd]
);
// Check if there's any content to display in the sheet // Check if there's any content to display in the sheet
const hasDetails = args !== undefined || result !== undefined || error !== undefined; const hasDetails = args !== undefined || result !== undefined || error !== undefined;
@@ -1133,8 +1171,9 @@ export const ToolCall = memo(function ToolCall({
parsedEditEntries, parsedEditEntries,
parsedReadEntries, parsedReadEntries,
parsedCommandDetails, parsedCommandDetails,
cwd,
}); });
}, [openToolCall, toolName, kind, status, args, result, error, parsedEditEntries, parsedReadEntries, parsedCommandDetails]); }, [openToolCall, toolName, kind, status, args, result, error, parsedEditEntries, parsedReadEntries, parsedCommandDetails, cwd]);
// Dummy renderDetails to make badge tappable - actual content is rendered in the sheet // Dummy renderDetails to make badge tappable - actual content is rendered in the sheet
const dummyRenderDetails = useCallback(() => null, []); const dummyRenderDetails = useCallback(() => null, []);
@@ -1142,6 +1181,7 @@ export const ToolCall = memo(function ToolCall({
return ( return (
<ExpandableBadge <ExpandableBadge
label={toolName} label={toolName}
secondaryLabel={principalParam}
icon={IconComponent} icon={IconComponent}
isExpanded={false} isExpanded={false}
onToggle={handleOpenSheet} onToggle={handleOpenSheet}
@@ -17,6 +17,7 @@ import { Pencil, Eye, SquareTerminal, Search, Wrench, X } from "lucide-react-nat
import { DiffViewer } from "./diff-viewer"; import { DiffViewer } from "./diff-viewer";
import { import {
extractKeyValuePairs, extractKeyValuePairs,
stripCwdPrefix,
type EditEntry, type EditEntry,
type ReadEntry, type ReadEntry,
type CommandDetails, type CommandDetails,
@@ -36,6 +37,7 @@ export interface ToolCallSheetData {
parsedEditEntries?: EditEntry[]; parsedEditEntries?: EditEntry[];
parsedReadEntries?: ReadEntry[]; parsedReadEntries?: ReadEntry[];
parsedCommandDetails?: CommandDetails | null; parsedCommandDetails?: CommandDetails | null;
cwd?: string;
} }
interface ToolCallSheetContextValue { interface ToolCallSheetContextValue {
@@ -234,6 +236,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
parsedEditEntries, parsedEditEntries,
parsedReadEntries, parsedReadEntries,
parsedCommandDetails, parsedCommandDetails,
cwd,
} = data; } = data;
const IconComponent = kind const IconComponent = kind
@@ -453,7 +456,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
<Text style={styles.sectionTitle}>Diff</Text> <Text style={styles.sectionTitle}>Diff</Text>
{entry.filePath ? ( {entry.filePath ? (
<View style={styles.fileBadge}> <View style={styles.fileBadge}>
<Text style={styles.fileBadgeText}>{entry.filePath}</Text> <Text style={styles.fileBadgeText}>{stripCwdPrefix(entry.filePath, cwd)}</Text>
</View> </View>
) : null} ) : null}
<View style={styles.diffContainer}> <View style={styles.diffContainer}>
@@ -478,7 +481,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
<Text style={styles.sectionTitle}>Read Result</Text> <Text style={styles.sectionTitle}>Read Result</Text>
{entry.filePath ? ( {entry.filePath ? (
<View style={styles.fileBadge}> <View style={styles.fileBadge}>
<Text style={styles.fileBadgeText}>{entry.filePath}</Text> <Text style={styles.fileBadgeText}>{stripCwdPrefix(entry.filePath, cwd)}</Text>
</View> </View>
) : null} ) : null}
<ScrollView <ScrollView
+1
View File
@@ -179,6 +179,7 @@ const commonTheme = {
fontWeight: { fontWeight: {
normal: "normal" as const, normal: "normal" as const,
medium: "500" as const,
semibold: "600" as const, semibold: "600" as const,
bold: "bold" as const, bold: "bold" as const,
}, },
+1 -2
View File
@@ -19,8 +19,7 @@ const toolCallEvent = (): AgentStreamEventPayload => ({
provider: "codex", provider: "codex",
item: { item: {
type: "tool_call", type: "tool_call",
server: "local", name: "run",
tool: "run",
status: "executing", status: "executing",
}, },
}); });
+15 -37
View File
@@ -33,8 +33,7 @@ const STREAM_HARNESS_LIVE: HarnessUpdate[] = [
{ {
event: buildToolStartEvent({ event: buildToolStartEvent({
callId: HARNESS_CALL_IDS.edit, callId: HARNESS_CALL_IDS.edit,
server: "editor", name: "apply_patch",
tool: "apply_patch",
input: { input: {
file_path: "README.md", file_path: "README.md",
patch: "*** Begin Patch\n*** Update File: README.md\n@@\n-Old line\n+New line\n*** End Patch", patch: "*** Begin Patch\n*** Update File: README.md\n@@\n-Old line\n+New line\n*** End Patch",
@@ -45,8 +44,7 @@ const STREAM_HARNESS_LIVE: HarnessUpdate[] = [
{ {
event: buildToolResultEvent({ event: buildToolResultEvent({
callId: HARNESS_CALL_IDS.edit, callId: HARNESS_CALL_IDS.edit,
server: "editor", name: "apply_patch",
tool: "apply_patch",
output: { output: {
changes: [ changes: [
{ {
@@ -62,8 +60,7 @@ const STREAM_HARNESS_LIVE: HarnessUpdate[] = [
{ {
event: buildToolStartEvent({ event: buildToolStartEvent({
callId: HARNESS_CALL_IDS.read, callId: HARNESS_CALL_IDS.read,
server: "editor", name: "read_file",
tool: "read_file",
input: { file_path: "README.md" }, input: { file_path: "README.md" },
}), }),
timestamp: new Date("2025-02-01T10:00:03Z"), timestamp: new Date("2025-02-01T10:00:03Z"),
@@ -71,8 +68,7 @@ const STREAM_HARNESS_LIVE: HarnessUpdate[] = [
{ {
event: buildToolResultEvent({ event: buildToolResultEvent({
callId: HARNESS_CALL_IDS.read, callId: HARNESS_CALL_IDS.read,
server: "editor", name: "read_file",
tool: "read_file",
output: { content: "# README\nNew line\n" }, output: { content: "# README\nNew line\n" },
}), }),
timestamp: new Date("2025-02-01T10:00:04Z"), timestamp: new Date("2025-02-01T10:00:04Z"),
@@ -80,9 +76,7 @@ const STREAM_HARNESS_LIVE: HarnessUpdate[] = [
{ {
event: buildToolStartEvent({ event: buildToolStartEvent({
callId: HARNESS_CALL_IDS.command, callId: HARNESS_CALL_IDS.command,
server: "command", name: "shell",
tool: "shell",
kind: "execute",
input: { command: "ls" }, input: { command: "ls" },
}), }),
timestamp: new Date("2025-02-01T10:00:05Z"), timestamp: new Date("2025-02-01T10:00:05Z"),
@@ -90,8 +84,7 @@ const STREAM_HARNESS_LIVE: HarnessUpdate[] = [
{ {
event: buildToolResultEvent({ event: buildToolResultEvent({
callId: HARNESS_CALL_IDS.command, callId: HARNESS_CALL_IDS.command,
server: "command", name: "shell",
tool: "shell",
output: { output: {
result: { result: {
command: "ls", command: "ls",
@@ -121,8 +114,7 @@ const STREAM_HARNESS_HYDRATED: HarnessUpdate[] = [
{ {
event: buildToolStartEvent({ event: buildToolStartEvent({
callId: HARNESS_CALL_IDS.edit, callId: HARNESS_CALL_IDS.edit,
server: "editor", name: "apply_patch",
tool: "apply_patch",
status: "completed", status: "completed",
}), }),
timestamp: new Date("2025-02-01T10:05:01Z"), timestamp: new Date("2025-02-01T10:05:01Z"),
@@ -130,8 +122,7 @@ const STREAM_HARNESS_HYDRATED: HarnessUpdate[] = [
{ {
event: buildToolStartEvent({ event: buildToolStartEvent({
callId: HARNESS_CALL_IDS.read, callId: HARNESS_CALL_IDS.read,
server: "editor", name: "read_file",
tool: "read_file",
status: "completed", status: "completed",
}), }),
timestamp: new Date("2025-02-01T10:05:02Z"), timestamp: new Date("2025-02-01T10:05:02Z"),
@@ -139,9 +130,7 @@ const STREAM_HARNESS_HYDRATED: HarnessUpdate[] = [
{ {
event: buildToolStartEvent({ event: buildToolStartEvent({
callId: HARNESS_CALL_IDS.command, callId: HARNESS_CALL_IDS.command,
server: "command", name: "shell",
tool: "shell",
kind: "execute",
status: "completed", status: "completed",
}), }),
timestamp: new Date("2025-02-01T10:05:03Z"), timestamp: new Date("2025-02-01T10:05:03Z"),
@@ -173,17 +162,13 @@ describe("stream harness captures hydrated regression", () => {
function buildToolStartEvent({ function buildToolStartEvent({
callId, callId,
server, name,
tool,
input, input,
kind,
status = "executing", status = "executing",
}: { }: {
callId: string; callId: string;
server: string; name: string;
tool: string;
input?: Record<string, unknown>; input?: Record<string, unknown>;
kind?: string;
status?: ToolCallStatus; status?: ToolCallStatus;
}): AgentStreamEventPayload { }): AgentStreamEventPayload {
return { return {
@@ -191,12 +176,9 @@ function buildToolStartEvent({
provider: "claude", provider: "claude",
item: { item: {
type: "tool_call", type: "tool_call",
server, name,
tool,
status, status,
callId, callId,
displayName: tool,
kind,
input, input,
}, },
}; };
@@ -204,13 +186,11 @@ function buildToolStartEvent({
function buildToolResultEvent({ function buildToolResultEvent({
callId, callId,
server, name,
tool,
output, output,
}: { }: {
callId: string; callId: string;
server: string; name: string;
tool: string;
output?: Record<string, unknown>; output?: Record<string, unknown>;
}): AgentStreamEventPayload { }): AgentStreamEventPayload {
return { return {
@@ -218,10 +198,8 @@ function buildToolResultEvent({
provider: "claude", provider: "claude",
item: { item: {
type: "tool_call", type: "tool_call",
server, name,
tool,
callId, callId,
displayName: tool,
output, output,
}, },
}; };
+29 -54
View File
@@ -41,8 +41,8 @@ function reasoningTimeline(text: string, provider: TestAgentProvider = "claude")
function toolTimeline( function toolTimeline(
id: string, id: string,
status: string, status: string,
raw?: unknown, _raw?: unknown,
options?: { callId?: string | null; provider?: "claude" | "codex"; server?: string; tool?: string; displayName?: string; kind?: string } options?: { callId?: string | null; provider?: "claude" | "codex"; name?: string }
): AgentStreamEventPayload { ): AgentStreamEventPayload {
const explicitCallIdProvided = const explicitCallIdProvided =
options && Object.prototype.hasOwnProperty.call(options, "callId"); options && Object.prototype.hasOwnProperty.call(options, "callId");
@@ -52,17 +52,15 @@ function toolTimeline(
: options?.callId : options?.callId
: id; : id;
const provider = options?.provider ?? "claude"; const provider = options?.provider ?? "claude";
const name = options?.name ?? id;
return { return {
type: "timeline", type: "timeline",
provider, provider,
item: { item: {
type: "tool_call", type: "tool_call",
server: options?.server ?? "terminal", name,
tool: options?.tool ?? id,
status, status,
callId: callIdValue, callId: callIdValue,
displayName: options?.displayName ?? id,
kind: options?.kind ?? "execute",
}, },
}; };
} }
@@ -73,12 +71,9 @@ function permissionTimeline(id: string, status: string): AgentStreamEventPayload
provider: "claude", provider: "claude",
item: { item: {
type: "tool_call", type: "tool_call",
server: "permission", name: "permission_request",
tool: "permission_request",
status, status,
callId: id, callId: id,
displayName: "Permission",
kind: "permission",
}, },
}; };
} }
@@ -188,8 +183,7 @@ function testToolCallStatusInference() {
provider: 'claude', provider: 'claude',
item: { item: {
type: 'tool_call', type: 'tool_call',
server: 'editor', name: 'read',
tool: 'read',
status: 'pending', status: 'pending',
callId: toolCallId, callId: toolCallId,
}, },
@@ -200,8 +194,7 @@ function testToolCallStatusInference() {
provider: 'claude', provider: 'claude',
item: { item: {
type: 'tool_call', type: 'tool_call',
server: 'editor', name: 'read',
tool: 'read',
callId: toolCallId, callId: toolCallId,
output: { content: 'Hello world' }, output: { content: 'Hello world' },
}, },
@@ -234,8 +227,7 @@ function testToolCallStatusInferenceFromRawOnly() {
provider: 'claude', provider: 'claude',
item: { item: {
type: 'tool_call', type: 'tool_call',
server: 'command', name: 'shell',
tool: 'shell',
callId: toolCallId, callId: toolCallId,
status: 'completed', status: 'completed',
output: { metadata: { exit_code: 0 } }, output: { metadata: { exit_code: 0 } },
@@ -257,8 +249,7 @@ function testToolCallFailureInferenceFromError() {
provider: 'claude', provider: 'claude',
item: { item: {
type: 'tool_call', type: 'tool_call',
server: 'command', name: 'shell',
tool: 'shell',
callId: toolCallId, callId: toolCallId,
error: { message: 'Command failed' }, error: { message: 'Command failed' },
}, },
@@ -304,8 +295,7 @@ function testToolCallParsedPayloadHydration() {
provider: 'claude', provider: 'claude',
item: { item: {
type: 'tool_call', type: 'tool_call',
server: 'editor', name: 'read_file',
tool: 'read_file',
status: 'pending', status: 'pending',
callId: readCallId, callId: readCallId,
input: { file_path: 'README.md' }, input: { file_path: 'README.md' },
@@ -319,8 +309,7 @@ function testToolCallParsedPayloadHydration() {
provider: 'claude', provider: 'claude',
item: { item: {
type: 'tool_call', type: 'tool_call',
server: 'editor', name: 'read_file',
tool: 'read_file',
callId: readCallId, callId: readCallId,
output: { content: 'Hello world' }, output: { content: 'Hello world' },
}, },
@@ -333,12 +322,10 @@ function testToolCallParsedPayloadHydration() {
provider: 'claude', provider: 'claude',
item: { item: {
type: 'tool_call', type: 'tool_call',
server: 'command', name: 'shell',
tool: 'shell',
status: 'pending', status: 'pending',
callId: commandCallId, callId: commandCallId,
input: { command: 'pwd' }, input: { command: 'pwd' },
kind: 'execute',
}, },
}, },
timestamp: timestampStart, timestamp: timestampStart,
@@ -349,8 +336,7 @@ function testToolCallParsedPayloadHydration() {
provider: 'claude', provider: 'claude',
item: { item: {
type: 'tool_call', type: 'tool_call',
server: 'command', name: 'shell',
tool: 'shell',
callId: commandCallId, callId: commandCallId,
output: { output: {
result: { result: {
@@ -449,8 +435,7 @@ function testClaudeHydratedToolBodies() {
provider: 'claude', provider: 'claude',
item: { item: {
type: 'tool_call', type: 'tool_call',
server: 'editor', name: 'apply_patch',
tool: 'apply_patch',
status: 'pending', status: 'pending',
callId: editCallId, callId: editCallId,
input: { input: {
@@ -467,8 +452,7 @@ function testClaudeHydratedToolBodies() {
provider: 'claude', provider: 'claude',
item: { item: {
type: 'tool_call', type: 'tool_call',
server: 'editor', name: 'apply_patch',
tool: 'apply_patch',
callId: editCallId, callId: editCallId,
output: { output: {
changes: [ changes: [
@@ -489,8 +473,7 @@ function testClaudeHydratedToolBodies() {
provider: 'claude', provider: 'claude',
item: { item: {
type: 'tool_call', type: 'tool_call',
server: 'editor', name: 'read_file',
tool: 'read_file',
status: 'pending', status: 'pending',
callId: readCallId, callId: readCallId,
input: { file_path: 'README.md' }, input: { file_path: 'README.md' },
@@ -504,8 +487,7 @@ function testClaudeHydratedToolBodies() {
provider: 'claude', provider: 'claude',
item: { item: {
type: 'tool_call', type: 'tool_call',
server: 'editor', name: 'read_file',
tool: 'read_file',
callId: readCallId, callId: readCallId,
output: { content: '# Hydrated test file\nHello Claude!' }, output: { content: '# Hydrated test file\nHello Claude!' },
}, },
@@ -518,12 +500,10 @@ function testClaudeHydratedToolBodies() {
provider: 'claude', provider: 'claude',
item: { item: {
type: 'tool_call', type: 'tool_call',
server: 'command', name: 'shell',
tool: 'shell',
status: 'pending', status: 'pending',
callId: commandCallId, callId: commandCallId,
input: { command: 'ls' }, input: { command: 'ls' },
kind: 'execute',
}, },
}, },
timestamp: timestampStart, timestamp: timestampStart,
@@ -534,8 +514,7 @@ function testClaudeHydratedToolBodies() {
provider: 'claude', provider: 'claude',
item: { item: {
type: 'tool_call', type: 'tool_call',
server: 'command', name: 'shell',
tool: 'shell',
callId: commandCallId, callId: commandCallId,
output: { output: {
result: { result: {
@@ -814,10 +793,7 @@ function buildConcurrentToolCallUpdates(provider: ToolCallProvider) {
const baseOptions = { const baseOptions = {
provider, provider,
server: 'command', name: 'shell',
tool: 'shell',
displayName: 'Run command',
kind: 'execute',
} as const; } as const;
return [ return [
@@ -924,7 +900,7 @@ function buildOutOfOrderToolCallSequence(provider: ToolCallProvider) {
'shell', 'shell',
'completed', 'completed',
{ type: 'tool_result', provider, tool_call_id: callId }, { type: 'tool_result', provider, tool_call_id: callId },
{ provider, server: 'command', tool: 'shell', callId } { provider, name: 'shell', callId }
), ),
timestamp: timestamps[0], timestamp: timestamps[0],
}, },
@@ -933,7 +909,7 @@ function buildOutOfOrderToolCallSequence(provider: ToolCallProvider) {
'shell', 'shell',
'executing', 'executing',
{ type: 'tool_use', provider }, { type: 'tool_use', provider },
{ provider, server: 'command', tool: 'shell', callId: null } { provider, name: 'shell', callId: null }
), ),
timestamp: timestamps[1], timestamp: timestamps[1],
}, },
@@ -976,7 +952,7 @@ function buildMetadataReplaySequence(provider: ToolCallProvider) {
'shell', 'shell',
'completed', 'completed',
{ type: 'tool_result', provider, tool_call_id: firstCallId }, { type: 'tool_result', provider, tool_call_id: firstCallId },
{ provider, server: 'command', tool: 'shell', callId: firstCallId, displayName: 'Run first' } { provider, name: 'shell', callId: firstCallId }
), ),
timestamp: timestamps[0], timestamp: timestamps[0],
}, },
@@ -985,7 +961,7 @@ function buildMetadataReplaySequence(provider: ToolCallProvider) {
'shell', 'shell',
'completed', 'completed',
{ type: 'tool_result', provider, tool_call_id: secondCallId }, { type: 'tool_result', provider, tool_call_id: secondCallId },
{ provider, server: 'command', tool: 'shell', callId: secondCallId, displayName: 'Run second' } { provider, name: 'shell', callId: secondCallId }
), ),
timestamp: timestamps[1], timestamp: timestamps[1],
}, },
@@ -994,7 +970,7 @@ function buildMetadataReplaySequence(provider: ToolCallProvider) {
'shell', 'shell',
'executing', 'executing',
{ type: 'tool_use', provider }, { type: 'tool_use', provider },
{ provider, server: 'command', tool: 'shell', callId: null, displayName: 'Run first', kind: 'execute' } { provider, name: 'shell', callId: null }
), ),
timestamp: timestamps[2], timestamp: timestamps[2],
}, },
@@ -1041,15 +1017,14 @@ function testMetadataReplayDeduplicationHydrated() {
function testFallbackToolCallIdsStayUnique() { function testFallbackToolCallIdsStayUnique() {
const timestamp = new Date('2025-01-01T14:05:00Z'); const timestamp = new Date('2025-01-01T14:05:00Z');
// Tool calls need different server/tool to remain distinct when lacking callIds // Tool calls need different name to remain distinct when lacking callIds
// (displayName alone is not sufficient for differentiation)
const updates = [ const updates = [
{ {
event: toolTimeline( event: toolTimeline(
'fallback-read', 'fallback-read',
'completed', 'completed',
undefined, undefined,
{ callId: null, server: 'editor', tool: 'read_file', displayName: 'Read file' } { callId: null, name: 'read_file' }
), ),
timestamp, timestamp,
}, },
@@ -1058,7 +1033,7 @@ function testFallbackToolCallIdsStayUnique() {
'fallback-shell', 'fallback-shell',
'completed', 'completed',
undefined, undefined,
{ callId: null, server: 'command', tool: 'shell', displayName: 'Run shell' } { callId: null, name: 'shell' }
), ),
timestamp, timestamp,
}, },
@@ -1072,7 +1047,7 @@ function testFallbackToolCallIdsStayUnique() {
assert.strictEqual( assert.strictEqual(
new Set(ids).size, new Set(ids).size,
ids.length, ids.length,
'Fallback-generated tool ids must be unique when server/tool differs' 'Fallback-generated tool ids must be unique when name differs'
); );
} }
+8 -53
View File
@@ -106,12 +106,9 @@ interface OrchestratorToolCallData {
export interface AgentToolCallData { export interface AgentToolCallData {
provider: AgentProvider; provider: AgentProvider;
server: string; name: string;
tool: string;
status?: ToolCallStatus; status?: ToolCallStatus;
callId?: string; callId?: string;
displayName?: string;
kind?: string;
input?: unknown; input?: unknown;
result?: unknown; result?: unknown;
error?: unknown; error?: unknown;
@@ -355,8 +352,7 @@ function findExistingAgentToolCallIndex(
const payload = entry.payload.data; const payload = entry.payload.data;
const providerMatches = const providerMatches =
payload.provider === data.provider && payload.provider === data.provider &&
payload.server === data.server && payload.name === data.name;
payload.tool === data.tool;
if (providerMatches) { if (providerMatches) {
metadataMatches.push({ index: i, item: entry as AgentToolCallItem }); metadataMatches.push({ index: i, item: entry as AgentToolCallItem });
} }
@@ -371,49 +367,13 @@ function findExistingAgentToolCallIndex(
} }
} }
const normalizedDisplayName = normalizeComparableString(data.displayName);
const normalizedKind = normalizeComparableString(data.kind);
const filterByComparableField = (
candidates: Array<{ index: number; item: AgentToolCallItem }>,
selector: (entry: AgentToolCallItem) => string | null,
value: string | null
): Array<{ index: number; item: AgentToolCallItem }> => {
if (!value) {
return candidates;
}
const matches = candidates.filter((candidate) => selector(candidate.item) === value);
if (matches.length === 1) {
return matches;
}
return matches.length > 0 ? matches : candidates;
};
const selectCandidate = (
candidates: Array<{ index: number; item: AgentToolCallItem }>
): number => {
const byDisplayName = filterByComparableField(
candidates,
(entry) => normalizeComparableString(entry.payload.data.displayName),
normalizedDisplayName
);
const byKind = filterByComparableField(
byDisplayName,
(entry) => normalizeComparableString(entry.payload.data.kind),
normalizedKind
);
return byKind[0]?.index ?? -1;
};
if (fallbackCandidates.length) { if (fallbackCandidates.length) {
return selectCandidate(fallbackCandidates); return fallbackCandidates[0]?.index ?? -1;
} }
// If this update still lacks a call id, fall back to metadata matches (e.g. replayed hydration events) // If this update still lacks a call id, fall back to metadata matches (e.g. replayed hydration events)
if (!normalizedCallId && metadataMatches.length) { if (!normalizedCallId && metadataMatches.length) {
return selectCandidate(metadataMatches); return metadataMatches[0]?.index ?? -1;
} }
return -1; return -1;
@@ -466,8 +426,6 @@ function appendAgentToolCall(
status: mergedStatus, status: mergedStatus,
result: mergedResult, result: mergedResult,
error: mergedError, error: mergedError,
displayName: payloadData.displayName ?? existing.payload.data.displayName,
kind: payloadData.kind ?? existing.payload.data.kind,
callId: payloadData.callId ?? existing.payload.data.callId, callId: payloadData.callId ?? existing.payload.data.callId,
parsedEdits: parsed.parsedEdits ?? existing.payload.data.parsedEdits, parsedEdits: parsed.parsedEdits ?? existing.payload.data.parsedEdits,
parsedReads: parsed.parsedReads ?? existing.payload.data.parsedReads, parsedReads: parsed.parsedReads ?? existing.payload.data.parsedReads,
@@ -483,7 +441,7 @@ function appendAgentToolCall(
: createUniqueTimelineId( : createUniqueTimelineId(
state, state,
"tool", "tool",
`${data.provider}:${data.server}:${data.tool}`, `${data.provider}:${data.name}`,
timestamp timestamp
); );
@@ -507,8 +465,8 @@ function isPermissionToolCall(raw: unknown): boolean {
if (!raw || typeof raw !== "object") { if (!raw || typeof raw !== "object") {
return false; return false;
} }
const candidate = raw as { server?: string; kind?: string }; const candidate = raw as { name?: string };
return candidate.server === "permission" || candidate.kind === "permission"; return candidate.name === "permission_request";
} }
const FAILED_STATUS_PATTERN = /fail|error|deny|reject|cancel|abort|exception|refus/; const FAILED_STATUS_PATTERN = /fail|error|deny|reject|cancel|abort|exception|refus/;
@@ -779,12 +737,9 @@ export function reduceStreamUpdate(
state, state,
{ {
provider: event.provider, provider: event.provider,
server: item.server, name: item.name,
tool: item.tool,
status: normalizeStatusString(item.status) ?? "executing", status: normalizeStatusString(item.status) ?? "executing",
callId: item.callId, callId: item.callId,
displayName: item.displayName,
kind: item.kind,
input: item.input, input: item.input,
result: item.output, result: item.output,
error: item.error, error: item.error,
@@ -752,3 +752,41 @@ export function extractKeyValuePairs(result: unknown): KeyValuePair[] {
value: stringifyValue(value), value: stringifyValue(value),
})); }));
} }
// ---- Principal Parameter Extraction ----
const PrincipalParamSchema = z.union([
z.object({ file_path: z.string() }).transform((d) => ({ type: "path" as const, value: d.file_path })),
z.object({ filePath: z.string() }).transform((d) => ({ type: "path" as const, value: d.filePath })),
z.object({ path: z.string() }).transform((d) => ({ type: "path" as const, value: d.path })),
z.object({ command: z.string() }).transform((d) => ({ type: "text" as const, value: d.command })),
z.object({ pattern: z.string() }).transform((d) => ({ type: "text" as const, value: d.pattern })),
z.object({ query: z.string() }).transform((d) => ({ type: "text" as const, value: d.query })),
z.object({ url: z.string() }).transform((d) => ({ type: "text" as const, value: d.url })),
]);
export function stripCwdPrefix(filePath: string, cwd?: string): string {
if (!cwd || !filePath) return filePath;
const normalizedCwd = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
const normalizedPath = filePath.replace(/\\/g, "/");
const prefix = `${normalizedCwd}/`;
if (normalizedPath.startsWith(prefix)) {
return normalizedPath.slice(prefix.length);
}
if (normalizedPath === normalizedCwd) {
return ".";
}
return filePath;
}
export function extractPrincipalParam(args: unknown, cwd?: string): string | undefined {
const parsed = PrincipalParamSchema.safeParse(args);
if (!parsed.success) {
return undefined;
}
const { type, value } = parsed.data;
return type === "path" ? stripCwdPrefix(value, cwd) : value;
}
@@ -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; 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 { function appendText(buffer: string, text: string): string {
const normalized = text.trim(); const normalized = text.trim();
if (!normalized) { if (!normalized) {
@@ -50,6 +62,13 @@ function extractWebQuery(value: unknown): string {
return value.query; 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. * Convert normalized agent timeline items into a concise text summary.
*/ */
@@ -84,13 +103,12 @@ export function curateAgentActivity(
break; break;
case "tool_call": { case "tool_call": {
flushBuffers(lines, buffers); 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}` : ""; const status = item.status ? ` ${item.status}` : "";
if (item.kind === "execute" || item.server === "command") { const kind = getToolKind(item.name);
lines.push(`[Command: ${label}]${status}`); if (kind === "execute") {
} else if (item.kind === "edit" || item.server === "file_change") { const command = extractCommand(item.input);
lines.push(`[Command: ${command || item.name}]${status}`);
} else if (kind === "edit") {
const files = extractFileChanges(item.output); const files = extractFileChanges(item.output);
if (files.length > 0) { if (files.length > 0) {
lines.push("[File Changes]"); lines.push("[File Changes]");
@@ -98,13 +116,13 @@ export function curateAgentActivity(
lines.push(`- (${file.kind}) ${file.path}`); lines.push(`- (${file.kind}) ${file.path}`);
} }
} else { } 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); const query = extractWebQuery(item.input);
lines.push(`[Web Search] ${query || label}`); lines.push(`[Web Search] ${query || item.name}`);
} else { } else {
lines.push(`[Tool ${item.server}.${item.tool}]${status}`); lines.push(`[Tool ${item.name}]${status}`);
} }
break; break;
} }
@@ -55,22 +55,34 @@ export type AgentUsage = {
totalCostUsd?: number; 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 = export type AgentTimelineItem =
| { type: "user_message"; text: string; messageId?: string } | { type: "user_message"; text: string; messageId?: string }
| { type: "assistant_message"; text: string } | { type: "assistant_message"; text: string }
| { type: "reasoning"; text: string } | { type: "reasoning"; text: string }
| { | ToolCallTimelineItem
type: "tool_call";
server: string;
tool: string;
status?: string;
callId?: string;
displayName?: string;
kind?: string;
input?: unknown;
output?: unknown;
error?: unknown;
}
| { type: "todo"; items: { text: string; completed: boolean }[] } | { type: "todo"; items: { text: string; completed: boolean }[] }
| { type: "error"; message: string }; | { type: "error"; message: string };
@@ -88,21 +88,16 @@ function extractCommandText(input: unknown): string | null {
} }
function isSleepCommandToolCall(item: ToolCallItem): boolean { 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() ?? ""; const inputCommand = extractCommandText(item.input)?.toLowerCase() ?? "";
return inputCommand.includes("sleep 60"); return inputCommand.includes("sleep 60");
} }
function isPermissionCommandToolCall(item: ToolCallItem): boolean { function isPermissionCommandToolCall(item: ToolCallItem): boolean {
if (item.server === "permission") { if (item.name === "permission_request") {
return false; return false;
} }
const display = typeof item.displayName === "string" ? item.displayName.toLowerCase() : "";
const inputCommand = extractCommandText(item.input)?.toLowerCase() ?? ""; const inputCommand = extractCommandText(item.input)?.toLowerCase() ?? "";
return display.includes("permission.txt") || inputCommand.includes("permission.txt"); return inputCommand.includes("permission.txt");
} }
type AgentMcpServerHandle = { type AgentMcpServerHandle = {
@@ -354,7 +349,7 @@ describe("ClaudeAgentClient (SDK integration)", () => {
const config = buildConfig(cwd, { maxThinkingTokens: 2048 }); const config = buildConfig(cwd, { maxThinkingTokens: 2048 });
const session = await client.createSession(config); 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."); const events = session.stream("Run the exact command `pwd` via Bash and stop.");
try { try {
@@ -363,10 +358,10 @@ describe("ClaudeAgentClient (SDK integration)", () => {
if ( if (
event.type === "timeline" && event.type === "timeline" &&
event.item.type === "tool_call" && event.item.type === "tool_call" &&
event.item.server.toLowerCase().includes("bash") && event.item.name.toLowerCase().includes("bash") &&
event.item.status === "pending" event.item.status === "pending"
) { ) {
pendingDisplay = event.item.displayName ?? null; pendingCommand = extractCommandText(event.item.input);
} }
if (event.type === "turn_completed" || event.type === "turn_failed") { if (event.type === "turn_completed" || event.type === "turn_failed") {
break; break;
@@ -377,8 +372,8 @@ describe("ClaudeAgentClient (SDK integration)", () => {
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
expect(pendingDisplay).toBeTruthy(); expect(pendingCommand).toBeTruthy();
expect(pendingDisplay?.toLowerCase()).toContain("pwd"); expect(pendingCommand?.toLowerCase()).toContain("pwd");
}, },
150_000 150_000
); );
@@ -418,9 +413,8 @@ describe("ClaudeAgentClient (SDK integration)", () => {
); );
const commandEvents = toolCalls.filter( const commandEvents = toolCalls.filter(
(item) => (item) =>
(item.kind === "execute" || item.server === "command") && item.name.toLowerCase().includes("bash") &&
typeof item.displayName === "string" && item.name !== "permission_request"
!item.displayName.startsWith("permission:")
); );
const fileChangeEvent = toolCalls.find((item) => { const fileChangeEvent = toolCalls.find((item) => {
// Check for file changes in structured output.files array // Check for file changes in structured output.files array
@@ -444,7 +438,7 @@ describe("ClaudeAgentClient (SDK integration)", () => {
}); });
const sawPwdCommand = commandEvents.some( 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); expect(completed).toBe(true);
@@ -906,7 +900,8 @@ describe("ClaudeAgentClient (SDK integration)", () => {
const liveState = hydrateStreamState(liveTimelineUpdates); const liveState = hydrateStreamState(liveTimelineUpdates);
const liveSnapshots = extractAgentToolSnapshots(liveState); const liveSnapshots = extractAgentToolSnapshots(liveState);
const commandTool = liveSnapshots.find((snapshot) => 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) => const editTool = liveSnapshots.find((snapshot) =>
rawContainsText(snapshot.data.result, "hydrate-proof.txt") rawContainsText(snapshot.data.result, "hydrate-proof.txt")
@@ -953,8 +948,8 @@ describe("ClaudeAgentClient (SDK integration)", () => {
({ live, hydrated }) => { ({ live, hydrated }) => {
expect(rawContainsText(live.result, cwd)).toBe(true); expect(rawContainsText(live.result, cwd)).toBe(true);
expect(rawContainsText(hydrated.result, cwd)).toBe(true); expect(rawContainsText(hydrated.result, cwd)).toBe(true);
expect((live.displayName ?? "").toLowerCase()).toContain("pwd"); expect((extractCommandText(live.input) ?? "").toLowerCase()).toContain("pwd");
expect((hydrated.displayName ?? "").toLowerCase()).toContain("pwd"); expect((extractCommandText(hydrated.input) ?? "").toLowerCase()).toContain("pwd");
} }
); );
assertHydratedReplica( assertHydratedReplica(
@@ -1165,8 +1160,7 @@ function buildToolSnapshotKey(data: AgentToolCallData, fallbackId: string): stri
if (normalized) { if (normalized) {
return normalized; return normalized;
} }
const display = typeof data.displayName === "string" && data.displayName.trim().length > 0 ? data.displayName.trim() : fallbackId; return `${data.provider}:${data.name}:${fallbackId}`;
return `${data.server}:${data.tool}:${display}`;
} }
function assertHydratedReplica( function assertHydratedReplica(
@@ -1179,9 +1173,7 @@ function assertHydratedReplica(
const hydrated = hydratedMap.get(liveSnapshot.key); const hydrated = hydratedMap.get(liveSnapshot.key);
expect(hydrated).toBeTruthy(); expect(hydrated).toBeTruthy();
expect(hydrated?.status).toBe(liveSnapshot.data.status); expect(hydrated?.status).toBe(liveSnapshot.data.status);
expect(hydrated?.server).toBe(liveSnapshot.data.server); expect(hydrated?.name).toBe(liveSnapshot.data.name);
expect(hydrated?.tool).toBe(liveSnapshot.data.tool);
expect(hydrated?.displayName).toBe(liveSnapshot.data.displayName);
expect(predicate(hydrated!)).toBe(true); expect(predicate(hydrated!)).toBe(true);
if (hydrated && extraAssertions) { if (hydrated && extraAssertions) {
extraAssertions({ live: liveSnapshot.data, hydrated }); extraAssertions({ live: liveSnapshot.data, hydrated });
@@ -619,12 +619,9 @@ class ClaudeAgentSession implements AgentSession {
if (pending.request.kind === "plan") { if (pending.request.kind === "plan") {
await this.setMode("acceptEdits"); await this.setMode("acceptEdits");
this.pushToolCall({ this.pushToolCall({
server: "plan", name: "plan_approval",
tool: "plan_approval",
status: "granted", status: "granted",
callId: pending.request.id, callId: pending.request.id,
displayName: "Plan approved",
kind: "plan",
}); });
} }
const result: PermissionResult = { const result: PermissionResult = {
@@ -634,12 +631,9 @@ class ClaudeAgentSession implements AgentSession {
}; };
pending.resolve(result); pending.resolve(result);
this.pushToolCall({ this.pushToolCall({
server: "permission", name: pending.request.name,
tool: pending.request.name,
status: "granted", status: "granted",
callId: pending.request.id, callId: pending.request.id,
displayName: pending.request.title ?? pending.request.name,
kind: "permission",
input: pending.request.input, input: pending.request.input,
}); });
} else { } else {
@@ -650,12 +644,9 @@ class ClaudeAgentSession implements AgentSession {
}; };
pending.resolve(result); pending.resolve(result);
this.pushToolCall({ this.pushToolCall({
server: "permission", name: pending.request.name,
tool: pending.request.name,
status: "denied", status: "denied",
callId: pending.request.id, callId: pending.request.id,
displayName: pending.request.title ?? pending.request.name,
kind: "permission",
input: pending.request.input, input: pending.request.input,
}); });
} }
@@ -1039,12 +1030,9 @@ class ClaudeAgentSession implements AgentSession {
}; };
this.pushToolCall({ this.pushToolCall({
server: "permission", name: toolName,
tool: toolName,
status: "requested", status: "requested",
callId: requestId, callId: requestId,
displayName: request.title ?? toolName,
kind: "permission",
input, input,
}); });
@@ -1067,12 +1055,9 @@ class ClaudeAgentSession implements AgentSession {
cleanup(); cleanup();
const error = new Error("Permission request timed out"); const error = new Error("Permission request timed out");
this.pushToolCall({ this.pushToolCall({
server: "permission", name: toolName,
tool: toolName,
status: "denied", status: "denied",
callId: requestId, callId: requestId,
displayName: request.title ?? toolName,
kind: "permission",
input, input,
}); });
this.pushEvent({ this.pushEvent({
@@ -1138,25 +1123,6 @@ class ClaudeAgentSession implements AgentSession {
this.enqueueTimeline(item); 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) { private pushEvent(event: AgentStreamEvent) {
if (this.eventQueue) { if (this.eventQueue) {
this.eventQueue.push(event); this.eventQueue.push(event);
@@ -1315,12 +1281,9 @@ class ClaudeAgentSession implements AgentSession {
this.toolUseCache.set(entry.id, entry); this.toolUseCache.set(entry.id, entry);
this.pushToolCall( this.pushToolCall(
{ {
server: entry.server, name: entry.name,
tool: entry.name,
status: "pending", status: "pending",
callId: entry.id, callId: entry.id,
displayName: this.buildToolDisplayName(entry),
kind: this.getToolKind(entry.classification),
input: entry.input ?? this.normalizeToolInput(block.input), input: entry.input ?? this.normalizeToolInput(block.input),
}, },
items items
@@ -1329,8 +1292,7 @@ class ClaudeAgentSession implements AgentSession {
private handleToolResult(block: ClaudeContentChunk, items: AgentTimelineItem[]): void { private handleToolResult(block: ClaudeContentChunk, items: AgentTimelineItem[]): void {
const entry = typeof block.tool_use_id === "string" ? this.toolUseCache.get(block.tool_use_id) : undefined; const entry = typeof block.tool_use_id === "string" ? this.toolUseCache.get(block.tool_use_id) : undefined;
const server = entry?.server ?? block.server ?? "tool"; const toolName = entry?.name ?? block.tool_name ?? "tool";
const tool = entry?.name ?? block.tool_name ?? "tool";
const status = block.is_error ? "failed" : "completed"; const status = block.is_error ? "failed" : "completed";
// Extract output from block.content (SDK always returns content in string form) // Extract output from block.content (SDK always returns content in string form)
@@ -1338,12 +1300,9 @@ class ClaudeAgentSession implements AgentSession {
this.pushToolCall( this.pushToolCall(
{ {
server, name: toolName,
tool,
status, status,
callId: typeof block.tool_use_id === "string" ? block.tool_use_id : undefined, callId: typeof block.tool_use_id === "string" ? block.tool_use_id : undefined,
displayName: this.buildToolDisplayName(entry),
kind: this.getToolKind(entry?.classification),
input: entry?.input, input: entry?.input,
output, output,
error: block.is_error ? block : undefined, error: block.is_error ? block : undefined,
@@ -1577,12 +1536,9 @@ class ClaudeAgentSession implements AgentSession {
this.applyToolInput(entry, normalized); this.applyToolInput(entry, normalized);
this.toolUseCache.set(toolId, entry); this.toolUseCache.set(toolId, entry);
this.pushToolCall({ this.pushToolCall({
server: entry.server, name: entry.name,
tool: entry.name,
status: "pending", status: "pending",
callId: toolId, callId: toolId,
displayName: this.buildToolDisplayName(entry),
kind: this.getToolKind(entry.classification),
input: normalized, input: normalized,
}); });
} }
@@ -250,10 +250,6 @@ function stringifyUnknown(value: unknown): string {
} }
function isSleepCommandToolCall(item: ToolCallItem): boolean { 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); const inputText = commandTextFromInput(item.input);
if (!inputText) { if (!inputText) {
return false; return false;
@@ -1954,13 +1954,6 @@ function shouldReportCommandError(input: {
return false; 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 { function extractContentText(content: unknown): string | null {
if (!Array.isArray(content)) { if (!Array.isArray(content)) {
return null; return null;
@@ -3105,12 +3098,9 @@ class CodexMcpAgentSession implements AgentSession {
type: "timeline", type: "timeline",
provider: CODEX_PROVIDER, provider: CODEX_PROVIDER,
item: createToolCallTimelineItem({ item: createToolCallTimelineItem({
server: "permission", name: pending.request.name,
tool: pending.request.name,
status, status,
callId: pending.request.id, callId: pending.request.id,
displayName: pending.request.title ? pending.request.title : pending.request.name,
kind: "permission",
input: pending.request.input, input: pending.request.input,
}), }),
}); });
@@ -3415,12 +3405,9 @@ class CodexMcpAgentSession implements AgentSession {
type: "timeline", type: "timeline",
provider: CODEX_PROVIDER, provider: CODEX_PROVIDER,
item: createToolCallTimelineItem({ item: createToolCallTimelineItem({
server: "permission", name: request.name,
tool: request.name,
status: "requested", status: "requested",
callId: request.id, callId: request.id,
displayName: request.title ? request.title : request.name,
kind: "permission",
input: request.input, input: request.input,
}), }),
}); });
@@ -3535,12 +3522,9 @@ class CodexMcpAgentSession implements AgentSession {
type: "timeline", type: "timeline",
provider: CODEX_PROVIDER, provider: CODEX_PROVIDER,
item: createToolCallTimelineItem({ item: createToolCallTimelineItem({
server: "file_change", name: "apply_patch",
tool: "apply_patch",
status: success ? "completed" : "failed", status: success ? "completed" : "failed",
callId, callId,
displayName: buildFileChangeSummary(summaryFiles),
kind: "edit",
input: { files: summaryFiles }, input: { files: summaryFiles },
output: { files: pendingChanges, message: parsedOutputText, success }, output: { files: pendingChanges, message: parsedOutputText, success },
}), }),
@@ -3607,19 +3591,15 @@ class CodexMcpAgentSession implements AgentSession {
if (!callId) { if (!callId) {
throw new Error("exec_command_begin missing call_id"); throw new Error("exec_command_begin missing call_id");
} }
const commandText = normalizeCommand(parsedEvent.command);
const fileRead = extractFileReadFromParsedCmd(parsedEvent.parsedCmd); const fileRead = extractFileReadFromParsedCmd(parsedEvent.parsedCmd);
if (fileRead) { if (fileRead) {
this.emitEvent({ this.emitEvent({
type: "timeline", type: "timeline",
provider: CODEX_PROVIDER, provider: CODEX_PROVIDER,
item: createToolCallTimelineItem({ item: createToolCallTimelineItem({
server: "file", name: "read_file",
tool: "read_file",
status: "running", status: "running",
callId, callId,
displayName: `Read: ${fileRead.name}`,
kind: "read",
input: { path: fileRead.path }, input: { path: fileRead.path },
}), }),
}); });
@@ -3628,12 +3608,9 @@ class CodexMcpAgentSession implements AgentSession {
type: "timeline", type: "timeline",
provider: CODEX_PROVIDER, provider: CODEX_PROVIDER,
item: createToolCallTimelineItem({ item: createToolCallTimelineItem({
server: "command", name: "shell",
tool: "shell",
status: "running", status: "running",
callId, callId,
displayName: commandText,
kind: "execute",
input: { command: parsedEvent.command, cwd: parsedEvent.cwd }, input: { command: parsedEvent.command, cwd: parsedEvent.cwd },
}), }),
}); });
@@ -3709,12 +3686,9 @@ class CodexMcpAgentSession implements AgentSession {
type: "timeline", type: "timeline",
provider: CODEX_PROVIDER, provider: CODEX_PROVIDER,
item: createToolCallTimelineItem({ item: createToolCallTimelineItem({
server: "file", name: "read_file",
tool: "read_file",
status: failed ? "failed" : "completed", status: failed ? "failed" : "completed",
callId, callId,
displayName: `Read: ${fileRead.name}`,
kind: "read",
input: { path: fileRead.path }, input: { path: fileRead.path },
output: { output: {
type: "read_file", type: "read_file",
@@ -3749,12 +3723,9 @@ class CodexMcpAgentSession implements AgentSession {
type: "timeline", type: "timeline",
provider: CODEX_PROVIDER, provider: CODEX_PROVIDER,
item: createToolCallTimelineItem({ item: createToolCallTimelineItem({
server: "command", name: "shell",
tool: "shell",
status: failed ? "failed" : "completed", status: failed ? "failed" : "completed",
callId, callId,
displayName: commandText,
kind: "execute",
input: { command: parsedEvent.command, cwd: parsedEvent.cwd }, input: { command: parsedEvent.command, cwd: parsedEvent.cwd },
output: structuredOutput, output: structuredOutput,
}), }),
@@ -3791,12 +3762,9 @@ class CodexMcpAgentSession implements AgentSession {
type: "timeline", type: "timeline",
provider: CODEX_PROVIDER, provider: CODEX_PROVIDER,
item: createToolCallTimelineItem({ item: createToolCallTimelineItem({
server: "file_change", name: "apply_patch",
tool: "apply_patch",
status: "running", status: "running",
callId, callId,
displayName: buildFileChangeSummary(files),
kind: "edit",
input: { changes: parsedEvent.changes, files }, input: { changes: parsedEvent.changes, files },
}), }),
}); });
@@ -3852,12 +3820,9 @@ class CodexMcpAgentSession implements AgentSession {
type: "timeline", type: "timeline",
provider: CODEX_PROVIDER, provider: CODEX_PROVIDER,
item: createToolCallTimelineItem({ item: createToolCallTimelineItem({
server: "file_change", name: "apply_patch",
tool: "apply_patch",
status: parsedEvent.success ? "completed" : "failed", status: parsedEvent.success ? "completed" : "failed",
callId, callId,
displayName: buildFileChangeSummary(summaryFiles),
kind: "edit",
input: { files: summaryFiles }, input: { files: summaryFiles },
output, output,
}), }),
@@ -3881,12 +3846,9 @@ class CodexMcpAgentSession implements AgentSession {
type: "timeline", type: "timeline",
provider: CODEX_PROVIDER, provider: CODEX_PROVIDER,
item: createToolCallTimelineItem({ item: createToolCallTimelineItem({
server: parsedEvent.server, name: `${parsedEvent.server}.${parsedEvent.tool}`,
tool: parsedEvent.tool,
status: success ? "completed" : "failed", status: success ? "completed" : "failed",
callId: parsedEvent.callId, callId: parsedEvent.callId,
displayName: `${parsedEvent.server}.${parsedEvent.tool}`,
kind: "tool",
input, input,
output: normalizedOutput, output: normalizedOutput,
}), }),
@@ -4059,12 +4021,9 @@ class CodexMcpAgentSession implements AgentSession {
commandOutput.exitCode = resolvedExitCode; commandOutput.exitCode = resolvedExitCode;
} }
return createToolCallTimelineItem({ return createToolCallTimelineItem({
server: "command", name: "shell",
tool: "shell",
status: item.status, status: item.status,
callId: item.callId, callId: item.callId,
displayName: command,
kind: "execute",
input: { command: item.command, cwd: item.cwd }, input: { command: item.command, cwd: item.cwd },
output: commandOutput, output: commandOutput,
error: item.error, error: item.error,
@@ -4089,12 +4048,9 @@ class CodexMcpAgentSession implements AgentSession {
? "running" ? "running"
: "completed"; : "completed";
return createToolCallTimelineItem({ return createToolCallTimelineItem({
server: "file_change", name: "apply_patch",
tool: "apply_patch",
status, status,
callId: item.callId, callId: item.callId,
displayName: buildFileChangeSummary(summaryFiles),
kind: "edit",
input: { files: summaryFiles }, input: { files: summaryFiles },
output: { files: changes }, output: { files: changes },
}); });
@@ -4104,16 +4060,12 @@ class CodexMcpAgentSession implements AgentSession {
return null; return null;
} }
const readItem = item as ReadFileThreadItem; const readItem = item as ReadFileThreadItem;
const displayName = `Read ${readItem.path}`;
const output = const output =
readItem.content !== undefined ? readItem.content : readItem.output; readItem.content !== undefined ? readItem.content : readItem.output;
return createToolCallTimelineItem({ return createToolCallTimelineItem({
server: "file_read", name: "read_file",
tool: "read_file",
status: readItem.status, status: readItem.status,
callId: readItem.callId, callId: readItem.callId,
displayName,
kind: "read",
input: readItem.input ? readItem.input : { path: readItem.path }, input: readItem.input ? readItem.input : { path: readItem.path },
output, output,
}); });
@@ -4123,12 +4075,9 @@ class CodexMcpAgentSession implements AgentSession {
return null; return null;
} }
return createToolCallTimelineItem({ return createToolCallTimelineItem({
server: item.server, name: `${item.server}.${item.tool}`,
tool: item.tool,
status: item.status, status: item.status,
callId: item.callId, callId: item.callId,
displayName: `${item.server}.${item.tool}`,
kind: "tool",
input: item.input, input: item.input,
output: item.output, output: item.output,
}); });
@@ -4137,16 +4086,12 @@ class CodexMcpAgentSession implements AgentSession {
if (eventType && eventType !== "item.completed") { if (eventType && eventType !== "item.completed") {
return null; return null;
} }
const displayName = `Web search: ${item.query}`;
const output = const output =
item.results !== undefined ? item.results : item.output; item.results !== undefined ? item.results : item.output;
return createToolCallTimelineItem({ return createToolCallTimelineItem({
server: "web_search", name: "web_search",
tool: "web_search",
status: item.status, status: item.status,
callId: item.callId, callId: item.callId,
displayName,
kind: "search",
input: item.input ? item.input : { query: item.query }, input: item.input ? item.input : { query: item.query },
output, output,
}); });
+230 -102
View File
@@ -8,7 +8,7 @@ import {
useTempClaudeConfigDir, useTempClaudeConfigDir,
} from "./test-utils/index.js"; } from "./test-utils/index.js";
import type { AgentTimelineItem } from "./agent/agent-sdk-types.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 { function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
@@ -3117,82 +3117,81 @@ describe("daemon E2E", () => {
}); });
describe("tool call structure", () => { 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( test(
"Claude agent tool calls have expected structure", "Claude agent: Read tool",
async () => { async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
// Create Claude agent with bypass permissions
const agent = await ctx.client.createAgent({ const agent = await ctx.client.createAgent({
provider: "claude", provider: "claude",
cwd, cwd,
title: "Tool Structure Test - Claude", title: "Claude Read Test",
modeId: "bypassPermissions", modeId: "bypassPermissions",
}); });
expect(agent.provider).toBe("claude");
ctx.client.clearMessageQueue(); ctx.client.clearMessageQueue();
// Prompt that triggers a Read tool call
await ctx.client.sendMessage( await ctx.client.sendMessage(
agent.id, agent.id,
"Read the file /etc/hosts and tell me how many lines it has. Be brief." "Read the file /etc/hosts and tell me how many lines it has. Be brief."
); );
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000); 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);
}
}
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
expect(toolCalls.length).toBeGreaterThan(0); expect(toolCalls.length).toBeGreaterThan(0);
// Log and verify structure for each tool call
for (const tc of toolCalls) { for (const tc of toolCalls) {
expect(tc.type).toBe("tool_call"); logToolCall("CLAUDE_READ", tc);
// 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,
})
);
} }
// Find a Read tool call specifically const readCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "Read");
const readCall = toolCalls.find(
(tc) =>
tc.server === "Read" ||
tc.tool === "Read" ||
tc.displayName === "Read"
);
expect(readCall).toBeDefined(); expect(readCall).toBeDefined();
expect(readCall?.name).toBe("Read");
expect(readCall?.input).toBeDefined(); expect(readCall?.input).toBeDefined();
// Cleanup
await ctx.client.deleteAgent(agent.id); await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
}, },
@@ -3200,80 +3199,209 @@ describe("daemon E2E", () => {
); );
test( test(
"Codex agent tool calls have expected structure", "Claude agent: Bash tool",
async () => { async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
// Create Codex agent with full access
const agent = await ctx.client.createAgent({ const agent = await ctx.client.createAgent({
provider: "codex", provider: "claude",
cwd, cwd,
title: "Tool Structure Test - Codex", title: "Claude Bash Test",
modeId: "full-access", modeId: "bypassPermissions",
}); });
expect(agent.provider).toBe("codex");
ctx.client.clearMessageQueue(); ctx.client.clearMessageQueue();
// Prompt that triggers a shell command
await ctx.client.sendMessage( await ctx.client.sendMessage(
agent.id, agent.id,
"Run `echo hello` and tell me what it outputs. Be brief." "Run `echo hello` and tell me what it outputs. Be brief."
); );
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000); 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);
}
}
const toolCalls = extractToolCalls(ctx.client.getMessageQueue(), agent.id);
expect(toolCalls.length).toBeGreaterThan(0); expect(toolCalls.length).toBeGreaterThan(0);
// Log and verify structure for each tool call
for (const tc of toolCalls) { for (const tc of toolCalls) {
expect(tc.type).toBe("tool_call"); logToolCall("CLAUDE_BASH", tc);
// 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,
})
);
} }
// Find a shell/execute tool call const bashCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "Bash");
const shellCall = toolCalls.find( expect(bashCall).toBeDefined();
(tc) => expect(bashCall?.name).toBe("Bash");
tc.kind === "execute" || expect(bashCall?.input).toBeDefined();
tc.server?.includes("shell") || // Command text should be in input.command
tc.displayName?.includes("echo") 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).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); await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
}, },
+2 -5
View File
@@ -142,12 +142,9 @@ export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem> =
}), }),
z.object({ z.object({
type: z.literal("tool_call"), type: z.literal("tool_call"),
server: z.string(), name: z.string(),
tool: z.string(),
status: z.string().optional(),
callId: z.string().optional(), callId: z.string().optional(),
displayName: z.string().optional(), status: z.string().optional(),
kind: z.string().optional(),
input: z.unknown().optional(), input: z.unknown().optional(),
output: z.unknown().optional(), output: z.unknown().optional(),
error: z.unknown().optional(), error: z.unknown().optional(),