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