diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 09c6aa693..be2ad9e63 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -283,15 +283,11 @@ export function AgentStreamView({ case "tool_call": { const { payload } = item; - console.log("[TOOL_CALL_DEBUG]", JSON.stringify(payload, null, 2)); - if (payload.source === "agent") { const data = payload.data; - const toolLabel = data.displayName ?? `${data.server}/${data.tool}`; content = ( ); } else { diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 872703aec..8ff19b2da 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -315,7 +315,7 @@ const styles = StyleSheet.create((theme) => ({ overflow: "hidden", backgroundColor: theme.colors.muted, borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, + borderColor: theme.colors.accentBorder, marginBottom: theme.spacing[2], }, fileHeader: { diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 3ce0ed365..e96c099a3 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -29,6 +29,7 @@ import { Colors } from "@/constants/theme"; import * as Clipboard from "expo-clipboard"; import type { TodoEntry, ThoughtStatus } from "@/types/stream"; import type { CommandDetails, EditEntry, ReadEntry } from "@/utils/tool-call-parsers"; +import { extractPrincipalParam } from "@/utils/tool-call-parsers"; import { resolveToolCallPreview } from "./tool-call-preview"; import { useToolCallSheet } from "./tool-call-sheet"; @@ -41,15 +42,16 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({ container: { flexDirection: "row", justifyContent: "flex-end", - marginBottom: theme.spacing[3], - paddingHorizontal: theme.spacing[4], + marginBottom: theme.spacing[4], + marginTop: theme.spacing[4], + paddingHorizontal: theme.spacing[2], }, bubble: { backgroundColor: theme.colors.muted, borderRadius: theme.borderRadius["2xl"], borderTopRightRadius: theme.borderRadius.sm, paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[3], + paddingVertical: theme.spacing[4], maxWidth: "80%", }, text: { @@ -187,7 +189,7 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ pressable: { borderRadius: theme.borderRadius.lg, borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, + borderColor: theme.colors.accentBorder, backgroundColor: theme.colors.secondary, paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[1], @@ -209,13 +211,24 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ backgroundColor: "transparent", }, label: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.medium, + flexShrink: 0, + }, + secondaryLabel: { flex: 1, color: theme.colors.mutedForeground, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, + marginLeft: theme.spacing[2], + }, + spacer: { + flex: 1, }, chevron: { marginLeft: theme.spacing[1], + flexShrink: 0, }, detailWrapper: { marginTop: theme.spacing[2], @@ -829,6 +842,7 @@ interface AgentThoughtMessageProps { interface ExpandableBadgeProps { label: string; + secondaryLabel?: string; icon?: ComponentType<{ size?: number; color?: string }>; isExpanded: boolean; onToggle: () => void; @@ -839,6 +853,7 @@ interface ExpandableBadgeProps { const ExpandableBadge = memo(function ExpandableBadge({ label, + secondaryLabel, icon, isExpanded, onToggle, @@ -914,6 +929,13 @@ const ExpandableBadge = memo(function ExpandableBadge({ {label} + {secondaryLabel ? ( + + {secondaryLabel} + + ) : ( + + )} {hasDetails ? ( = { 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({ toolName, - kind, args, result, error, @@ -1112,12 +1144,18 @@ export const ToolCall = memo(function ToolCall({ parsedEditEntries, parsedReadEntries, parsedCommandDetails, + cwd, }: ToolCallProps) { const { openToolCall } = useToolCallSheet(); - const IconComponent = kind - ? toolKindIcons[kind.toLowerCase()] || Wrench - : Wrench; + const kind = getToolKindFromName(toolName); + const IconComponent = toolKindIcons[kind] || 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 const hasDetails = args !== undefined || result !== undefined || error !== undefined; @@ -1133,8 +1171,9 @@ export const ToolCall = memo(function ToolCall({ parsedEditEntries, parsedReadEntries, 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 const dummyRenderDetails = useCallback(() => null, []); @@ -1142,6 +1181,7 @@ export const ToolCall = memo(function ToolCall({ return ( Diff {entry.filePath ? ( - {entry.filePath} + {stripCwdPrefix(entry.filePath, cwd)} ) : null} @@ -478,7 +481,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) { Read Result {entry.filePath ? ( - {entry.filePath} + {stripCwdPrefix(entry.filePath, cwd)} ) : null} ({ provider: "codex", item: { type: "tool_call", - server: "local", - tool: "run", + name: "run", status: "executing", }, }); diff --git a/packages/app/src/types/stream.harness.test.ts b/packages/app/src/types/stream.harness.test.ts index 80f1eee37..6dce63ea0 100644 --- a/packages/app/src/types/stream.harness.test.ts +++ b/packages/app/src/types/stream.harness.test.ts @@ -33,8 +33,7 @@ const STREAM_HARNESS_LIVE: HarnessUpdate[] = [ { event: buildToolStartEvent({ callId: HARNESS_CALL_IDS.edit, - server: "editor", - tool: "apply_patch", + name: "apply_patch", input: { file_path: "README.md", 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({ callId: HARNESS_CALL_IDS.edit, - server: "editor", - tool: "apply_patch", + name: "apply_patch", output: { changes: [ { @@ -62,8 +60,7 @@ const STREAM_HARNESS_LIVE: HarnessUpdate[] = [ { event: buildToolStartEvent({ callId: HARNESS_CALL_IDS.read, - server: "editor", - tool: "read_file", + name: "read_file", input: { file_path: "README.md" }, }), timestamp: new Date("2025-02-01T10:00:03Z"), @@ -71,8 +68,7 @@ const STREAM_HARNESS_LIVE: HarnessUpdate[] = [ { event: buildToolResultEvent({ callId: HARNESS_CALL_IDS.read, - server: "editor", - tool: "read_file", + name: "read_file", output: { content: "# README\nNew line\n" }, }), timestamp: new Date("2025-02-01T10:00:04Z"), @@ -80,9 +76,7 @@ const STREAM_HARNESS_LIVE: HarnessUpdate[] = [ { event: buildToolStartEvent({ callId: HARNESS_CALL_IDS.command, - server: "command", - tool: "shell", - kind: "execute", + name: "shell", input: { command: "ls" }, }), timestamp: new Date("2025-02-01T10:00:05Z"), @@ -90,8 +84,7 @@ const STREAM_HARNESS_LIVE: HarnessUpdate[] = [ { event: buildToolResultEvent({ callId: HARNESS_CALL_IDS.command, - server: "command", - tool: "shell", + name: "shell", output: { result: { command: "ls", @@ -121,8 +114,7 @@ const STREAM_HARNESS_HYDRATED: HarnessUpdate[] = [ { event: buildToolStartEvent({ callId: HARNESS_CALL_IDS.edit, - server: "editor", - tool: "apply_patch", + name: "apply_patch", status: "completed", }), timestamp: new Date("2025-02-01T10:05:01Z"), @@ -130,8 +122,7 @@ const STREAM_HARNESS_HYDRATED: HarnessUpdate[] = [ { event: buildToolStartEvent({ callId: HARNESS_CALL_IDS.read, - server: "editor", - tool: "read_file", + name: "read_file", status: "completed", }), timestamp: new Date("2025-02-01T10:05:02Z"), @@ -139,9 +130,7 @@ const STREAM_HARNESS_HYDRATED: HarnessUpdate[] = [ { event: buildToolStartEvent({ callId: HARNESS_CALL_IDS.command, - server: "command", - tool: "shell", - kind: "execute", + name: "shell", status: "completed", }), timestamp: new Date("2025-02-01T10:05:03Z"), @@ -173,17 +162,13 @@ describe("stream harness captures hydrated regression", () => { function buildToolStartEvent({ callId, - server, - tool, + name, input, - kind, status = "executing", }: { callId: string; - server: string; - tool: string; + name: string; input?: Record; - kind?: string; status?: ToolCallStatus; }): AgentStreamEventPayload { return { @@ -191,12 +176,9 @@ function buildToolStartEvent({ provider: "claude", item: { type: "tool_call", - server, - tool, + name, status, callId, - displayName: tool, - kind, input, }, }; @@ -204,13 +186,11 @@ function buildToolStartEvent({ function buildToolResultEvent({ callId, - server, - tool, + name, output, }: { callId: string; - server: string; - tool: string; + name: string; output?: Record; }): AgentStreamEventPayload { return { @@ -218,10 +198,8 @@ function buildToolResultEvent({ provider: "claude", item: { type: "tool_call", - server, - tool, + name, callId, - displayName: tool, output, }, }; diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index ab8f12a22..182c9a46a 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -41,8 +41,8 @@ function reasoningTimeline(text: string, provider: TestAgentProvider = "claude") function toolTimeline( id: string, status: string, - raw?: unknown, - options?: { callId?: string | null; provider?: "claude" | "codex"; server?: string; tool?: string; displayName?: string; kind?: string } + _raw?: unknown, + options?: { callId?: string | null; provider?: "claude" | "codex"; name?: string } ): AgentStreamEventPayload { const explicitCallIdProvided = options && Object.prototype.hasOwnProperty.call(options, "callId"); @@ -52,17 +52,15 @@ function toolTimeline( : options?.callId : id; const provider = options?.provider ?? "claude"; + const name = options?.name ?? id; return { type: "timeline", provider, item: { type: "tool_call", - server: options?.server ?? "terminal", - tool: options?.tool ?? id, + name, status, callId: callIdValue, - displayName: options?.displayName ?? id, - kind: options?.kind ?? "execute", }, }; } @@ -73,12 +71,9 @@ function permissionTimeline(id: string, status: string): AgentStreamEventPayload provider: "claude", item: { type: "tool_call", - server: "permission", - tool: "permission_request", + name: "permission_request", status, callId: id, - displayName: "Permission", - kind: "permission", }, }; } @@ -188,8 +183,7 @@ function testToolCallStatusInference() { provider: 'claude', item: { type: 'tool_call', - server: 'editor', - tool: 'read', + name: 'read', status: 'pending', callId: toolCallId, }, @@ -200,8 +194,7 @@ function testToolCallStatusInference() { provider: 'claude', item: { type: 'tool_call', - server: 'editor', - tool: 'read', + name: 'read', callId: toolCallId, output: { content: 'Hello world' }, }, @@ -234,8 +227,7 @@ function testToolCallStatusInferenceFromRawOnly() { provider: 'claude', item: { type: 'tool_call', - server: 'command', - tool: 'shell', + name: 'shell', callId: toolCallId, status: 'completed', output: { metadata: { exit_code: 0 } }, @@ -257,8 +249,7 @@ function testToolCallFailureInferenceFromError() { provider: 'claude', item: { type: 'tool_call', - server: 'command', - tool: 'shell', + name: 'shell', callId: toolCallId, error: { message: 'Command failed' }, }, @@ -304,8 +295,7 @@ function testToolCallParsedPayloadHydration() { provider: 'claude', item: { type: 'tool_call', - server: 'editor', - tool: 'read_file', + name: 'read_file', status: 'pending', callId: readCallId, input: { file_path: 'README.md' }, @@ -319,8 +309,7 @@ function testToolCallParsedPayloadHydration() { provider: 'claude', item: { type: 'tool_call', - server: 'editor', - tool: 'read_file', + name: 'read_file', callId: readCallId, output: { content: 'Hello world' }, }, @@ -333,12 +322,10 @@ function testToolCallParsedPayloadHydration() { provider: 'claude', item: { type: 'tool_call', - server: 'command', - tool: 'shell', + name: 'shell', status: 'pending', callId: commandCallId, input: { command: 'pwd' }, - kind: 'execute', }, }, timestamp: timestampStart, @@ -349,8 +336,7 @@ function testToolCallParsedPayloadHydration() { provider: 'claude', item: { type: 'tool_call', - server: 'command', - tool: 'shell', + name: 'shell', callId: commandCallId, output: { result: { @@ -449,8 +435,7 @@ function testClaudeHydratedToolBodies() { provider: 'claude', item: { type: 'tool_call', - server: 'editor', - tool: 'apply_patch', + name: 'apply_patch', status: 'pending', callId: editCallId, input: { @@ -467,8 +452,7 @@ function testClaudeHydratedToolBodies() { provider: 'claude', item: { type: 'tool_call', - server: 'editor', - tool: 'apply_patch', + name: 'apply_patch', callId: editCallId, output: { changes: [ @@ -489,8 +473,7 @@ function testClaudeHydratedToolBodies() { provider: 'claude', item: { type: 'tool_call', - server: 'editor', - tool: 'read_file', + name: 'read_file', status: 'pending', callId: readCallId, input: { file_path: 'README.md' }, @@ -504,8 +487,7 @@ function testClaudeHydratedToolBodies() { provider: 'claude', item: { type: 'tool_call', - server: 'editor', - tool: 'read_file', + name: 'read_file', callId: readCallId, output: { content: '# Hydrated test file\nHello Claude!' }, }, @@ -518,12 +500,10 @@ function testClaudeHydratedToolBodies() { provider: 'claude', item: { type: 'tool_call', - server: 'command', - tool: 'shell', + name: 'shell', status: 'pending', callId: commandCallId, input: { command: 'ls' }, - kind: 'execute', }, }, timestamp: timestampStart, @@ -534,8 +514,7 @@ function testClaudeHydratedToolBodies() { provider: 'claude', item: { type: 'tool_call', - server: 'command', - tool: 'shell', + name: 'shell', callId: commandCallId, output: { result: { @@ -814,10 +793,7 @@ function buildConcurrentToolCallUpdates(provider: ToolCallProvider) { const baseOptions = { provider, - server: 'command', - tool: 'shell', - displayName: 'Run command', - kind: 'execute', + name: 'shell', } as const; return [ @@ -924,7 +900,7 @@ function buildOutOfOrderToolCallSequence(provider: ToolCallProvider) { 'shell', 'completed', { type: 'tool_result', provider, tool_call_id: callId }, - { provider, server: 'command', tool: 'shell', callId } + { provider, name: 'shell', callId } ), timestamp: timestamps[0], }, @@ -933,7 +909,7 @@ function buildOutOfOrderToolCallSequence(provider: ToolCallProvider) { 'shell', 'executing', { type: 'tool_use', provider }, - { provider, server: 'command', tool: 'shell', callId: null } + { provider, name: 'shell', callId: null } ), timestamp: timestamps[1], }, @@ -976,7 +952,7 @@ function buildMetadataReplaySequence(provider: ToolCallProvider) { 'shell', 'completed', { 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], }, @@ -985,7 +961,7 @@ function buildMetadataReplaySequence(provider: ToolCallProvider) { 'shell', 'completed', { 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], }, @@ -994,7 +970,7 @@ function buildMetadataReplaySequence(provider: ToolCallProvider) { 'shell', 'executing', { type: 'tool_use', provider }, - { provider, server: 'command', tool: 'shell', callId: null, displayName: 'Run first', kind: 'execute' } + { provider, name: 'shell', callId: null } ), timestamp: timestamps[2], }, @@ -1041,15 +1017,14 @@ function testMetadataReplayDeduplicationHydrated() { function testFallbackToolCallIdsStayUnique() { const timestamp = new Date('2025-01-01T14:05:00Z'); - // Tool calls need different server/tool to remain distinct when lacking callIds - // (displayName alone is not sufficient for differentiation) + // Tool calls need different name to remain distinct when lacking callIds const updates = [ { event: toolTimeline( 'fallback-read', 'completed', undefined, - { callId: null, server: 'editor', tool: 'read_file', displayName: 'Read file' } + { callId: null, name: 'read_file' } ), timestamp, }, @@ -1058,7 +1033,7 @@ function testFallbackToolCallIdsStayUnique() { 'fallback-shell', 'completed', undefined, - { callId: null, server: 'command', tool: 'shell', displayName: 'Run shell' } + { callId: null, name: 'shell' } ), timestamp, }, @@ -1072,7 +1047,7 @@ function testFallbackToolCallIdsStayUnique() { assert.strictEqual( new Set(ids).size, ids.length, - 'Fallback-generated tool ids must be unique when server/tool differs' + 'Fallback-generated tool ids must be unique when name differs' ); } diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index a9ab2a72b..be74c4e2d 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -106,12 +106,9 @@ interface OrchestratorToolCallData { export interface AgentToolCallData { provider: AgentProvider; - server: string; - tool: string; + name: string; status?: ToolCallStatus; callId?: string; - displayName?: string; - kind?: string; input?: unknown; result?: unknown; error?: unknown; @@ -355,8 +352,7 @@ function findExistingAgentToolCallIndex( const payload = entry.payload.data; const providerMatches = payload.provider === data.provider && - payload.server === data.server && - payload.tool === data.tool; + payload.name === data.name; if (providerMatches) { 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) { - 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 (!normalizedCallId && metadataMatches.length) { - return selectCandidate(metadataMatches); + return metadataMatches[0]?.index ?? -1; } return -1; @@ -466,8 +426,6 @@ function appendAgentToolCall( status: mergedStatus, result: mergedResult, error: mergedError, - displayName: payloadData.displayName ?? existing.payload.data.displayName, - kind: payloadData.kind ?? existing.payload.data.kind, callId: payloadData.callId ?? existing.payload.data.callId, parsedEdits: parsed.parsedEdits ?? existing.payload.data.parsedEdits, parsedReads: parsed.parsedReads ?? existing.payload.data.parsedReads, @@ -483,7 +441,7 @@ function appendAgentToolCall( : createUniqueTimelineId( state, "tool", - `${data.provider}:${data.server}:${data.tool}`, + `${data.provider}:${data.name}`, timestamp ); @@ -507,8 +465,8 @@ function isPermissionToolCall(raw: unknown): boolean { if (!raw || typeof raw !== "object") { return false; } - const candidate = raw as { server?: string; kind?: string }; - return candidate.server === "permission" || candidate.kind === "permission"; + const candidate = raw as { name?: string }; + return candidate.name === "permission_request"; } const FAILED_STATUS_PATTERN = /fail|error|deny|reject|cancel|abort|exception|refus/; @@ -779,12 +737,9 @@ export function reduceStreamUpdate( state, { provider: event.provider, - server: item.server, - tool: item.tool, + name: item.name, status: normalizeStatusString(item.status) ?? "executing", callId: item.callId, - displayName: item.displayName, - kind: item.kind, input: item.input, result: item.output, error: item.error, diff --git a/packages/app/src/utils/tool-call-parsers.ts b/packages/app/src/utils/tool-call-parsers.ts index b27dc98d2..15d4bb58d 100644 --- a/packages/app/src/utils/tool-call-parsers.ts +++ b/packages/app/src/utils/tool-call-parsers.ts @@ -752,3 +752,41 @@ export function extractKeyValuePairs(result: unknown): KeyValuePair[] { 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; +} diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index 33c45b1ad..852f07d7f 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -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; } diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 1877ccae5..5341ea72a 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -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 }; diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index 62f09f3ea..d289eb3ac 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -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 }); diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index bcca98d48..242642e89 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -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, }); } diff --git a/packages/server/src/server/agent/providers/codex-mcp-agent.test.ts b/packages/server/src/server/agent/providers/codex-mcp-agent.test.ts index 7e283c58a..9f2699136 100644 --- a/packages/server/src/server/agent/providers/codex-mcp-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-mcp-agent.test.ts @@ -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; diff --git a/packages/server/src/server/agent/providers/codex-mcp-agent.ts b/packages/server/src/server/agent/providers/codex-mcp-agent.ts index b9d29f956..5a4b55a24 100644 --- a/packages/server/src/server/agent/providers/codex-mcp-agent.ts +++ b/packages/server/src/server/agent/providers/codex-mcp-agent.ts @@ -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, }); diff --git a/packages/server/src/server/daemon.e2e.test.ts b/packages/server/src/server/daemon.e2e.test.ts index 5084c1bdc..54d617bdd 100644 --- a/packages/server/src/server/daemon.e2e.test.ts +++ b/packages/server/src/server/daemon.e2e.test.ts @@ -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(); + 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") - ); - expect(shellCall).toBeDefined(); + 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 }); }, diff --git a/packages/server/src/server/messages.ts b/packages/server/src/server/messages.ts index f1644cebb..0ef52f699 100644 --- a/packages/server/src/server/messages.ts +++ b/packages/server/src/server/messages.ts @@ -142,12 +142,9 @@ export const AgentTimelineItemPayloadSchema: z.ZodType = }), 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(),