Fix hydrated tool call statuses and show tool outputs

This commit is contained in:
Mohamed Boudra
2025-11-15 13:29:55 +01:00
parent b5daa1fa66
commit bd77d53e02
5 changed files with 450 additions and 17 deletions

View File

@@ -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 })}
/>

View File

@@ -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<string, any> = {
// 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}
</Text>
</View>
{(commandDetails ||
primaryEditEntry ||
primaryReadEntry ||
genericResult ||
formattedError) && (
<View style={toolCallStylesheet.expandedContent}>
{commandDetails &&
(commandDetails.command ||
commandDetails.cwd ||
commandDetails.exitCode !== undefined ||
commandDetails.output) && (
<View style={toolCallStylesheet.section}>
<Text style={toolCallStylesheet.sectionTitle}>Command</Text>
<View style={toolCallStylesheet.sectionContent}>
{commandDetails.command && (
<Text
style={toolCallStylesheet.sectionText}
numberOfLines={2}
>
{commandDetails.command}
</Text>
)}
{commandDetails.cwd && (
<Text
style={toolCallStylesheet.sectionText}
numberOfLines={1}
>
{commandDetails.cwd}
</Text>
)}
{commandDetails.exitCode !== undefined && (
<Text style={toolCallStylesheet.sectionText}>
Exit code:{" "}
{commandDetails.exitCode === null
? "Unknown"
: commandDetails.exitCode}
</Text>
)}
{commandDetails.output && (
<Text
style={toolCallStylesheet.sectionText}
numberOfLines={6}
>
{formatPreviewValue(commandDetails.output)}
</Text>
)}
</View>
</View>
)}
{primaryReadEntry && (
<View style={toolCallStylesheet.section}>
<Text style={toolCallStylesheet.sectionTitle}>
{primaryReadEntry.filePath
? `Read: ${primaryReadEntry.filePath}`
: "Read Output"}
</Text>
<View style={toolCallStylesheet.sectionContent}>
<Text
style={toolCallStylesheet.sectionText}
numberOfLines={6}
>
{formatPreviewValue(primaryReadEntry.content)}
</Text>
</View>
</View>
)}
{primaryEditEntry && (
<View style={toolCallStylesheet.section}>
<Text style={toolCallStylesheet.sectionTitle}>
{primaryEditEntry.filePath
? `Diff: ${primaryEditEntry.filePath}`
: "Diff"}
</Text>
<View style={toolCallStylesheet.sectionContent}>
<DiffViewer diffLines={primaryEditEntry.diffLines} maxHeight={160} />
</View>
</View>
)}
{genericResult && (
<View style={toolCallStylesheet.section}>
<Text style={toolCallStylesheet.sectionTitle}>Result</Text>
<View style={toolCallStylesheet.sectionContent}>
<Text style={toolCallStylesheet.sectionText} numberOfLines={6}>
{genericResult}
</Text>
</View>
</View>
)}
{formattedError && (
<View style={toolCallStylesheet.section}>
<Text
style={[
toolCallStylesheet.sectionTitle,
toolCallStylesheet.errorSectionTitle,
]}
>
Error
</Text>
<View
style={[
toolCallStylesheet.sectionContent,
toolCallStylesheet.errorSectionContent,
]}
>
<Text
style={[
toolCallStylesheet.sectionText,
toolCallStylesheet.errorSectionTitle,
]}
numberOfLines={6}
>
{formattedError}
</Text>
</View>
</View>
)}
</View>
)}
</View>
</Pressable>
);

View File

@@ -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<object>();
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<string, unknown>;
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",

11
plan.md
View File

@@ -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`.

View File

@@ -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();