diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 563baa199..b4c1b18bd 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -249,6 +249,8 @@ export function AgentStreamView({ toolName={toolLabel} kind={data.kind} args={data.raw} + result={data.result} + error={data.error} status={data.status as "executing" | "completed" | "failed"} onOpenDetails={() => handleOpenToolCallDetails({ payload })} /> diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 9e02f3a44..5e5d6b167 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -26,6 +26,12 @@ import { baseColors, theme } from "@/styles/theme"; import { Colors } from "@/constants/theme"; import * as Clipboard from "expo-clipboard"; import type { TodoEntry } from "@/types/stream"; +import { + extractCommandDetails, + extractEditEntries, + extractReadEntries, +} from "@/utils/tool-call-parsers"; +import { DiffViewer } from "./diff-viewer"; interface UserMessageProps { message: string; @@ -960,6 +966,26 @@ const toolKindIcons: Record = { // Add more mappings as needed }; +function formatPreviewValue(value: unknown, limit = 800): string { + if (value === undefined || value === null) { + return ""; + } + let text: string; + if (typeof value === "string") { + text = value.trim(); + } else { + try { + text = JSON.stringify(value, null, 2); + } catch { + text = String(value); + } + } + if (text.length <= limit) { + return text; + } + return `${text.slice(0, limit)}…`; +} + export const ToolCall = memo(function ToolCall({ toolName, kind, @@ -969,6 +995,22 @@ export const ToolCall = memo(function ToolCall({ status, onOpenDetails, }: ToolCallProps) { + const editEntries = useMemo(() => extractEditEntries(args, result), [args, result]); + const readEntries = useMemo(() => extractReadEntries(result, args), [args, result]); + const commandDetails = useMemo(() => extractCommandDetails(args, result), [args, result]); + + const primaryEditEntry = editEntries[0]; + const primaryReadEntry = readEntries[0]; + const genericResult = + result !== undefined && + !commandDetails?.output && + !primaryReadEntry && + !primaryEditEntry + ? formatPreviewValue(result) + : null; + const formattedError = + error !== undefined ? formatPreviewValue(error ?? null, 600) : null; + const spinAnim = useRef(new Animated.Value(0)).current; useEffect(() => { @@ -1055,6 +1097,128 @@ export const ToolCall = memo(function ToolCall({ {toolName} + {(commandDetails || + primaryEditEntry || + primaryReadEntry || + genericResult || + formattedError) && ( + + {commandDetails && + (commandDetails.command || + commandDetails.cwd || + commandDetails.exitCode !== undefined || + commandDetails.output) && ( + + Command + + {commandDetails.command && ( + + {commandDetails.command} + + )} + {commandDetails.cwd && ( + + {commandDetails.cwd} + + )} + {commandDetails.exitCode !== undefined && ( + + Exit code:{" "} + {commandDetails.exitCode === null + ? "Unknown" + : commandDetails.exitCode} + + )} + {commandDetails.output && ( + + {formatPreviewValue(commandDetails.output)} + + )} + + + )} + + {primaryReadEntry && ( + + + {primaryReadEntry.filePath + ? `Read: ${primaryReadEntry.filePath}` + : "Read Output"} + + + + {formatPreviewValue(primaryReadEntry.content)} + + + + )} + + {primaryEditEntry && ( + + + {primaryEditEntry.filePath + ? `Diff: ${primaryEditEntry.filePath}` + : "Diff"} + + + + + + )} + + {genericResult && ( + + Result + + + {genericResult} + + + + )} + + {formattedError && ( + + + Error + + + + {formattedError} + + + + )} + + )} ); diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index b03bfef45..a011c44c1 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -221,7 +221,12 @@ function appendAgentToolCall( data: AgentToolCallData, timestamp: Date ): StreamItem[] { - const normalizedStatus = normalizeToolCallStatus(data.status); + const normalizedStatus = normalizeToolCallStatus( + data.status, + data.result, + data.error, + data.raw + ); const callId = data.callId ?? extractToolCallId(data.raw); const payloadData: AgentToolCallData = { @@ -293,20 +298,140 @@ function isPermissionToolCall(raw: unknown): boolean { return candidate.server === "permission" || candidate.kind === "permission"; } -function normalizeToolCallStatus(status?: string): "executing" | "completed" | "failed" { +const FAILED_STATUS_PATTERN = /fail|error|deny|reject|cancel|abort|exception|refus/; +const COMPLETED_STATUS_PATTERN = + /complete|success|granted|applied|done|resolved|finish|succeed|ok/; + +function normalizeStatusString( + status?: string | null +): "executing" | "completed" | "failed" | null { if (!status) { - return "executing"; + return null; } - const normalized = status.toLowerCase(); - if (/fail|error|deny|reject|cancel/.test(normalized)) { + const normalized = status.trim().toLowerCase(); + if (!normalized) { + return null; + } + if (FAILED_STATUS_PATTERN.test(normalized)) { return "failed"; } - if (/complete|success|granted|applied|done|resolved/.test(normalized)) { + if (COMPLETED_STATUS_PATTERN.test(normalized)) { return "completed"; } return "executing"; } +function hasValue(value: unknown): boolean { + return value !== undefined && value !== null; +} + +function inferStatusFromRaw(raw: unknown): "completed" | "failed" | null { + if (!hasValue(raw)) { + return null; + } + + const queue: unknown[] = Array.isArray(raw) ? [...raw] : [raw]; + const visited = new Set(); + + while (queue.length > 0) { + const candidate = queue.shift(); + if (!candidate || typeof candidate !== "object") { + continue; + } + if (visited.has(candidate as object)) { + continue; + } + visited.add(candidate as object); + const record = candidate as Record; + + if (record.is_error === true) { + return "failed"; + } + + const statusValue = normalizeStatusString( + typeof record.status === "string" ? record.status : undefined + ); + if (statusValue === "failed") { + return "failed"; + } + if (statusValue === "completed") { + return "completed"; + } + + if ("error" in record && hasValue(record.error)) { + return "failed"; + } + + if (typeof record.stderr === "string" && record.stderr.length > 0) { + return "failed"; + } + + const typeValue = + typeof record.type === "string" ? record.type.toLowerCase() : ""; + if (typeValue) { + if (FAILED_STATUS_PATTERN.test(typeValue)) { + return "failed"; + } + if (/result|response|output|success/.test(typeValue)) { + return "completed"; + } + } + + const exitCode = + typeof record.exitCode === "number" + ? record.exitCode + : typeof record.exit_code === "number" + ? record.exit_code + : null; + if (exitCode !== null) { + return exitCode === 0 ? "completed" : "failed"; + } + + const successValue = + typeof record.success === "boolean" ? record.success : null; + if (successValue !== null) { + return successValue ? "completed" : "failed"; + } + + for (const value of Object.values(record)) { + if (typeof value === "object" && value !== null) { + queue.push(value); + } + } + } + + return null; +} + +function normalizeToolCallStatus( + status?: string, + result?: unknown, + error?: unknown, + raw?: unknown +): "executing" | "completed" | "failed" { + const normalizedFromStatus = normalizeStatusString(status); + if (normalizedFromStatus === "failed") { + return "failed"; + } + if (normalizedFromStatus === "completed") { + return "completed"; + } + + if (hasValue(error)) { + return "failed"; + } + if (hasValue(result)) { + return "completed"; + } + + const inferredFromRaw = inferStatusFromRaw(raw); + if (inferredFromRaw) { + return inferredFromRaw; + } + + return normalizedFromStatus ?? "executing"; +} + const TOOL_CALL_ID_KEYS = [ "toolCallId", "tool_call_id", diff --git a/plan.md b/plan.md index f45e278ff..cf7ebb1d8 100644 --- a/plan.md +++ b/plan.md @@ -17,6 +17,11 @@ - Updated the agent overflow menu label in `packages/app/src/app/agent/[id].tsx` so the refresh action now matches the desired wording while keeping the busy state text untouched; no additional changes were required. - [x] Are we filtering our own shats (already present in agents storage) from the resume agent list? We should if not. - Resume tab now filters out persisted sessions whose session ids match any active agent (live session id or persisted handle) to avoid duplicate entries; verified via `npm run typecheck --workspace=@voice-dev/app`. -- [ ] Getting "two children with the same key" for "thoughts" and "assistant" review our keying strategy, and make it more robust and performant, and stable. -- [ ] Hydrated session show previous tool calls as loading. At least for claude we're not loading the output Chekc Codex too. -- [ ] Add agent type indicator in the agent list, so we can quickly identify the agent type (Claude, Codex, etc.). On the left of the status pill. +- [x] Getting "two children with the same key" for "thoughts" and "assistant" review our keying strategy, and make it more robust and performant, and stable. + - Added deterministic per-entry suffixes when creating assistant and thought timeline ids so FlatList keys remain unique even when providers replay identical text chunks with the same timestamps; reran `npm run typecheck --workspace=@voice-dev/app`. +- [x] Hydrated session show previous tool calls as loading. At least for claude we're not loading the output Chekc Codex too. + - Tool call snapshots now infer completed/failed states when historical events lacked an explicit status, and we accumulate every tool payload in `raw` so hydrated sessions expose prior diffs/reads/command output instead of staying in a loading state. Added regression coverage in `test-idempotent-stream.ts` and ran `npm run typecheck --workspace=@voice-dev/app`. +- [x] Add agent type indicator in the agent list, so we can quickly identify the agent type (Claude, Codex, etc.). On the left of the status pill. + - Agent cards now include a provider badge left of the status pill by pulling provider labels from the manifest, with new styles to match the sidebar treatment; ran `npm run typecheck --workspace=@voice-dev/app`. +- [x] Hydrated agents still show loading state for tool calls, check this properly, it's not fixed. I am also not seeing the tool call output in the agent stream, which is important. + - Tool snapshots now infer completed/failed states by walking the raw payload (exit codes, tool_result/error flags) when status/result/error are missing, and added regression coverage in `test-idempotent-stream.ts`. The agent stream cards now render command output/read content/diff previews inline plus show failures, and we pass result/error data through so hydrated tool calls immediately display their output. Verified with `npm run typecheck --workspace=@voice-dev/app` and `npx tsx test-idempotent-stream.ts`. diff --git a/test-idempotent-stream.ts b/test-idempotent-stream.ts index 9aeb96e56..fa69715a7 100644 --- a/test-idempotent-stream.ts +++ b/test-idempotent-stream.ts @@ -253,9 +253,143 @@ function testToolCallInputPreservation() { } } -// Test 5: Assistant message chunks should preserve whitespace between words +// Test 5: Completed tool calls without status should infer completion for hydrated state +function testToolCallStatusInference() { + console.log('\n=== Test 5: Tool Call Status Inference ==='); + + const toolCallId = 'tool-completion'; + const timestamp1 = new Date('2025-01-01T10:10:00Z'); + const timestamp2 = new Date('2025-01-01T10:10:05Z'); + + const startEvent: AgentStreamEventPayload = { + type: 'timeline', + provider: 'claude', + item: { + type: 'tool_call', + server: 'editor', + tool: 'read', + status: 'pending', + callId: toolCallId, + raw: { type: 'tool_use', tool_use_id: toolCallId, input: { file_path: 'README.md' } }, + }, + }; + + const completionEvent: AgentStreamEventPayload = { + type: 'timeline', + provider: 'claude', + item: { + type: 'tool_call', + server: 'editor', + tool: 'read', + callId: toolCallId, + raw: { type: 'tool_result', tool_use_id: toolCallId, output: { content: 'Hello world' } }, + output: { content: 'Hello world' }, + }, + }; + + const state = hydrateStreamState([ + { event: startEvent, timestamp: timestamp1 }, + { event: completionEvent, timestamp: timestamp2 }, + ]); + + const toolEntry = state.find( + (item): item is ToolCallItem => + item.kind === 'tool_call' && item.payload.source === 'agent' && item.payload.data.callId === toolCallId + ); + + if ( + toolEntry && + toolEntry.payload.data.status === 'completed' && + toolEntry.payload.data.result && + (toolEntry.payload.data.result as { content?: string }).content === 'Hello world' + ) { + console.log('✅ PASS: Missing status inferred from output and completion payload kept'); + } else { + console.log('❌ FAIL: Expected inferred completion status and output'); + console.log('State:', JSON.stringify(state, null, 2)); + } +} + +// Test 5b: Completed tool calls should also infer status from raw payload +function testToolCallStatusInferenceFromRawOnly() { + console.log('\n=== Test 5b: Tool Call Status From Raw Payload ==='); + + const toolCallId = 'raw-status'; + const timestamp = new Date('2025-01-01T10:20:00Z'); + + const rawEvent: AgentStreamEventPayload = { + type: 'timeline', + provider: 'claude', + item: { + type: 'tool_call', + server: 'command', + tool: 'shell', + callId: toolCallId, + raw: { + type: 'mcp_tool_result', + tool_use_id: toolCallId, + result: { metadata: { exit_code: 0 } }, + }, + }, + }; + + const state = hydrateStreamState([{ event: rawEvent, timestamp }]); + const toolEntry = state.find( + (item): item is ToolCallItem => + item.kind === 'tool_call' && item.payload.source === 'agent' + ); + + if (toolEntry?.payload.data.status === 'completed') { + console.log('✅ PASS: Raw payload exit code inferred completion'); + } else { + console.log('❌ FAIL: Expected completed status inferred from raw payload'); + console.log('State:', JSON.stringify(state, null, 2)); + } +} + +// Test 5c: Tool call failures should be inferred from raw payload errors +function testToolCallFailureInferenceFromRaw() { + console.log('\n=== Test 5c: Tool Call Failure From Raw Payload ==='); + + const toolCallId = 'raw-error'; + const timestamp = new Date('2025-01-01T10:25:00Z'); + + const rawEvent: AgentStreamEventPayload = { + type: 'timeline', + provider: 'claude', + item: { + type: 'tool_call', + server: 'command', + tool: 'shell', + callId: toolCallId, + raw: { + type: 'mcp_tool_result', + tool_use_id: toolCallId, + is_error: true, + error: { + message: 'Command failed', + }, + }, + }, + }; + + const state = hydrateStreamState([{ event: rawEvent, timestamp }]); + const toolEntry = state.find( + (item): item is ToolCallItem => + item.kind === 'tool_call' && item.payload.source === 'agent' + ); + + if (toolEntry?.payload.data.status === 'failed') { + console.log('✅ PASS: Raw payload error inferred failure'); + } else { + console.log('❌ FAIL: Expected failed status inferred from raw payload'); + console.log('State:', JSON.stringify(state, null, 2)); + } +} + +// Test 6: Assistant message chunks should preserve whitespace between words function testAssistantWhitespacePreservation() { - console.log('\n=== Test 5: Assistant Message Whitespace Preservation ==='); + console.log('\n=== Test 6: Assistant Message Whitespace Preservation ==='); const timestamp = new Date('2025-01-01T11:00:00Z'); @@ -276,9 +410,9 @@ function testAssistantWhitespacePreservation() { } } -// Test 6: User messages should persist through hydration and deduplicate with live events +// Test 7: User messages should persist through hydration and deduplicate with live events function testUserMessageHydration() { - console.log('\n=== Test 6: User Message Hydration ==='); + console.log('\n=== Test 7: User Message Hydration ==='); const timestamp = new Date('2025-01-01T11:30:00Z'); const messageId = 'msg_user_1'; @@ -316,9 +450,9 @@ function testUserMessageHydration() { } } -// Test 7: Permission tool calls should not show in the timeline +// Test 8: Permission tool calls should not show in the timeline function testPermissionToolCallFiltering() { - console.log('\n=== Test 7: Permission Tool Call Filtering ==='); + console.log('\n=== Test 8: Permission Tool Call Filtering ==='); const timestamp = new Date('2025-01-01T12:00:00Z'); const updates = [ @@ -339,9 +473,9 @@ function testPermissionToolCallFiltering() { } } -// Test 8: Todo lists should consolidate into a single entry and update completions +// Test 9: Todo lists should consolidate into a single entry and update completions function testTodoListConsolidation() { - console.log('\n=== Test 8: Todo List Consolidation ==='); + console.log('\n=== Test 9: Todo List Consolidation ==='); const timestamp1 = new Date('2025-01-01T12:30:00Z'); const timestamp2 = new Date('2025-01-01T12:31:00Z'); @@ -387,6 +521,9 @@ testIdempotentReduction(); testUserMessageDeduplication(); testMultipleMessages(); testToolCallInputPreservation(); +testToolCallStatusInference(); +testToolCallStatusInferenceFromRawOnly(); +testToolCallFailureInferenceFromRaw(); testAssistantWhitespacePreservation(); testUserMessageHydration(); testPermissionToolCallFiltering();