From 4fdb1a37f6dce94bde82c2b48b4d1e6b50a20e17 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Wed, 25 Feb 2026 15:39:23 -0800 Subject: [PATCH 01/14] feat: show thinking content and tool calls in conversation tab - Add extractThinkingContent() to HAR parser (extracts reasoning_text and thinking fields from SSE streaming responses) - Add thinkingContent field to ConversationTurn and WorkerResult types - Update worker + multi-turn loop to extract and save thinking content - Expand ConversationView UI: - Collapsible "Thinking" section with violet tint (Brain icon) - Inline tool calls with expandable arguments and responses - Replace simple "N tool call(s)" count with rich tool call list - Add 6 tests for extractThinkingContent --- .../src/components/ConversationView.tsx | 140 ++++++++++++++++-- apps/portal/src/types.ts | 1 + apps/workers/coder-acp-copilot/src/index.ts | 6 +- packages/shared/src/har/har-parser.test.ts | 84 ++++++++++- packages/shared/src/har/har-parser.ts | 47 ++++++ packages/shared/src/har/index.ts | 2 +- packages/shared/src/judge/multi-turn-loop.ts | 3 + packages/shared/src/types/types.ts | 3 + 8 files changed, 273 insertions(+), 13 deletions(-) diff --git a/apps/portal/src/components/ConversationView.tsx b/apps/portal/src/components/ConversationView.tsx index cbb969ca9..2ef9a575d 100644 --- a/apps/portal/src/components/ConversationView.tsx +++ b/apps/portal/src/components/ConversationView.tsx @@ -1,14 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import { useState } from "react"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; -import { Bot, Scale, CheckCircle2, AlertCircle } from "lucide-react"; +import { Bot, Scale, CheckCircle2, AlertCircle, Brain, Wrench, ChevronRight, ChevronDown } from "lucide-react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeRaw from "rehype-raw"; -import type { ConversationTurn } from "@/types"; +import type { ConversationTurn, ToolCall } from "@/types"; interface ConversationViewProps { turns: ConversationTurn[]; @@ -22,7 +23,9 @@ interface ConversationViewProps { * Layout: * - Task prompt (top, full-width, neutral) * - For each turn: + * - Thinking content (collapsible, muted) * - Agent response (right-aligned, primary tint) + * - Tool calls (inline, collapsible) * - Judge feedback (left-aligned, amber tint) */ export function ConversationView({ turns, task }: ConversationViewProps) { @@ -62,6 +65,90 @@ export function ConversationView({ turns, task }: ConversationViewProps) { ); } +/** Collapsible section with a toggle header */ +function CollapsibleSection({ + label, + icon: Icon, + iconClassName, + count, + defaultOpen = false, + children, +}: { + label: string; + icon: React.ComponentType<{ className?: string }>; + iconClassName?: string; + count?: number; + defaultOpen?: boolean; + children: React.ReactNode; +}) { + const [open, setOpen] = useState(defaultOpen); + return ( +
+ + {open &&
{children}
} +
+ ); +} + +/** Inline tool call display */ +function ToolCallInline({ tc }: { tc: ToolCall }) { + const [expanded, setExpanded] = useState(false); + const hasResponse = !!tc.response; + const argsStr = JSON.stringify(tc.arguments, null, 2); + const isLargeArgs = argsStr.length > 80; + + return ( +
+ + {expanded && ( +
+ {argsStr !== "{}" && ( +
+ Arguments +
{argsStr}
+
+ )} + {hasResponse && ( +
+ Response +
{tc.response}
+
+ )} +
+ )} +
+ ); +} + function TurnMessages({ turn }: { turn: ConversationTurn }) { return ( <> @@ -77,6 +164,27 @@ function TurnMessages({ turn }: { turn: ConversationTurn }) {
+ {/* Thinking content — collapsible, above agent response */} + {turn.thinkingContent && ( +
+
+ + + +
+ {turn.thinkingContent} +
+
+
+
+
+
+ )} + {/* Agent response — right aligned */}
{turn.codingAgentResponse}
- {turn.toolCalls && turn.toolCalls.length > 0 && ( -
- - {turn.toolCalls.length} tool call{turn.toolCalls.length !== 1 ? "s" : ""} - -
- )} + {/* Tool calls — inline, collapsible */} + {turn.toolCalls && turn.toolCalls.length > 0 && ( +
+
+ +
+ {turn.toolCalls.map((tc, idx) => ( + + ))} +
+
+
+
+ )} + {/* Judge feedback — left aligned */}
diff --git a/apps/portal/src/types.ts b/apps/portal/src/types.ts index b445a2073..334fc4591 100644 --- a/apps/portal/src/types.ts +++ b/apps/portal/src/types.ts @@ -29,6 +29,7 @@ export interface ConversationTurn { timestamp: string; criteriaResults?: CriterionResult[]; toolCalls?: ToolCall[]; + thinkingContent?: string; harUrl?: string; } diff --git a/apps/workers/coder-acp-copilot/src/index.ts b/apps/workers/coder-acp-copilot/src/index.ts index 970919d61..e9edc8bd6 100644 --- a/apps/workers/coder-acp-copilot/src/index.ts +++ b/apps/workers/coder-acp-copilot/src/index.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { CodingAgentQueueProcessor, WorkerProcessor, WorkerProcessorOptions, WorkerResult, QueueProcessorConfig, LogEvent, TokenManagerClient, DevProxyClient, parseHarFile, extractToolCalls, detectCliVersion } from "shared"; +import { CodingAgentQueueProcessor, WorkerProcessor, WorkerProcessorOptions, WorkerResult, QueueProcessorConfig, LogEvent, TokenManagerClient, DevProxyClient, parseHarFile, extractToolCalls, extractThinkingContent, detectCliVersion } from "shared"; import { runACPSession } from "./acp-client.js"; import dotenv from "dotenv"; @@ -127,11 +127,13 @@ class CopilotProcessor implements WorkerProcessor { if (harFilePath) { const har = await parseHarFile(harFilePath); const toolCalls = extractToolCalls(har); + const thinkingContent = extractThinkingContent(har) || undefined; await log("info", `Extracted ${toolCalls.length} tool calls from HAR`, { toolCallCount: toolCalls.length, toolNames: toolCalls.map((tc) => tc.name), + ...(thinkingContent && { thinkingContentLength: thinkingContent.length }), }); - return { response, toolCalls, harFilePath }; + return { response, toolCalls, thinkingContent, harFilePath }; } else { await log("warn", "No HAR file found after DevProxy recording"); } diff --git a/packages/shared/src/har/har-parser.test.ts b/packages/shared/src/har/har-parser.test.ts index 6442a13d3..faf858a44 100644 --- a/packages/shared/src/har/har-parser.test.ts +++ b/packages/shared/src/har/har-parser.test.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { parseHarFile, extractToolCalls, sanitizeHar } from "./har-parser.js"; +import { parseHarFile, extractToolCalls, sanitizeHar, extractThinkingContent } from "./har-parser.js"; import type { HarFile, ToolCall } from "./types.js"; // Mock fs/promises for parseHarFile tests @@ -600,3 +600,85 @@ describe("sanitizeHar", () => { expect(sanitized.log.entries[1].request.headers[0].value).toBe("[REDACTED]"); }); }); + +describe("extractThinkingContent", () => { + it("extracts reasoning_text from SSE streaming responses", () => { + const sseBody = [ + 'data: {"choices":[{"delta":{"role":"assistant"}}]}', + 'data: {"choices":[{"delta":{"reasoning_text":"Let me think"}}]}', + 'data: {"choices":[{"delta":{"reasoning_text":" about this."}}]}', + 'data: {"choices":[{"delta":{"content":"Here is the answer."}}]}', + "data: [DONE]", + ].join("\n"); + + const har = makeHar([makeEntry({ responseBody: sseBody })]); + const thinking = extractThinkingContent(har); + expect(thinking).toBe("Let me think about this."); + }); + + it("extracts thinking field from SSE streaming responses", () => { + const sseBody = [ + 'data: {"choices":[{"delta":{"role":"assistant"}}]}', + 'data: {"choices":[{"delta":{"thinking":"Analyzing the request"}}]}', + 'data: {"choices":[{"delta":{"thinking":" carefully."}}]}', + "data: [DONE]", + ].join("\n"); + + const har = makeHar([makeEntry({ responseBody: sseBody })]); + const thinking = extractThinkingContent(har); + expect(thinking).toBe("Analyzing the request carefully."); + }); + + it("concatenates thinking from multiple entries", () => { + const entry1Body = [ + 'data: {"choices":[{"delta":{"reasoning_text":"First thought."}}]}', + "data: [DONE]", + ].join("\n"); + const entry2Body = [ + 'data: {"choices":[{"delta":{"reasoning_text":"Second thought."}}]}', + "data: [DONE]", + ].join("\n"); + + const har = makeHar([ + makeEntry({ responseBody: entry1Body }), + makeEntry({ responseBody: entry2Body }), + ]); + const thinking = extractThinkingContent(har); + expect(thinking).toBe("First thought.Second thought."); + }); + + it("returns empty string when no thinking content present", () => { + const sseBody = [ + 'data: {"choices":[{"delta":{"content":"Just content."}}]}', + "data: [DONE]", + ].join("\n"); + + const har = makeHar([makeEntry({ responseBody: sseBody })]); + const thinking = extractThinkingContent(har); + expect(thinking).toBe(""); + }); + + it("skips empty reasoning_text values", () => { + const sseBody = [ + 'data: {"choices":[{"delta":{"reasoning_text":""}}]}', + 'data: {"choices":[{"delta":{"reasoning_text":"Actual thought."}}]}', + "data: [DONE]", + ].join("\n"); + + const har = makeHar([makeEntry({ responseBody: sseBody })]); + const thinking = extractThinkingContent(har); + expect(thinking).toBe("Actual thought."); + }); + + it("handles non-streaming JSON responses gracefully", () => { + const har = makeHar([ + makeEntry({ + responseBody: { + choices: [{ message: { role: "assistant", content: "No thinking here" } }], + }, + }), + ]); + const thinking = extractThinkingContent(har); + expect(thinking).toBe(""); + }); +}); diff --git a/packages/shared/src/har/har-parser.ts b/packages/shared/src/har/har-parser.ts index 5ea6ca7a0..8f0928853 100644 --- a/packages/shared/src/har/har-parser.ts +++ b/packages/shared/src/har/har-parser.ts @@ -306,3 +306,50 @@ function extractToolResponsesFromBody( // Not a valid JSON body — skip } } + +/** + * Extract thinking/reasoning content from a parsed HAR file. + * + * Scans SSE streaming responses for `reasoning_text` fields in + * `choices[].delta` and concatenates them into a single string. + * These fields contain the model's chain-of-thought reasoning + * (e.g. Copilot extended thinking). + */ +export function extractThinkingContent(har: HarFile): string { + const parts: string[] = []; + + for (const entry of har.log.entries) { + const body = getResponseBody(entry); + if (!body) continue; + + const lines = body.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith("data: ") || trimmed === "data: [DONE]") continue; + + try { + const json = JSON.parse(trimmed.slice(6)); + const choices = json.choices; + if (!Array.isArray(choices)) continue; + + for (const choice of choices) { + const delta = choice.delta; + if (!delta) continue; + + // OpenAI-style reasoning_text field (used by Copilot) + if (typeof delta.reasoning_text === "string" && delta.reasoning_text) { + parts.push(delta.reasoning_text); + } + // Also check for 'thinking' field (Anthropic-style, via proxy) + if (typeof delta.thinking === "string" && delta.thinking) { + parts.push(delta.thinking); + } + } + } catch { + continue; + } + } + } + + return parts.join(""); +} diff --git a/packages/shared/src/har/index.ts b/packages/shared/src/har/index.ts index 07992ddab..f70e8274b 100644 --- a/packages/shared/src/har/index.ts +++ b/packages/shared/src/har/index.ts @@ -1,5 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export { parseHarFile, extractToolCalls, sanitizeHar, sanitizeHarFile } from "./har-parser.js"; +export { parseHarFile, extractToolCalls, extractThinkingContent, sanitizeHar, sanitizeHarFile } from "./har-parser.js"; export type { ToolCall, HarFile, HarEntry, HarRequest, HarResponse, HarLog, HarNameValue } from "./types.js"; diff --git a/packages/shared/src/judge/multi-turn-loop.ts b/packages/shared/src/judge/multi-turn-loop.ts index d851326e2..b18646fbd 100644 --- a/packages/shared/src/judge/multi-turn-loop.ts +++ b/packages/shared/src/judge/multi-turn-loop.ts @@ -131,10 +131,12 @@ export async function runMultiTurnLoop( let codingResponse: string; let turnToolCalls: import("../har/types.js").ToolCall[] | undefined; let turnHarUrl: string | undefined; + let turnThinkingContent: string | undefined; try { const workerResult = await processor.processMessage(nextPrompt, iterLog, { model, mcpServerConfigs, skillConfigs }); codingResponse = workerResult.response; turnToolCalls = workerResult.toolCalls; + turnThinkingContent = workerResult.thinkingContent; // Upload HAR file to blob storage if available (sanitized to strip credentials) if (workerResult.harFilePath) { @@ -242,6 +244,7 @@ export async function runMultiTurnLoop( timestamp: new Date(), criteriaResults, ...(turnToolCalls && turnToolCalls.length > 0 && { toolCalls: turnToolCalls }), + ...(turnThinkingContent && { thinkingContent: turnThinkingContent }), ...(turnHarUrl && { harUrl: turnHarUrl }), }; turns.push(turn); diff --git a/packages/shared/src/types/types.ts b/packages/shared/src/types/types.ts index 1d2cfb384..037a7ce8f 100644 --- a/packages/shared/src/types/types.ts +++ b/packages/shared/src/types/types.ts @@ -18,6 +18,7 @@ export interface ConversationTurn { timestamp: Date; criteriaResults?: CriterionResult[]; // Per-criterion breakdown from DAG evaluation toolCalls?: ToolCall[]; // Tool calls extracted from DevProxy HAR for this turn + thinkingContent?: string; // Reasoning/thinking text extracted from HAR SSE streams harUrl?: string; // Blob storage URL to the HAR file for this turn } @@ -135,6 +136,8 @@ export interface WorkerResult { response: string; /** Tool calls extracted from DevProxy HAR capture (if available) */ toolCalls?: ToolCall[]; + /** Reasoning/thinking text extracted from HAR SSE streams (if available) */ + thinkingContent?: string; /** Path to the HAR file on disk (for upload to blob storage) */ harFilePath?: string; } From f497d884e971f0e812619d70dd4f578b109c3ea1 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Wed, 25 Feb 2026 16:18:48 -0800 Subject: [PATCH 02/14] refactor: extract thinking+tools from HAR client-side instead of DB - Revert server-side thinkingContent storage (types, worker, multi-turn loop) - Add useHarExtraction hook: fetches HAR per-turn, extracts thinking + tool calls - Make getResponseBody browser-safe (atob instead of Buffer.from) - ConversationView uses HAR-extracted data with DB fallback for tool calls - RunDetail passes runId to ConversationView --- .../src/components/ConversationView.tsx | 38 ++- apps/portal/src/hooks/useHarExtraction.ts | 225 ++++++++++++++++++ apps/portal/src/pages/RunDetail.tsx | 2 +- apps/portal/src/types.ts | 1 - apps/workers/coder-acp-copilot/src/index.ts | 6 +- packages/shared/src/har/har-parser.ts | 8 +- packages/shared/src/judge/multi-turn-loop.ts | 3 - packages/shared/src/types/types.ts | 3 - 8 files changed, 263 insertions(+), 23 deletions(-) create mode 100644 apps/portal/src/hooks/useHarExtraction.ts diff --git a/apps/portal/src/components/ConversationView.tsx b/apps/portal/src/components/ConversationView.tsx index 2ef9a575d..12518f6cc 100644 --- a/apps/portal/src/components/ConversationView.tsx +++ b/apps/portal/src/components/ConversationView.tsx @@ -5,16 +5,19 @@ import { useState } from "react"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; -import { Bot, Scale, CheckCircle2, AlertCircle, Brain, Wrench, ChevronRight, ChevronDown } from "lucide-react"; +import { Bot, Scale, CheckCircle2, AlertCircle, Brain, Wrench, ChevronRight, ChevronDown, Loader2 } from "lucide-react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeRaw from "rehype-raw"; import type { ConversationTurn, ToolCall } from "@/types"; +import { useHarExtraction } from "@/hooks/useHarExtraction"; interface ConversationViewProps { turns: ConversationTurn[]; /** The original task / user prompt, shown as the first message */ task?: string; + /** Run ID — needed to fetch HAR data for each turn */ + runId: string; } /** @@ -28,7 +31,7 @@ interface ConversationViewProps { * - Tool calls (inline, collapsible) * - Judge feedback (left-aligned, amber tint) */ -export function ConversationView({ turns, task }: ConversationViewProps) { +export function ConversationView({ turns, task, runId }: ConversationViewProps) { if (turns.length === 0 && !task) { return (
@@ -59,7 +62,7 @@ export function ConversationView({ turns, task }: ConversationViewProps) { {/* Turn messages */} {turns.map((turn) => ( - + ))}
); @@ -149,7 +152,14 @@ function ToolCallInline({ tc }: { tc: ToolCall }) { ); } -function TurnMessages({ turn }: { turn: ConversationTurn }) { +function TurnMessages({ turn, runId }: { turn: ConversationTurn; runId: string }) { + const hasHar = !!turn.harUrl; + const { data: harData, isLoading: harLoading } = useHarExtraction(runId, turn.iteration, hasHar); + + // Use HAR-extracted data when available, fall back to DB-stored data + const thinkingContent = harData?.thinkingContent || undefined; + const toolCalls = harData?.toolCalls ?? turn.toolCalls ?? []; + return ( <> {/* Iteration divider */} @@ -165,7 +175,7 @@ function TurnMessages({ turn }: { turn: ConversationTurn }) {
{/* Thinking content — collapsible, above agent response */} - {turn.thinkingContent && ( + {thinkingContent && (
- {turn.thinkingContent} + {thinkingContent}
@@ -209,18 +219,26 @@ function TurnMessages({ turn }: { turn: ConversationTurn }) {
{/* Tool calls — inline, collapsible */} - {turn.toolCalls && turn.toolCalls.length > 0 && ( + {harLoading && hasHar && ( +
+
+ + Loading tool calls… +
+
+ )} + {toolCalls.length > 0 && (
- {turn.toolCalls.map((tc, idx) => ( + {toolCalls.map((tc, idx) => ( ))}
diff --git a/apps/portal/src/hooks/useHarExtraction.ts b/apps/portal/src/hooks/useHarExtraction.ts new file mode 100644 index 000000000..db3ffa3bc --- /dev/null +++ b/apps/portal/src/hooks/useHarExtraction.ts @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { api } from "@/lib/api"; +import type { ToolCall } from "@/types"; + +// --------------------------------------------------------------------------- +// Minimal HAR types (matches HarNetworkViewer's types) +// --------------------------------------------------------------------------- +interface HarEntry { + startedDateTime: string; + request: { + method: string; + url: string; + postData?: { text?: string }; + }; + response: { + content: { + text?: string; + encoding?: string; + }; + }; +} + +interface HarFile { + log: { entries: HarEntry[] }; +} + +// --------------------------------------------------------------------------- +// HAR body helpers +// --------------------------------------------------------------------------- +function getResponseBody(entry: HarEntry): string | null { + const content = entry.response?.content; + if (!content?.text) return null; + if (content.encoding === "base64") { + try { + return atob(content.text); + } catch { + return null; + } + } + return content.text; +} + +// --------------------------------------------------------------------------- +// Extraction: thinking / reasoning content +// --------------------------------------------------------------------------- +function extractThinkingFromHar(har: HarFile): string { + const parts: string[] = []; + + for (const entry of har.log.entries) { + const body = getResponseBody(entry); + if (!body) continue; + + for (const line of body.split("\n")) { + const trimmed = line.trim(); + if (!trimmed.startsWith("data: ") || trimmed === "data: [DONE]") continue; + + try { + const json = JSON.parse(trimmed.slice(6)); + const choices = json.choices; + if (!Array.isArray(choices)) continue; + + for (const choice of choices) { + const delta = choice.delta; + if (!delta) continue; + if (typeof delta.reasoning_text === "string" && delta.reasoning_text) { + parts.push(delta.reasoning_text); + } + if (typeof delta.thinking === "string" && delta.thinking) { + parts.push(delta.thinking); + } + } + } catch { + continue; + } + } + } + + return parts.join(""); +} + +// --------------------------------------------------------------------------- +// Extraction: tool calls (mirrors shared/har-parser extractToolCalls logic) +// --------------------------------------------------------------------------- +function extractToolCallsFromHar(har: HarFile): ToolCall[] { + const toolCalls: Map = new Map(); + const toolResponses: Map = new Map(); + + for (const entry of har.log.entries) { + const responseBody = getResponseBody(entry); + if (responseBody) { + extractFromResponseBody(responseBody, entry.startedDateTime, toolCalls); + } + + const requestBody = entry.request?.postData?.text; + if (requestBody) { + extractToolResponses(requestBody, toolResponses); + } + } + + for (const [id, response] of toolResponses) { + const tc = toolCalls.get(id); + if (tc) tc.response = response; + } + + return Array.from(toolCalls.values()); +} + +function extractFromResponseBody( + body: string, + timestamp: string, + toolCalls: Map, +): void { + // Try non-streaming first + try { + const json = JSON.parse(body); + const choices = json.choices; + if (Array.isArray(choices)) { + for (const choice of choices) { + const msg = choice.message; + if (!msg?.tool_calls) continue; + for (const tc of msg.tool_calls) { + if (!tc.id || toolCalls.has(tc.id)) continue; + let args: Record = {}; + try { args = JSON.parse(tc.function?.arguments ?? "{}"); } catch { args = { _raw: tc.function?.arguments }; } + toolCalls.set(tc.id, { id: tc.id, name: tc.function?.name || "unknown", arguments: args, timestamp }); + } + } + return; + } + } catch { /* streaming */ } + + // SSE streaming + const partialCalls: Map = new Map(); + const indexToId: Map = new Map(); + let nextAutoIndex = 0; + + for (const line of body.split("\n")) { + const trimmed = line.trim(); + if (!trimmed.startsWith("data: ") || trimmed === "data: [DONE]") continue; + try { + const json = JSON.parse(trimmed.slice(6)); + const choices = json.choices; + if (!Array.isArray(choices)) continue; + for (const choice of choices) { + const delta = choice.delta; + if (!delta?.tool_calls) continue; + for (const tc of delta.tool_calls) { + if (tc.id) { + partialCalls.set(tc.id, { name: tc.function?.name || "", arguments: tc.function?.arguments || "" }); + const idx = tc.index ?? nextAutoIndex; + indexToId.set(idx, tc.id); + nextAutoIndex = idx + 1; + } else if (tc.index !== undefined) { + const id = indexToId.get(tc.index); + if (id) { + const partial = partialCalls.get(id); + if (partial && tc.function?.arguments) partial.arguments += tc.function.arguments; + } + } + } + } + } catch { continue; } + } + + for (const [id, partial] of partialCalls) { + if (toolCalls.has(id)) continue; + let args: Record = {}; + try { args = JSON.parse(partial.arguments); } catch { args = { _raw: partial.arguments }; } + toolCalls.set(id, { id, name: partial.name, arguments: args, timestamp }); + } +} + +function extractToolResponses(body: string, responses: Map): void { + try { + const json = JSON.parse(body); + if (!Array.isArray(json.messages)) return; + for (const msg of json.messages) { + if (msg.role === "tool" && msg.tool_call_id && msg.content) { + responses.set(msg.tool_call_id, typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content)); + } + } + } catch { /* skip */ } +} + +// --------------------------------------------------------------------------- +// Extracted data shape +// --------------------------------------------------------------------------- +export interface HarExtractedData { + thinkingContent: string; + toolCalls: ToolCall[]; +} + +// --------------------------------------------------------------------------- +// React hook: fetch HAR for a turn and extract thinking + tool calls +// --------------------------------------------------------------------------- +export function useHarExtraction(runId: string, iteration?: number, hasHar?: boolean): { + data: HarExtractedData | undefined; + isLoading: boolean; +} { + const { data: har, isLoading } = useQuery({ + queryKey: ["har-extraction", runId, iteration], + queryFn: async () => { + const url = api.harUrl(runId, iteration); + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }, + enabled: !!hasHar, + staleTime: Infinity, // HAR data is immutable once created + }); + + const data = useMemo(() => { + if (!har) return undefined; + return { + thinkingContent: extractThinkingFromHar(har), + toolCalls: extractToolCallsFromHar(har), + }; + }, [har]); + + return { data, isLoading }; +} diff --git a/apps/portal/src/pages/RunDetail.tsx b/apps/portal/src/pages/RunDetail.tsx index 75c222e18..da2a46b52 100644 --- a/apps/portal/src/pages/RunDetail.tsx +++ b/apps/portal/src/pages/RunDetail.tsx @@ -218,7 +218,7 @@ export function RunDetail() { {/* Conversation tab — chat-style view of agent/judge exchanges */} {run.turns && run.turns.length > 0 && ( - + )} diff --git a/apps/portal/src/types.ts b/apps/portal/src/types.ts index 334fc4591..b445a2073 100644 --- a/apps/portal/src/types.ts +++ b/apps/portal/src/types.ts @@ -29,7 +29,6 @@ export interface ConversationTurn { timestamp: string; criteriaResults?: CriterionResult[]; toolCalls?: ToolCall[]; - thinkingContent?: string; harUrl?: string; } diff --git a/apps/workers/coder-acp-copilot/src/index.ts b/apps/workers/coder-acp-copilot/src/index.ts index e9edc8bd6..970919d61 100644 --- a/apps/workers/coder-acp-copilot/src/index.ts +++ b/apps/workers/coder-acp-copilot/src/index.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { CodingAgentQueueProcessor, WorkerProcessor, WorkerProcessorOptions, WorkerResult, QueueProcessorConfig, LogEvent, TokenManagerClient, DevProxyClient, parseHarFile, extractToolCalls, extractThinkingContent, detectCliVersion } from "shared"; +import { CodingAgentQueueProcessor, WorkerProcessor, WorkerProcessorOptions, WorkerResult, QueueProcessorConfig, LogEvent, TokenManagerClient, DevProxyClient, parseHarFile, extractToolCalls, detectCliVersion } from "shared"; import { runACPSession } from "./acp-client.js"; import dotenv from "dotenv"; @@ -127,13 +127,11 @@ class CopilotProcessor implements WorkerProcessor { if (harFilePath) { const har = await parseHarFile(harFilePath); const toolCalls = extractToolCalls(har); - const thinkingContent = extractThinkingContent(har) || undefined; await log("info", `Extracted ${toolCalls.length} tool calls from HAR`, { toolCallCount: toolCalls.length, toolNames: toolCalls.map((tc) => tc.name), - ...(thinkingContent && { thinkingContentLength: thinkingContent.length }), }); - return { response, toolCalls, thinkingContent, harFilePath }; + return { response, toolCalls, harFilePath }; } else { await log("warn", "No HAR file found after DevProxy recording"); } diff --git a/packages/shared/src/har/har-parser.ts b/packages/shared/src/har/har-parser.ts index 8f0928853..5051f690f 100644 --- a/packages/shared/src/har/har-parser.ts +++ b/packages/shared/src/har/har-parser.ts @@ -130,7 +130,13 @@ function getResponseBody(entry: HarEntry): string | null { if (content.encoding === "base64") { try { - return Buffer.from(content.text, "base64").toString("utf-8"); + // Use globalThis.atob for browser+Node 16+ compatibility + // (avoids Node-only Buffer dependency) + return decodeURIComponent( + Array.from(globalThis.atob(content.text), (c) => + "%" + c.charCodeAt(0).toString(16).padStart(2, "0") + ).join("") + ); } catch { return null; } diff --git a/packages/shared/src/judge/multi-turn-loop.ts b/packages/shared/src/judge/multi-turn-loop.ts index b18646fbd..d851326e2 100644 --- a/packages/shared/src/judge/multi-turn-loop.ts +++ b/packages/shared/src/judge/multi-turn-loop.ts @@ -131,12 +131,10 @@ export async function runMultiTurnLoop( let codingResponse: string; let turnToolCalls: import("../har/types.js").ToolCall[] | undefined; let turnHarUrl: string | undefined; - let turnThinkingContent: string | undefined; try { const workerResult = await processor.processMessage(nextPrompt, iterLog, { model, mcpServerConfigs, skillConfigs }); codingResponse = workerResult.response; turnToolCalls = workerResult.toolCalls; - turnThinkingContent = workerResult.thinkingContent; // Upload HAR file to blob storage if available (sanitized to strip credentials) if (workerResult.harFilePath) { @@ -244,7 +242,6 @@ export async function runMultiTurnLoop( timestamp: new Date(), criteriaResults, ...(turnToolCalls && turnToolCalls.length > 0 && { toolCalls: turnToolCalls }), - ...(turnThinkingContent && { thinkingContent: turnThinkingContent }), ...(turnHarUrl && { harUrl: turnHarUrl }), }; turns.push(turn); diff --git a/packages/shared/src/types/types.ts b/packages/shared/src/types/types.ts index 037a7ce8f..1d2cfb384 100644 --- a/packages/shared/src/types/types.ts +++ b/packages/shared/src/types/types.ts @@ -18,7 +18,6 @@ export interface ConversationTurn { timestamp: Date; criteriaResults?: CriterionResult[]; // Per-criterion breakdown from DAG evaluation toolCalls?: ToolCall[]; // Tool calls extracted from DevProxy HAR for this turn - thinkingContent?: string; // Reasoning/thinking text extracted from HAR SSE streams harUrl?: string; // Blob storage URL to the HAR file for this turn } @@ -136,8 +135,6 @@ export interface WorkerResult { response: string; /** Tool calls extracted from DevProxy HAR capture (if available) */ toolCalls?: ToolCall[]; - /** Reasoning/thinking text extracted from HAR SSE streams (if available) */ - thinkingContent?: string; /** Path to the HAR file on disk (for upload to blob storage) */ harFilePath?: string; } From 8067b33b95ef18ff91e28f4356dcb72d5be050ac Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Wed, 25 Feb 2026 17:09:58 -0800 Subject: [PATCH 03/14] refactor: remove toolCalls from DB, extract client-side from HAR - Remove toolCalls from ConversationTurn, WorkerResult, and Run types - Worker still extracts + logs tool calls for observability, but no longer returns them - multi-turn-loop no longer persists toolCalls on turns - ConversationView relies solely on HAR extraction via useHarExtraction hook - API type definitions cleaned up (toolCalls removed from turns and top-level) --- apps/api/src/index.ts | 2 -- apps/portal/src/components/ConversationView.tsx | 4 ++-- apps/portal/src/types.ts | 2 -- apps/workers/coder-acp-copilot/src/index.ts | 3 ++- packages/shared/src/judge/multi-turn-loop.ts | 4 ---- packages/shared/src/types/types.ts | 4 ---- 6 files changed, 4 insertions(+), 15 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index b027e9cfc..5062e039a 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -215,7 +215,6 @@ interface RequestDocument { snapshotUrl: string; passed: boolean; timestamp: Date; - toolCalls?: Array<{ id: string; name: string; arguments: Record; response?: string; timestamp?: string }>; harUrl?: string; }>; personaInstructions?: string; @@ -226,7 +225,6 @@ interface RequestDocument { taskPromptId?: string; // Materialized UUIDv5 of scenario.task (FK → task-prompts._id) mcpServers?: string[]; // MCP server slugs selected for this run skillRevisions?: string[]; // Human-readable skill revision refs (source/skillName@commitHash) - toolCalls?: Array<{ id: string; name: string; arguments: Record; response?: string; timestamp?: string }>; harUrl?: string; } diff --git a/apps/portal/src/components/ConversationView.tsx b/apps/portal/src/components/ConversationView.tsx index 12518f6cc..1d0f5e22e 100644 --- a/apps/portal/src/components/ConversationView.tsx +++ b/apps/portal/src/components/ConversationView.tsx @@ -156,9 +156,9 @@ function TurnMessages({ turn, runId }: { turn: ConversationTurn; runId: string } const hasHar = !!turn.harUrl; const { data: harData, isLoading: harLoading } = useHarExtraction(runId, turn.iteration, hasHar); - // Use HAR-extracted data when available, fall back to DB-stored data + // Thinking and tool calls are extracted client-side from HAR (not stored in DB) const thinkingContent = harData?.thinkingContent || undefined; - const toolCalls = harData?.toolCalls ?? turn.toolCalls ?? []; + const toolCalls = harData?.toolCalls ?? []; return ( <> diff --git a/apps/portal/src/types.ts b/apps/portal/src/types.ts index b445a2073..d24d1f9de 100644 --- a/apps/portal/src/types.ts +++ b/apps/portal/src/types.ts @@ -28,7 +28,6 @@ export interface ConversationTurn { passed: boolean; timestamp: string; criteriaResults?: CriterionResult[]; - toolCalls?: ToolCall[]; harUrl?: string; } @@ -77,7 +76,6 @@ export interface Run { mcpServers?: string[]; skills?: string[]; skillRevisions?: string[]; - toolCalls?: ToolCall[]; harUrl?: string; } diff --git a/apps/workers/coder-acp-copilot/src/index.ts b/apps/workers/coder-acp-copilot/src/index.ts index 970919d61..f6a10a91f 100644 --- a/apps/workers/coder-acp-copilot/src/index.ts +++ b/apps/workers/coder-acp-copilot/src/index.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { CodingAgentQueueProcessor, WorkerProcessor, WorkerProcessorOptions, WorkerResult, QueueProcessorConfig, LogEvent, TokenManagerClient, DevProxyClient, parseHarFile, extractToolCalls, detectCliVersion } from "shared"; +// NOTE: extractToolCalls is used for server-side logging only; tool calls are NOT stored in the DB import { runACPSession } from "./acp-client.js"; import dotenv from "dotenv"; @@ -131,7 +132,7 @@ class CopilotProcessor implements WorkerProcessor { toolCallCount: toolCalls.length, toolNames: toolCalls.map((tc) => tc.name), }); - return { response, toolCalls, harFilePath }; + return { response, harFilePath }; } else { await log("warn", "No HAR file found after DevProxy recording"); } diff --git a/packages/shared/src/judge/multi-turn-loop.ts b/packages/shared/src/judge/multi-turn-loop.ts index d851326e2..657d09820 100644 --- a/packages/shared/src/judge/multi-turn-loop.ts +++ b/packages/shared/src/judge/multi-turn-loop.ts @@ -129,12 +129,10 @@ export async function runMultiTurnLoop( // Step 1: Call the coding agent await iterLog("info", "Calling coding agent..."); let codingResponse: string; - let turnToolCalls: import("../har/types.js").ToolCall[] | undefined; let turnHarUrl: string | undefined; try { const workerResult = await processor.processMessage(nextPrompt, iterLog, { model, mcpServerConfigs, skillConfigs }); codingResponse = workerResult.response; - turnToolCalls = workerResult.toolCalls; // Upload HAR file to blob storage if available (sanitized to strip credentials) if (workerResult.harFilePath) { @@ -164,7 +162,6 @@ export async function runMultiTurnLoop( await iterLog("info", "Coding agent completed", { responseLength: codingResponse.length, - toolCallCount: turnToolCalls?.length ?? 0, }); // Step 2: Snapshot workspace to blob storage @@ -241,7 +238,6 @@ export async function runMultiTurnLoop( passed: judgePassed, timestamp: new Date(), criteriaResults, - ...(turnToolCalls && turnToolCalls.length > 0 && { toolCalls: turnToolCalls }), ...(turnHarUrl && { harUrl: turnHarUrl }), }; turns.push(turn); diff --git a/packages/shared/src/types/types.ts b/packages/shared/src/types/types.ts index 1d2cfb384..aad96d79a 100644 --- a/packages/shared/src/types/types.ts +++ b/packages/shared/src/types/types.ts @@ -17,7 +17,6 @@ export interface ConversationTurn { passed: boolean; timestamp: Date; criteriaResults?: CriterionResult[]; // Per-criterion breakdown from DAG evaluation - toolCalls?: ToolCall[]; // Tool calls extracted from DevProxy HAR for this turn harUrl?: string; // Blob storage URL to the HAR file for this turn } @@ -104,7 +103,6 @@ export interface RequestDocument { mcpServers?: string[]; // MCP server slugs selected for this run skillRevisions?: string[]; // Skill revision refs (e.g. "vercel-labs/agent-skills/my-skill@a1b2c3d") agentVersion?: string; // Coding agent binary version (e.g. "@github/copilot@0.0.415") - toolCalls?: ToolCall[]; // Tool calls extracted from DevProxy HAR (one-shot) harUrl?: string; // Blob storage URL to the HAR file (one-shot) } @@ -133,8 +131,6 @@ export interface WorkerProcessorOptions { export interface WorkerResult { /** The coding agent's text response */ response: string; - /** Tool calls extracted from DevProxy HAR capture (if available) */ - toolCalls?: ToolCall[]; /** Path to the HAR file on disk (for upload to blob storage) */ harFilePath?: string; } From 0cdc308c5c37a86995d5aec85e6ad8bd6ecb65c0 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Wed, 25 Feb 2026 17:14:09 -0800 Subject: [PATCH 04/14] fix: remove remaining toolCalls references from queue-processor, TurnTimeline, RunDetail --- apps/portal/src/components/TurnTimeline.tsx | 6 +----- apps/portal/src/pages/RunDetail.tsx | 7 ++----- packages/shared/src/queue/queue-processor.ts | 3 +-- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/apps/portal/src/components/TurnTimeline.tsx b/apps/portal/src/components/TurnTimeline.tsx index c65e58d78..fa75df25b 100644 --- a/apps/portal/src/components/TurnTimeline.tsx +++ b/apps/portal/src/components/TurnTimeline.tsx @@ -68,11 +68,7 @@ export function TurnTimeline({ turns, runId }: TurnTimelineProps) { Incomplete )} - {turn.toolCalls && turn.toolCalls.length > 0 && ( - - {turn.toolCalls.length} tool call{turn.toolCalls.length !== 1 ? "s" : ""} - - )} +
{new Date(turn.timestamp).toLocaleString()} diff --git a/apps/portal/src/pages/RunDetail.tsx b/apps/portal/src/pages/RunDetail.tsx index da2a46b52..90ddc26f4 100644 --- a/apps/portal/src/pages/RunDetail.tsx +++ b/apps/portal/src/pages/RunDetail.tsx @@ -118,7 +118,7 @@ export function RunDetail() { } const isV2 = run.scenario?.version === "v2"; - const hasHarData = !!(run.harUrl || run.turns?.some(t => t.harUrl || (t.toolCalls && t.toolCalls.length > 0))); + const hasHarData = !!(run.harUrl || run.turns?.some(t => t.harUrl)); return (
@@ -488,10 +488,7 @@ export function RunDetail() { {/* Tool calls summary across all turns */} {(() => { - const allToolCalls = [ - ...(run.toolCalls ?? []), - ...(run.turns?.flatMap(t => (t.toolCalls ?? []).map(tc => ({ ...tc, _iteration: t.iteration }))) ?? []), - ]; + const allToolCalls: { id: string; name: string; arguments: Record; response?: string; timestamp?: string; _iteration?: number }[] = []; if (allToolCalls.length === 0) return (

No tool calls captured. HAR file may still be available for download.

diff --git a/packages/shared/src/queue/queue-processor.ts b/packages/shared/src/queue/queue-processor.ts index a0132f05f..0dca8c1dc 100644 --- a/packages/shared/src/queue/queue-processor.ts +++ b/packages/shared/src/queue/queue-processor.ts @@ -165,14 +165,13 @@ export class CodingAgentQueueProcessor extends BaseQueueProcessor 0 && { toolCalls: workerResult.toolCalls }), ...(harUrl && { harUrl }), updatedAt: new Date(), }, From 1421c58ee9c1a7d5766adbed22906a2744a0ac13 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Wed, 25 Feb 2026 17:48:24 -0800 Subject: [PATCH 05/14] feat: unified HAR cache + client-side Tool Calls tab extraction - Add useHarData generic hook: single source of truth for HAR fetching - All tabs (Conversation, Network, Tool Calls) share query key ["har", runId, iteration] - Refactor useHarExtraction and HarNetworkViewer to use useHarData - Add useAllTurnsToolCalls hook: fetches all turn HARs via useQueries, aggregates tool calls - Extract ToolCallsTab component in RunDetail: replaces DB-driven IIFE with client-side HAR extraction - Tool calls table now includes Iteration column --- .../src/components/HarNetworkViewer.tsx | 12 +- apps/portal/src/hooks/useHarExtraction.ts | 95 +++++++- apps/portal/src/pages/RunDetail.tsx | 230 ++++++++++-------- 3 files changed, 214 insertions(+), 123 deletions(-) diff --git a/apps/portal/src/components/HarNetworkViewer.tsx b/apps/portal/src/components/HarNetworkViewer.tsx index 537ef74b5..b0c4314df 100644 --- a/apps/portal/src/components/HarNetworkViewer.tsx +++ b/apps/portal/src/components/HarNetworkViewer.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { useState, useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useHarData } from "@/hooks/useHarExtraction"; import { api } from "@/lib/api"; import { Card, CardContent } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; @@ -124,15 +124,7 @@ export function HarNetworkViewer({ runId, iteration }: HarNetworkViewerProps) { const [filter, setFilter] = useState(""); const [selectedIdx, setSelectedIdx] = useState(null); - const { data: har, isLoading, error } = useQuery({ - queryKey: ["har", runId, iteration], - queryFn: async () => { - const url = api.harUrl(runId, iteration); - const res = await fetch(url); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return res.json(); - }, - }); + const { data: har, isLoading, error } = useHarData(runId, iteration); const entries = useMemo(() => { if (!har) return []; diff --git a/apps/portal/src/hooks/useHarExtraction.ts b/apps/portal/src/hooks/useHarExtraction.ts index db3ffa3bc..ebf50f5d5 100644 --- a/apps/portal/src/hooks/useHarExtraction.ts +++ b/apps/portal/src/hooks/useHarExtraction.ts @@ -1,10 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueries } from "@tanstack/react-query"; import { useMemo } from "react"; import { api } from "@/lib/api"; -import type { ToolCall } from "@/types"; +import type { ToolCall, ConversationTurn } from "@/types"; // --------------------------------------------------------------------------- // Minimal HAR types (matches HarNetworkViewer's types) @@ -195,23 +195,32 @@ export interface HarExtractedData { } // --------------------------------------------------------------------------- -// React hook: fetch HAR for a turn and extract thinking + tool calls +// Shared HAR data hook — single source of truth for fetching HAR JSON. +// All tabs (Conversation, Network, Tool Calls) share this cache. +// Generic so each consumer can use their own HAR type definition. // --------------------------------------------------------------------------- -export function useHarExtraction(runId: string, iteration?: number, hasHar?: boolean): { - data: HarExtractedData | undefined; - isLoading: boolean; -} { - const { data: har, isLoading } = useQuery({ - queryKey: ["har-extraction", runId, iteration], +export function useHarData(runId: string, iteration?: number, enabled = true) { + return useQuery({ + queryKey: ["har", runId, iteration], queryFn: async () => { const url = api.harUrl(runId, iteration); const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }, - enabled: !!hasHar, + enabled, staleTime: Infinity, // HAR data is immutable once created }); +} + +// --------------------------------------------------------------------------- +// React hook: fetch HAR for a turn and extract thinking + tool calls +// --------------------------------------------------------------------------- +export function useHarExtraction(runId: string, iteration?: number, hasHar?: boolean): { + data: HarExtractedData | undefined; + isLoading: boolean; +} { + const { data: har, isLoading } = useHarData(runId, iteration, !!hasHar); const data = useMemo(() => { if (!har) return undefined; @@ -223,3 +232,69 @@ export function useHarExtraction(runId: string, iteration?: number, hasHar?: boo return { data, isLoading }; } + +// --------------------------------------------------------------------------- +// React hook: fetch HARs for ALL turns and aggregate tool calls +// --------------------------------------------------------------------------- +export interface AggregatedToolCall extends ToolCall { + _iteration?: number; +} + +export function useAllTurnsToolCalls( + runId: string, + turns?: ConversationTurn[], + topLevelHarUrl?: string, +): { allToolCalls: AggregatedToolCall[]; isLoading: boolean } { + // Build query descriptors: one per turn with harUrl + optional top-level (one-shot) + const queries = useMemo(() => { + const q: { iteration: number | undefined; enabled: boolean }[] = []; + + // One-shot run: top-level HAR, no iteration + if (topLevelHarUrl && (!turns || turns.length === 0)) { + q.push({ iteration: undefined, enabled: true }); + } + + // Multi-turn: one query per turn with harUrl + if (turns) { + for (const t of turns) { + if (t.harUrl) { + q.push({ iteration: t.iteration, enabled: true }); + } + } + } + + return q; + }, [turns, topLevelHarUrl]); + + const results = useQueries({ + queries: queries.map((q) => ({ + queryKey: ["har", runId, q.iteration], + queryFn: async () => { + const url = api.harUrl(runId, q.iteration); + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json() as Promise; + }, + enabled: q.enabled, + staleTime: Infinity, + })), + }); + + const allToolCalls = useMemo(() => { + const out: AggregatedToolCall[] = []; + for (let i = 0; i < results.length; i++) { + const har = results[i].data; + if (!har) continue; + const tcs = extractToolCallsFromHar(har); + const iteration = queries[i].iteration; + for (const tc of tcs) { + out.push({ ...tc, _iteration: iteration }); + } + } + return out; + }, [results, queries]); + + const isLoading = results.some((r) => r.isLoading); + + return { allToolCalls, isLoading }; +} diff --git a/apps/portal/src/pages/RunDetail.tsx b/apps/portal/src/pages/RunDetail.tsx index 90ddc26f4..d57769460 100644 --- a/apps/portal/src/pages/RunDetail.tsx +++ b/apps/portal/src/pages/RunDetail.tsx @@ -18,10 +18,11 @@ import { CriteriaGraphView } from "@/components/CriteriaGraphView"; import { HarNetworkViewer } from "@/components/HarNetworkViewer"; import { ConversationView } from "@/components/ConversationView"; import { useLogStream } from "@/hooks/use-log-stream"; +import { useAllTurnsToolCalls } from "@/hooks/useHarExtraction"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeRaw from "rehype-raw"; -import { ArrowLeft, Copy, Check, Sparkles, CheckCircle2, XCircle, MinusCircle, FileText, Plus, Download } from "lucide-react"; +import { ArrowLeft, Copy, Check, Sparkles, CheckCircle2, XCircle, MinusCircle, FileText, Plus, Download, Loader2 } from "lucide-react"; import { formatDate, formatId } from "@/lib/utils"; import { useState } from "react"; import { toast } from "sonner"; @@ -471,108 +472,7 @@ export function RunDetail() { {/* Tool Calls tab — HAR captures & tool calls summary */} {hasHarData && ( -
-

Network Captures

- {run.harUrl && ( - - )} -
- - {/* Tool calls summary across all turns */} - {(() => { - const allToolCalls: { id: string; name: string; arguments: Record; response?: string; timestamp?: string; _iteration?: number }[] = []; - if (allToolCalls.length === 0) return ( -
-

No tool calls captured. HAR file may still be available for download.

-
- ); - - // Group by tool name for summary - const byName = new Map(); - for (const tc of allToolCalls) { - byName.set(tc.name, (byName.get(tc.name) ?? 0) + 1); - } - - return ( -
- {/* Summary badges */} -
- {Array.from(byName.entries()) - .sort((a, b) => b[1] - a[1]) - .map(([name, count]) => ( - - {name} ×{count} - - ))} -
- - {/* Full tool calls table */} - - -
- - - - - - - - - - {allToolCalls.map((tc, idx) => ( - - - - - - ))} - -
ToolArgumentsTime
- - {tc.name} - - -
-                                    {JSON.stringify(tc.arguments)}
-                                  
-
- {tc.timestamp ? new Date(tc.timestamp).toLocaleTimeString() : "–"} -
-
-
-
- - {/* Per-turn HAR download links */} - {run.turns && run.turns.some(t => t.harUrl) && ( -
-

Per-Turn HAR Files

-
- {run.turns.filter(t => t.harUrl).map(t => ( - - ))} -
-
- )} -
- ); - })()} +
)} @@ -616,3 +516,127 @@ function HarIterationTabs({ runId, turns }: { runId: string; turns: { iteration:
); } + +// --------------------------------------------------------------------------- +// Tool Calls tab — extracts tool calls from HAR client-side +// --------------------------------------------------------------------------- + +import type { ConversationTurn } from "@/types"; + +function ToolCallsTab({ runId, turns, harUrl }: { runId: string; turns?: ConversationTurn[]; harUrl?: string }) { + const { allToolCalls, isLoading } = useAllTurnsToolCalls(runId, turns, harUrl); + + // Group by tool name for summary + const byName = new Map(); + for (const tc of allToolCalls) { + byName.set(tc.name, (byName.get(tc.name) ?? 0) + 1); + } + + return ( + <> +
+

Network Captures

+ {harUrl && ( + + )} +
+ + {isLoading && ( +
+ + Extracting tool calls from HAR… +
+ )} + + {!isLoading && allToolCalls.length === 0 && ( +
+

No tool calls captured. HAR file may still be available for download.

+
+ )} + + {allToolCalls.length > 0 && ( +
+ {/* Summary badges */} +
+ {Array.from(byName.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([name, count]) => ( + + {name} ×{count} + + ))} +
+ + {/* Full tool calls table */} + + +
+ + + + + + + + + + + {allToolCalls.map((tc, idx) => ( + + + + + + + ))} + +
IterationToolArgumentsTime
+ {tc._iteration ?? "–"} + + + {tc.name} + + +
+                            {JSON.stringify(tc.arguments)}
+                          
+
+ {tc.timestamp ? new Date(tc.timestamp).toLocaleTimeString() : "–"} +
+
+
+
+ + {/* Per-turn HAR download links */} + {turns && turns.some(t => t.harUrl) && ( +
+

Per-Turn HAR Files

+
+ {turns.filter(t => t.harUrl).map(t => ( + + ))} +
+
+ )} +
+ )} + + ); +} From f71a87ea88a7bfb55500dbb472edfd1d0d365b71 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Wed, 25 Feb 2026 18:27:49 -0800 Subject: [PATCH 06/14] feat(portal): chronological interleaving of thinking, content, and tool calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor HAR extraction to produce ordered ConversationSegment[] instead of flat aggregated fields. Each HAR entry (= one LLM roundtrip) emits segments in order: thinking → content → tool_calls. Across entries, this naturally produces chronological interleaving. - Add ConversationSegment union type (thinking | content | tool_calls) - Replace extractThinkingFromHar + extractToolCallsFromHar with extractChronologicalSegments (entry-level extraction) - Derive thinkingContent/toolCalls from segments for backward compat - ConversationView renders segments in order via SegmentBlock component - Falls back to DB codingAgentResponse when no HAR content segments --- .../src/components/ConversationView.tsx | 159 ++++++------ apps/portal/src/hooks/useHarExtraction.ts | 231 ++++++++++-------- 2 files changed, 220 insertions(+), 170 deletions(-) diff --git a/apps/portal/src/components/ConversationView.tsx b/apps/portal/src/components/ConversationView.tsx index 1d0f5e22e..5bc66641a 100644 --- a/apps/portal/src/components/ConversationView.tsx +++ b/apps/portal/src/components/ConversationView.tsx @@ -10,7 +10,7 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeRaw from "rehype-raw"; import type { ConversationTurn, ToolCall } from "@/types"; -import { useHarExtraction } from "@/hooks/useHarExtraction"; +import { useHarExtraction, type ConversationSegment } from "@/hooks/useHarExtraction"; interface ConversationViewProps { turns: ConversationTurn[]; @@ -156,9 +156,8 @@ function TurnMessages({ turn, runId }: { turn: ConversationTurn; runId: string } const hasHar = !!turn.harUrl; const { data: harData, isLoading: harLoading } = useHarExtraction(runId, turn.iteration, hasHar); - // Thinking and tool calls are extracted client-side from HAR (not stored in DB) - const thinkingContent = harData?.thinkingContent || undefined; - const toolCalls = harData?.toolCalls ?? []; + const segments = harData?.segments ?? []; + const hasContentSegment = segments.some((s) => s.type === "content"); return ( <> @@ -174,77 +173,24 @@ function TurnMessages({ turn, runId }: { turn: ConversationTurn; runId: string }
- {/* Thinking content — collapsible, above agent response */} - {thinkingContent && ( -
-
- - - -
- {thinkingContent} -
-
-
-
-
-
- )} - - {/* Agent response — right aligned */} -
- - -
- - Coding Agent - - {new Date(turn.timestamp).toLocaleTimeString()} - -
-
- {turn.codingAgentResponse} -
-
-
-
- - {/* Tool calls — inline, collapsible */} + {/* Loading indicator */} {harLoading && hasHar && (
- Loading tool calls… + Loading HAR data…
)} - {toolCalls.length > 0 && ( -
-
- -
- {toolCalls.map((tc, idx) => ( - - ))} -
-
-
-
+ + {/* Chronological segments from HAR */} + {segments.map((segment, idx) => ( + + ))} + + {/* Fallback: show DB agent response if no content segments from HAR */} + {!hasContentSegment && !harLoading && ( + )} {/* Judge feedback — left aligned */} @@ -289,3 +235,80 @@ function TurnMessages({ turn, runId }: { turn: ConversationTurn; runId: string } ); } + +/** Render a single chronological segment */ +function SegmentBlock({ segment, turn }: { segment: ConversationSegment; turn: ConversationTurn }) { + switch (segment.type) { + case "thinking": + return ( +
+
+ + + +
+ {segment.content} +
+
+
+
+
+
+ ); + + case "content": + return ; + + case "tool_calls": + return ( +
+
+ +
+ {segment.toolCalls.map((tc, idx) => ( + + ))} +
+
+
+
+ ); + } +} + +/** Agent response card — right aligned */ +function AgentResponseBlock({ content, turn }: { content: string; turn: ConversationTurn }) { + return ( +
+ + +
+ + Coding Agent + + {new Date(turn.timestamp).toLocaleTimeString()} + +
+
+ {content} +
+
+
+
+ ); +} diff --git a/apps/portal/src/hooks/useHarExtraction.ts b/apps/portal/src/hooks/useHarExtraction.ts index ebf50f5d5..ba068cb20 100644 --- a/apps/portal/src/hooks/useHarExtraction.ts +++ b/apps/portal/src/hooks/useHarExtraction.ts @@ -28,6 +28,14 @@ interface HarFile { log: { entries: HarEntry[] }; } +// --------------------------------------------------------------------------- +// Chronological segment types +// --------------------------------------------------------------------------- +export type ConversationSegment = + | { type: "thinking"; content: string } + | { type: "content"; content: string } + | { type: "tool_calls"; toolCalls: ToolCall[] }; + // --------------------------------------------------------------------------- // HAR body helpers // --------------------------------------------------------------------------- @@ -45,96 +53,66 @@ function getResponseBody(entry: HarEntry): string | null { } // --------------------------------------------------------------------------- -// Extraction: thinking / reasoning content +// Collect tool responses from request bodies (global across all entries) // --------------------------------------------------------------------------- -function extractThinkingFromHar(har: HarFile): string { - const parts: string[] = []; - - for (const entry of har.log.entries) { - const body = getResponseBody(entry); - if (!body) continue; - - for (const line of body.split("\n")) { - const trimmed = line.trim(); - if (!trimmed.startsWith("data: ") || trimmed === "data: [DONE]") continue; - - try { - const json = JSON.parse(trimmed.slice(6)); - const choices = json.choices; - if (!Array.isArray(choices)) continue; - - for (const choice of choices) { - const delta = choice.delta; - if (!delta) continue; - if (typeof delta.reasoning_text === "string" && delta.reasoning_text) { - parts.push(delta.reasoning_text); - } - if (typeof delta.thinking === "string" && delta.thinking) { - parts.push(delta.thinking); - } - } - } catch { - continue; +function extractToolResponses(body: string, responses: Map): void { + try { + const json = JSON.parse(body); + if (!Array.isArray(json.messages)) return; + for (const msg of json.messages) { + if (msg.role === "tool" && msg.tool_call_id && msg.content) { + responses.set(msg.tool_call_id, typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content)); } } - } - - return parts.join(""); + } catch { /* skip */ } } // --------------------------------------------------------------------------- -// Extraction: tool calls (mirrors shared/har-parser extractToolCalls logic) +// Extract segments from a single HAR entry's response body. +// Each entry = one LLM roundtrip. Within an entry, the order is: +// thinking → content → tool_calls // --------------------------------------------------------------------------- -function extractToolCallsFromHar(har: HarFile): ToolCall[] { - const toolCalls: Map = new Map(); - const toolResponses: Map = new Map(); - - for (const entry of har.log.entries) { - const responseBody = getResponseBody(entry); - if (responseBody) { - extractFromResponseBody(responseBody, entry.startedDateTime, toolCalls); - } - - const requestBody = entry.request?.postData?.text; - if (requestBody) { - extractToolResponses(requestBody, toolResponses); - } - } - - for (const [id, response] of toolResponses) { - const tc = toolCalls.get(id); - if (tc) tc.response = response; - } - - return Array.from(toolCalls.values()); -} - -function extractFromResponseBody( +function extractEntrySegments( body: string, timestamp: string, - toolCalls: Map, -): void { - // Try non-streaming first + toolResponses: Map, +): ConversationSegment[] { + const segments: ConversationSegment[] = []; + const thinkingParts: string[] = []; + const contentParts: string[] = []; + + // --- Try non-streaming (single JSON response) first --- try { const json = JSON.parse(body); const choices = json.choices; if (Array.isArray(choices)) { for (const choice of choices) { const msg = choice.message; - if (!msg?.tool_calls) continue; - for (const tc of msg.tool_calls) { - if (!tc.id || toolCalls.has(tc.id)) continue; - let args: Record = {}; - try { args = JSON.parse(tc.function?.arguments ?? "{}"); } catch { args = { _raw: tc.function?.arguments }; } - toolCalls.set(tc.id, { id: tc.id, name: tc.function?.name || "unknown", arguments: args, timestamp }); + if (!msg) continue; + if (typeof msg.content === "string" && msg.content) { + contentParts.push(msg.content); + } + if (Array.isArray(msg.tool_calls)) { + const tcs: ToolCall[] = []; + for (const tc of msg.tool_calls) { + if (!tc.id) continue; + let args: Record = {}; + try { args = JSON.parse(tc.function?.arguments ?? "{}"); } catch { args = { _raw: tc.function?.arguments }; } + const response = toolResponses.get(tc.id); + tcs.push({ id: tc.id, name: tc.function?.name || "unknown", arguments: args, timestamp, ...(response && { response }) }); + } + if (contentParts.length > 0) segments.push({ type: "content", content: contentParts.join("") }); + if (tcs.length > 0) segments.push({ type: "tool_calls", toolCalls: tcs }); + } else if (contentParts.length > 0) { + segments.push({ type: "content", content: contentParts.join("") }); } } - return; + return segments; } - } catch { /* streaming */ } + } catch { /* streaming — fall through */ } - // SSE streaming - const partialCalls: Map = new Map(); + // --- SSE streaming --- + const partialToolCalls: Map = new Map(); const indexToId: Map = new Map(); let nextAutoIndex = 0; @@ -147,18 +125,29 @@ function extractFromResponseBody( if (!Array.isArray(choices)) continue; for (const choice of choices) { const delta = choice.delta; - if (!delta?.tool_calls) continue; - for (const tc of delta.tool_calls) { - if (tc.id) { - partialCalls.set(tc.id, { name: tc.function?.name || "", arguments: tc.function?.arguments || "" }); - const idx = tc.index ?? nextAutoIndex; - indexToId.set(idx, tc.id); - nextAutoIndex = idx + 1; - } else if (tc.index !== undefined) { - const id = indexToId.get(tc.index); - if (id) { - const partial = partialCalls.get(id); - if (partial && tc.function?.arguments) partial.arguments += tc.function.arguments; + if (!delta) continue; + + // Thinking + if (typeof delta.reasoning_text === "string" && delta.reasoning_text) thinkingParts.push(delta.reasoning_text); + if (typeof delta.thinking === "string" && delta.thinking) thinkingParts.push(delta.thinking); + + // Content + if (typeof delta.content === "string" && delta.content) contentParts.push(delta.content); + + // Tool calls + if (delta.tool_calls) { + for (const tc of delta.tool_calls) { + if (tc.id) { + partialToolCalls.set(tc.id, { name: tc.function?.name || "", arguments: tc.function?.arguments || "" }); + const idx = tc.index ?? nextAutoIndex; + indexToId.set(idx, tc.id); + nextAutoIndex = idx + 1; + } else if (tc.index !== undefined) { + const id = indexToId.get(tc.index); + if (id) { + const partial = partialToolCalls.get(id); + if (partial && tc.function?.arguments) partial.arguments += tc.function.arguments; + } } } } @@ -166,32 +155,61 @@ function extractFromResponseBody( } catch { continue; } } - for (const [id, partial] of partialCalls) { - if (toolCalls.has(id)) continue; - let args: Record = {}; - try { args = JSON.parse(partial.arguments); } catch { args = { _raw: partial.arguments }; } - toolCalls.set(id, { id, name: partial.name, arguments: args, timestamp }); + // Build segments in chronological order: thinking → content → tool_calls + if (thinkingParts.length > 0) { + segments.push({ type: "thinking", content: thinkingParts.join("") }); + } + if (contentParts.length > 0) { + segments.push({ type: "content", content: contentParts.join("") }); + } + if (partialToolCalls.size > 0) { + const tcs: ToolCall[] = []; + for (const [id, partial] of partialToolCalls) { + let args: Record = {}; + try { args = JSON.parse(partial.arguments); } catch { args = { _raw: partial.arguments }; } + const response = toolResponses.get(id); + tcs.push({ id, name: partial.name, arguments: args, timestamp, ...(response && { response }) }); + } + segments.push({ type: "tool_calls", toolCalls: tcs }); } + + return segments; } -function extractToolResponses(body: string, responses: Map): void { - try { - const json = JSON.parse(body); - if (!Array.isArray(json.messages)) return; - for (const msg of json.messages) { - if (msg.role === "tool" && msg.tool_call_id && msg.content) { - responses.set(msg.tool_call_id, typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content)); - } +// --------------------------------------------------------------------------- +// Main extraction: chronological segments across all HAR entries +// --------------------------------------------------------------------------- +function extractChronologicalSegments(har: HarFile): ConversationSegment[] { + // First pass: collect all tool responses from request bodies + const toolResponses = new Map(); + for (const entry of har.log.entries) { + const requestBody = entry.request?.postData?.text; + if (requestBody) { + extractToolResponses(requestBody, toolResponses); } - } catch { /* skip */ } + } + + // Second pass: extract segments per entry in chronological order + const segments: ConversationSegment[] = []; + for (const entry of har.log.entries) { + const body = getResponseBody(entry); + if (!body) continue; + segments.push(...extractEntrySegments(body, entry.startedDateTime, toolResponses)); + } + + return segments; } // --------------------------------------------------------------------------- // Extracted data shape // --------------------------------------------------------------------------- export interface HarExtractedData { + /** All thinking content concatenated (backward compat) */ thinkingContent: string; + /** All tool calls aggregated (backward compat for Tool Calls tab) */ toolCalls: ToolCall[]; + /** Chronological segments — interleaved thinking, content, and tool calls */ + segments: ConversationSegment[]; } // --------------------------------------------------------------------------- @@ -224,10 +242,16 @@ export function useHarExtraction(runId: string, iteration?: number, hasHar?: boo const data = useMemo(() => { if (!har) return undefined; - return { - thinkingContent: extractThinkingFromHar(har), - toolCalls: extractToolCallsFromHar(har), - }; + const segments = extractChronologicalSegments(har); + // Derive flat aggregates from segments for backward compat + const thinkingContent = segments + .filter((s): s is ConversationSegment & { type: "thinking" } => s.type === "thinking") + .map((s) => s.content) + .join(""); + const toolCalls = segments + .filter((s): s is ConversationSegment & { type: "tool_calls" } => s.type === "tool_calls") + .flatMap((s) => s.toolCalls); + return { thinkingContent, toolCalls, segments }; }, [har]); return { data, isLoading }; @@ -285,7 +309,10 @@ export function useAllTurnsToolCalls( for (let i = 0; i < results.length; i++) { const har = results[i].data; if (!har) continue; - const tcs = extractToolCallsFromHar(har); + const segments = extractChronologicalSegments(har); + const tcs = segments + .filter((s): s is ConversationSegment & { type: "tool_calls" } => s.type === "tool_calls") + .flatMap((s) => s.toolCalls); const iteration = queries[i].iteration; for (const tc of tcs) { out.push({ ...tc, _iteration: iteration }); From 77ae69e5293eb0b3d49ad087f04fd08af8d14981 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Wed, 25 Feb 2026 18:34:06 -0800 Subject: [PATCH 07/14] feat(portal): show timestamps on tool calls in conversation view --- .../src/components/ConversationView.tsx | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/portal/src/components/ConversationView.tsx b/apps/portal/src/components/ConversationView.tsx index 5bc66641a..b0ffb501d 100644 --- a/apps/portal/src/components/ConversationView.tsx +++ b/apps/portal/src/components/ConversationView.tsx @@ -74,6 +74,7 @@ function CollapsibleSection({ icon: Icon, iconClassName, count, + timestamp, defaultOpen = false, children, }: { @@ -81,6 +82,7 @@ function CollapsibleSection({ icon: React.ComponentType<{ className?: string }>; iconClassName?: string; count?: number; + timestamp?: string; defaultOpen?: boolean; children: React.ReactNode; }) { @@ -98,6 +100,11 @@ function CollapsibleSection({ {count !== undefined && ( {count} )} + {timestamp && ( + + {new Date(timestamp).toLocaleTimeString()} + + )} {open &&
{children}
}
@@ -126,11 +133,18 @@ function ToolCallInline({ tc }: { tc: ToolCall }) { {argsStr === "{}" ? "" : argsStr} )} - {hasResponse && ( - - has response - - )} + + {hasResponse && ( + + has response + + )} + {tc.timestamp && ( + + {new Date(tc.timestamp).toLocaleTimeString()} + + )} + {expanded && (
@@ -272,6 +286,7 @@ function SegmentBlock({ segment, turn }: { segment: ConversationSegment; turn: C icon={Wrench} iconClassName="text-blue-500" count={segment.toolCalls.length} + timestamp={segment.toolCalls[0]?.timestamp} defaultOpen={segment.toolCalls.length <= 5} >
From 6d1f0955623efc57125fb92b8c3349dd0ad6e187 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Wed, 25 Feb 2026 18:37:06 -0800 Subject: [PATCH 08/14] feat(portal): show 'no response' badge only for tool calls without a response --- apps/portal/src/components/ConversationView.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/portal/src/components/ConversationView.tsx b/apps/portal/src/components/ConversationView.tsx index b0ffb501d..adfb6bf4f 100644 --- a/apps/portal/src/components/ConversationView.tsx +++ b/apps/portal/src/components/ConversationView.tsx @@ -134,9 +134,9 @@ function ToolCallInline({ tc }: { tc: ToolCall }) { )} - {hasResponse && ( - - has response + {!hasResponse && ( + + no response )} {tc.timestamp && ( From a314dd1c550aaeccbeb32ffc33adb2c673abadc6 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Wed, 25 Feb 2026 18:38:58 -0800 Subject: [PATCH 09/14] feat(portal): always show tool call args inline with CSS truncation --- apps/portal/src/components/ConversationView.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/portal/src/components/ConversationView.tsx b/apps/portal/src/components/ConversationView.tsx index adfb6bf4f..961176547 100644 --- a/apps/portal/src/components/ConversationView.tsx +++ b/apps/portal/src/components/ConversationView.tsx @@ -116,21 +116,21 @@ function ToolCallInline({ tc }: { tc: ToolCall }) { const [expanded, setExpanded] = useState(false); const hasResponse = !!tc.response; const argsStr = JSON.stringify(tc.arguments, null, 2); - const isLargeArgs = argsStr.length > 80; + const argsOneLine = JSON.stringify(tc.arguments); return (