diff --git a/apps/portal/src/hooks/useHarExtraction.test.tsx b/apps/portal/src/hooks/useHarExtraction.test.tsx
new file mode 100644
index 000000000..d9d410995
--- /dev/null
+++ b/apps/portal/src/hooks/useHarExtraction.test.tsx
@@ -0,0 +1,64 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+// @vitest-environment happy-dom
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, renderHook, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { ReactNode } from "react";
+import type { ConversationTurn } from "@/types";
+import { api } from "@/lib/api";
+import { apiClient } from "@/lib/api-client";
+import { useAllTurnsToolCalls } from "./useHarExtraction";
+
+vi.mock("@/lib/api", () => ({
+ api: {
+ toolCallsUrl: vi.fn(() => "/api/v1/requests/request-1/tool-calls?iteration=1"),
+ runToolCallsUrl: vi.fn(() => "/api/v1/requests/request-1/runs/attempt-1/tool-calls?iteration=1"),
+ },
+}));
+vi.mock("@/lib/api-client", () => ({ apiClient: vi.fn() }));
+
+function wrapper({ children }: { children: ReactNode }) {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return {children};
+}
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+describe("useAllTurnsToolCalls", () => {
+ it("reads only the JSONL artifact for a historical attempt", async () => {
+ const turns = [{
+ iteration: 1,
+ toolCallsUrl: "https://blob/tool-calls.jsonl",
+ harUrl: "https://blob/run.har",
+ toolCalls: [{ id: "old", name: "legacy", arguments: {} }],
+ }] as ConversationTurn[];
+ vi.mocked(apiClient).mockResolvedValue({
+ ok: true,
+ text: async () => '{"id":"new","name":"read_file","arguments":{}}\n',
+ } as Response);
+
+ const { result } = renderHook(() => useAllTurnsToolCalls("request-1", turns, "attempt-1"), { wrapper });
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.allToolCalls).toEqual([{
+ id: "new", name: "read_file", arguments: {}, timestamp: "", _iteration: 1,
+ }]);
+ expect(api.runToolCallsUrl).toHaveBeenCalledWith("request-1", "attempt-1", 1);
+ expect(apiClient).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not fetch a HAR for turns without a tool-calls artifact", () => {
+ const turns = [{ iteration: 1, harUrl: "https://blob/run.har" }] as ConversationTurn[];
+
+ const { result } = renderHook(() => useAllTurnsToolCalls("request-1", turns), { wrapper });
+
+ expect(result.current.allToolCalls).toEqual([]);
+ expect(result.current.isLoading).toBe(false);
+ expect(apiClient).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/portal/src/hooks/useHarExtraction.ts b/apps/portal/src/hooks/useHarExtraction.ts
index 6830411e3..0b40af56f 100644
--- a/apps/portal/src/hooks/useHarExtraction.ts
+++ b/apps/portal/src/hooks/useHarExtraction.ts
@@ -7,7 +7,6 @@ import { api } from "@/lib/api";
import { apiClient } from "@/lib/api-client";
import type { ConversationTurn } from "@/types";
import {
- extractChronologicalSegments,
extractFromHar,
type ConversationSegment,
type HarExtractedData,
@@ -54,7 +53,7 @@ export function useHarExtraction(runId: string, iteration?: number, hasHar?: boo
}
// ---------------------------------------------------------------------------
-// React hook: fetch HARs for ALL turns and aggregate tool calls
+// React hook: fetch server-extracted tool calls for all turns
// ---------------------------------------------------------------------------
export interface AggregatedToolCall extends ToolCall {
_iteration?: number;
@@ -63,34 +62,14 @@ export interface AggregatedToolCall extends ToolCall {
export function useAllTurnsToolCalls(
runId: string,
turns?: ConversationTurn[],
- topLevelHarUrl?: string,
attemptRunId?: string,
): { allToolCalls: AggregatedToolCall[]; isLoading: boolean } {
- // Turns that already have pre-computed `toolCalls` (legacy inline shape)
- // don't need any blob fetch.
- const preComputed = useMemo(() => {
- const out: AggregatedToolCall[] = [];
- if (turns) {
- for (const t of turns) {
- if (t.toolCalls && t.toolCalls.length > 0) {
- for (const tc of t.toolCalls) {
- out.push({ ...tc, timestamp: tc.timestamp ?? "", _iteration: t.iteration });
- }
- }
- }
- }
- return out;
- }, [turns]);
-
- // New shape: per-iteration tool-calls.jsonl in blob storage. Fetched via
- // the API proxy (`/api/v1/requests/:id/tool-calls?iteration=N`) and parsed
- // line-by-line. Preferred over re-extracting from the HAR.
+ // Per-iteration tool-calls.jsonl is the source for the Tool Calls tab.
const jsonlQueries = useMemo(() => {
const q: { iteration: number }[] = [];
if (turns) {
for (const t of turns) {
- const hasInline = t.toolCalls && t.toolCalls.length > 0;
- if (!hasInline && t.toolCallsUrl) {
+ if (t.toolCallsUrl) {
q.push({ iteration: t.iteration });
}
}
@@ -115,48 +94,8 @@ export function useAllTurnsToolCalls(
})),
});
- // Legacy fallback: turns that have `harUrl` but neither inline `toolCalls`
- // nor a `toolCallsUrl` blob — re-extract from the HAR.
- 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 but WITHOUT pre-computed
- // toolCalls and WITHOUT a tool-calls JSONL blob.
- if (turns) {
- for (const t of turns) {
- const hasInline = t.toolCalls && t.toolCalls.length > 0;
- const hasJsonl = !!t.toolCallsUrl;
- if (t.harUrl && !hasInline && !hasJsonl) {
- q.push({ iteration: t.iteration, enabled: true });
- }
- }
- }
-
- return q;
- }, [turns, topLevelHarUrl]);
-
- const results = useQueries({
- queries: queries.map((q) => ({
- queryKey: ["har", runId, q.iteration, attemptRunId],
- queryFn: async () => {
- const url = attemptRunId ? api.runHarUrl(runId, attemptRunId, q.iteration) : api.harUrl(runId, q.iteration);
- const res = await apiClient(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[] = [...preComputed];
- // JSONL blob fetches first
+ const out: AggregatedToolCall[] = [];
for (let i = 0; i < jsonlResults.length; i++) {
const tcs = jsonlResults[i].data;
if (!tcs) continue;
@@ -165,23 +104,10 @@ export function useAllTurnsToolCalls(
out.push({ ...tc, timestamp: tc.timestamp ?? "", _iteration: iteration });
}
}
- // Legacy HAR re-extraction
- for (let i = 0; i < results.length; i++) {
- const har = results[i].data;
- if (!har) continue;
- 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 });
- }
- }
return out;
- }, [preComputed, jsonlResults, jsonlQueries, results, queries]);
+ }, [jsonlResults, jsonlQueries]);
- const isLoading = results.some((r) => r.isLoading) || jsonlResults.some((r) => r.isLoading);
+ const isLoading = jsonlResults.some((r) => r.isLoading);
return { allToolCalls, isLoading };
}
diff --git a/apps/portal/src/pages/RunDetail.tsx b/apps/portal/src/pages/RunDetail.tsx
index 77ac6b0fa..144af623e 100644
--- a/apps/portal/src/pages/RunDetail.tsx
+++ b/apps/portal/src/pages/RunDetail.tsx
@@ -1688,7 +1688,7 @@ export function RunDetail() {
{/* Tool Calls tab — HAR captures & tool calls summary */}
{hasHarData && (
-
+
)}
@@ -1811,7 +1811,7 @@ function VideoIterationTabs({ runId, turns, setupVideoUrls, attemptRunId }: { ru
}
// ---------------------------------------------------------------------------
-// Tool Calls tab — extracts tool calls from HAR client-side
+// Tool Calls tab — reads server-extracted per-iteration tool calls
// ---------------------------------------------------------------------------
import type { ConversationTurn } from "@/types";
@@ -1830,8 +1830,8 @@ function ExpandableCell({ children, className = "" }: { children: React.ReactNod
);
}
-function ToolCallsTab({ runId, turns, harUrl, attemptRunId }: { runId: string; turns?: ConversationTurn[]; harUrl?: string; attemptRunId?: string }) {
- const { allToolCalls, isLoading } = useAllTurnsToolCalls(runId, turns, harUrl, attemptRunId);
+function ToolCallsTab({ runId, turns, attemptRunId }: { runId: string; turns?: ConversationTurn[]; attemptRunId?: string }) {
+ const { allToolCalls, isLoading } = useAllTurnsToolCalls(runId, turns, attemptRunId);
// Group by tool name for summary
const byName = new Map();
diff --git a/docs/research/realtime-data-flow.md b/docs/research/realtime-data-flow.md
index abe77fd3d..1c0aece61 100644
--- a/docs/research/realtime-data-flow.md
+++ b/docs/research/realtime-data-flow.md
@@ -65,8 +65,10 @@ read lazily by clients (no SSE stream — fetched on demand by ID/iteration):
Tool-call JSONL was introduced (#812) to keep unbounded per-iteration tool-call
lists out of the `RequestDocument` (CosmosDB document size is capped at 2 MB).
-The turn carries only `toolCallsUrl` + `toolCallCount`; legacy turns with an
-inline `toolCalls[]` array remain readable for backwards compatibility.
+The turn carries `toolCallsUrl` + `toolCallCount`. The Portal Tool Calls tab
+reads only these server-extracted JSONL blobs; it no longer downloads HARs or
+uses legacy inline `toolCalls[]` for that tab. Other HAR-backed views still
+use `useHarData` for conversation and network details.
## React Query Polling