diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index c25dba5b7..35df2274e 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -27,7 +27,7 @@ import { baseColors, theme } from "@/styles/theme"; 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 type { CommandDetails, EditEntry, ReadEntry, DiffLine } from "@/utils/tool-call-parsers"; import { DiffViewer } from "./diff-viewer"; import { resolveToolCallPreview } from "./tool-call-preview"; @@ -1205,6 +1205,80 @@ function formatFullValue(value: unknown): string { } } +// Helper function to build diff lines (mirrors logic from tool-call-parsers) +function buildLineDiffFromStrings(originalText: string, updatedText: string): DiffLine[] { + const splitIntoLines = (text: string): string[] => { + if (!text) return []; + return text.replace(/\r\n/g, "\n").split("\n"); + }; + + const originalLines = splitIntoLines(originalText); + const updatedLines = splitIntoLines(updatedText); + + const hasAnyContent = originalLines.length > 0 || updatedLines.length > 0; + if (!hasAnyContent) return []; + + const m = originalLines.length; + const n = updatedLines.length; + const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); + + for (let i = m - 1; i >= 0; i -= 1) { + for (let j = n - 1; j >= 0; j -= 1) { + if (originalLines[i] === updatedLines[j]) { + dp[i][j] = dp[i + 1][j + 1] + 1; + } else { + dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + } + + const diff: DiffLine[] = []; + let i = 0; + let j = 0; + + while (i < m && j < n) { + if (originalLines[i] === updatedLines[j]) { + diff.push({ type: "context", content: ` ${originalLines[i]}` }); + i += 1; + j += 1; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + diff.push({ type: "remove", content: `-${originalLines[i]}` }); + i += 1; + } else { + diff.push({ type: "add", content: `+${updatedLines[j]}` }); + j += 1; + } + } + + while (i < m) { + diff.push({ type: "remove", content: `-${originalLines[i]}` }); + i += 1; + } + + while (j < n) { + diff.push({ type: "add", content: `+${updatedLines[j]}` }); + j += 1; + } + + return diff; +} + +// Type guard for structured tool results +type StructuredToolResult = { + type: "command" | "file_write" | "file_edit" | "file_read" | "generic"; + [key: string]: unknown; +}; + +function isStructuredToolResult(result: unknown): result is StructuredToolResult { + return ( + typeof result === "object" && + result !== null && + "type" in result && + typeof result.type === "string" && + ["command", "file_write", "file_edit", "file_read", "generic"].includes(result.type) + ); +} + export const ToolCall = memo(function ToolCall({ toolName, kind, @@ -1230,6 +1304,12 @@ export const ToolCall = memo(function ToolCall({ const [isExpanded, setIsExpanded] = useState(false); + // Check if result has a type field for structured rendering + const structuredResult = useMemo( + () => (isStructuredToolResult(result) ? result : null), + [result] + ); + const IconComponent = kind ? toolKindIcons[kind.toLowerCase()] || Wrench : Wrench; @@ -1251,6 +1331,79 @@ export const ToolCall = memo(function ToolCall({ setIsExpanded((prev) => !prev); }, []); + // Helper functions to extract data from structured results + const extractCommandFromStructured = useCallback( + (structured: StructuredToolResult): CommandDetails | null => { + if (structured.type !== "command") return null; + + const cmd: CommandDetails = {}; + if (typeof structured.command === "string") cmd.command = structured.command; + if (typeof structured.cwd === "string") cmd.cwd = structured.cwd; + if (typeof structured.output === "string") cmd.output = structured.output; + if (typeof structured.exitCode === "number") cmd.exitCode = structured.exitCode; + + return cmd.command || cmd.output ? cmd : null; + }, + [] + ); + + const extractDiffFromStructured = useCallback( + (structured: StructuredToolResult): EditEntry[] => { + if (structured.type !== "file_write" && structured.type !== "file_edit") { + return []; + } + + const filePath = typeof structured.filePath === "string" ? structured.filePath : undefined; + + // For file_write, create a diff from oldContent -> newContent + if (structured.type === "file_write") { + const oldContent = typeof structured.oldContent === "string" ? structured.oldContent : ""; + const newContent = typeof structured.newContent === "string" ? structured.newContent : ""; + + // Use the same diff building logic from tool-call-parsers + const diffLines = buildLineDiffFromStrings(oldContent, newContent); + if (diffLines.length > 0) { + return [{ filePath, diffLines }]; + } + } + + // For file_edit, it might already have diffLines or we need to construct them + if (structured.type === "file_edit") { + // Check if diffLines are provided directly + if (Array.isArray(structured.diffLines)) { + return [{ filePath, diffLines: structured.diffLines as DiffLine[] }]; + } + + // Otherwise try to build from old/new content + const oldContent = typeof structured.oldContent === "string" ? structured.oldContent : ""; + const newContent = typeof structured.newContent === "string" ? structured.newContent : ""; + const diffLines = buildLineDiffFromStrings(oldContent, newContent); + if (diffLines.length > 0) { + return [{ filePath, diffLines }]; + } + } + + return []; + }, + [] + ); + + const extractReadFromStructured = useCallback( + (structured: StructuredToolResult): ReadEntry[] => { + if (structured.type !== "file_read") return []; + + const filePath = typeof structured.filePath === "string" ? structured.filePath : undefined; + const content = typeof structured.content === "string" ? structured.content : ""; + + if (content) { + return [{ filePath, content }]; + } + + return []; + }, + [] + ); + const hasCommandDetails = Boolean( commandDetails && (commandDetails.command || @@ -1412,7 +1565,185 @@ export const ToolCall = memo(function ToolCall({ }, [args, serializedArgs, result, serializedResult, error, serializedError, hasStructuredContent]); const renderDetails = useCallback(() => { - // Don't show readSections if commandDetails has output (they show the same data) + // If we have a structured result, use type-based rendering + if (structuredResult) { + const sections: ReactNode[] = []; + + // Render based on type + switch (structuredResult.type) { + case "command": { + const cmd = extractCommandFromStructured(structuredResult); + if (cmd) { + // Reuse the command section rendering logic + sections.push( + + Command + {cmd.command ? ( + + {cmd.command} + + ) : null} + {cmd.cwd ? ( + + Directory + {cmd.cwd} + + ) : null} + {cmd.exitCode !== undefined ? ( + + Exit Code + + {cmd.exitCode === null ? "Unknown" : cmd.exitCode} + + + ) : null} + {cmd.output ? ( + + {cmd.output} + + ) : null} + + ); + } + break; + } + + case "file_write": + case "file_edit": { + const diffs = extractDiffFromStructured(structuredResult); + diffs.forEach((entry, index) => { + sections.push( + + Diff + {entry.filePath ? ( + + {entry.filePath} + + ) : null} + + + + + ); + }); + break; + } + + case "file_read": { + const reads = extractReadFromStructured(structuredResult); + reads.forEach((entry, index) => { + sections.push( + + Read Result + {entry.filePath ? ( + + {entry.filePath} + + ) : null} + + {entry.content} + + + ); + }); + break; + } + + case "generic": + default: { + // Show raw JSON for generic type + if (result !== undefined) { + sections.push( + + Result + + {serializedResult} + + + ); + } + break; + } + } + + // Always show args if available + if (args !== undefined) { + sections.unshift( + + Arguments + + {serializedArgs} + + + ); + } + + // Always show errors if available + if (error !== undefined) { + sections.push( + + Error + + + {serializedError} + + + + ); + } + + if (sections.length === 0) { + return ( + + No additional details available + + ); + } + + return {sections}; + } + + // Fall back to heuristic parsing for backwards compatibility const showReadSections = !commandDetails?.output && readSections.length > 0; if ( @@ -1436,7 +1767,23 @@ export const ToolCall = memo(function ToolCall({ {jsonSections} ); - }, [commandSection, commandDetails?.output, editSections, readSections, jsonSections]); + }, [ + structuredResult, + extractCommandFromStructured, + extractDiffFromStructured, + extractReadFromStructured, + serializedArgs, + serializedResult, + serializedError, + args, + result, + error, + commandSection, + commandDetails?.output, + editSections, + readSections, + jsonSections, + ]); return ( = {}; - // SDK tool_result blocks have content as a string - if (typeof block.content === "string" && block.content.length > 0) { - result.result = { output: block.content }; + if (content.length > 0) { + result.result = { output: content }; } // Preserve file changes tracked during tool execution @@ -1058,6 +1070,86 @@ class ClaudeAgentSession implements AgentSession { return Object.keys(result).length > 0 ? result : undefined; } + private buildStructuredToolResult( + server: string, + tool: string, + output: string, + input?: Record | null + ): Record | undefined { + const normalizedServer = server.toLowerCase(); + const normalizedTool = tool.toLowerCase(); + + // Command execution tools + if ( + normalizedServer.includes("bash") || + normalizedServer.includes("shell") || + normalizedServer.includes("command") || + normalizedTool.includes("bash") || + normalizedTool.includes("shell") || + normalizedTool.includes("command") || + (input && (typeof input.command === "string" || Array.isArray(input.command))) + ) { + const command = this.extractCommandText(input ?? {}) ?? "command"; + return { + type: "command", + command, + output, + cwd: typeof input?.cwd === "string" ? input.cwd : undefined, + }; + } + + // File write tools (new files or complete replacements) + if ( + normalizedTool.includes("write") || + normalizedTool === "write_file" || + normalizedTool === "create_file" + ) { + if (input && typeof input.file_path === "string") { + return { + type: "file_write", + filePath: input.file_path, + oldContent: "", + newContent: typeof input.content === "string" ? input.content : output, + }; + } + } + + // File edit/patch tools + if ( + normalizedTool.includes("edit") || + normalizedTool.includes("patch") || + normalizedTool === "apply_patch" || + normalizedTool === "apply_diff" + ) { + if (input && typeof input.file_path === "string") { + return { + type: "file_edit", + filePath: input.file_path, + diff: typeof input.patch === "string" ? input.patch : typeof input.diff === "string" ? input.diff : undefined, + oldContent: typeof input.old_str === "string" ? input.old_str : undefined, + newContent: typeof input.new_str === "string" ? input.new_str : undefined, + }; + } + } + + // File read tools + if ( + normalizedTool.includes("read") || + normalizedTool === "read_file" || + normalizedTool === "view_file" + ) { + if (input && typeof input.file_path === "string") { + return { + type: "file_read", + filePath: input.file_path, + content: output, + }; + } + } + + return undefined; + } + private mapPartialEvent(event: SDKPartialAssistantMessage["event"]): AgentTimelineItem[] { if (event.type === "content_block_start" && (event.content_block as ClaudeContentChunk | undefined)?.type === "tool_use") { const block = event.content_block as ClaudeContentChunk; diff --git a/packages/server/src/server/agent/providers/codex-agent.ts b/packages/server/src/server/agent/providers/codex-agent.ts index a5fd855ef..5f2a1392c 100644 --- a/packages/server/src/server/agent/providers/codex-agent.ts +++ b/packages/server/src/server/agent/providers/codex-agent.ts @@ -817,7 +817,12 @@ class CodexAgentSession implements AgentSession { return { type: "assistant_message", text: item.text }; case "reasoning": return { type: "reasoning", text: item.text }; - case "command_execution": + case "command_execution": { + const output = (item as any)?.output; + const cwd = (item as any)?.cwd; + const commandValue = item.command; + const command = typeof commandValue === "string" ? commandValue : + Array.isArray(commandValue) ? (commandValue as string[]).join(" ") : "command"; return createToolCallTimelineItem({ server: "command", tool: "shell", @@ -825,10 +830,16 @@ class CodexAgentSession implements AgentSession { callId: (item as any)?.call_id, displayName: buildCommandDisplayName(item.command), kind: "execute", - input: { command: item.command, cwd: (item as any)?.cwd }, - output: (item as any)?.output, + input: { command: item.command, cwd }, + output: typeof output === "string" ? { + type: "command", + command, + output, + cwd, + } : undefined, error: (item as any)?.error, }); + } case "file_change": { const files = item.changes.map((change) => ({ path: change.path, kind: change.kind })); return createToolCallTimelineItem({ @@ -1100,6 +1111,16 @@ function finalizeRolloutFunctionCall( const result = safeJsonParse(payload.output); const exitCode = result?.metadata?.exit_code; const status = exitCode === undefined || exitCode === 0 ? "completed" : "failed"; + + // Build structured command output + const output = result?.stdout ? { + type: "command" as const, + command: command.command, + output: result.stdout, + exitCode, + cwd: command.cwd, + } : result; + events.push({ type: "timeline", provider: "codex", @@ -1111,7 +1132,7 @@ function finalizeRolloutFunctionCall( displayName: buildCommandDisplayName(command.command), kind: "execute", input: { command: command.command, cwd: command.cwd }, - output: result, + output, }), }); commandCalls.delete(payload.call_id); diff --git a/packages/server/src/server/messages.ts b/packages/server/src/server/messages.ts index b788b3a24..c829f88e6 100644 --- a/packages/server/src/server/messages.ts +++ b/packages/server/src/server/messages.ts @@ -115,6 +115,15 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType = z.discriminatedUnion("type", [ z.object({