Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions apps/portal/src/hooks/useHarExtraction.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

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();
});
});
86 changes: 6 additions & 80 deletions apps/portal/src/hooks/useHarExtraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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 });
}
}
Expand All @@ -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<HarFile>;
},
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;
Expand All @@ -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 };
}
8 changes: 4 additions & 4 deletions apps/portal/src/pages/RunDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1688,7 +1688,7 @@ export function RunDetail() {
{/* Tool Calls tab — HAR captures & tool calls summary */}
{hasHarData && (
<TabsContent value="tool-calls" className="mt-4 space-y-4">
<ToolCallsTab runId={run._id} turns={activeRun?.turns} harUrl={activeRun?.harUrl} attemptRunId={isViewingHistorical ? activeRun?._id : undefined} />
<ToolCallsTab runId={run._id} turns={activeRun?.turns} attemptRunId={isViewingHistorical ? activeRun?._id : undefined} />
</TabsContent>
)}
</Tabs>
Expand Down Expand Up @@ -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";
Expand All @@ -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<string, number>();
Expand Down
6 changes: 4 additions & 2 deletions docs/research/realtime-data-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down