diff --git a/docs/ai-coder/agents/platform-controls/experiments.md b/docs/ai-coder/agents/platform-controls/experiments.md index 5c01312508..a274faa5d0 100644 --- a/docs/ai-coder/agents/platform-controls/experiments.md +++ b/docs/ai-coder/agents/platform-controls/experiments.md @@ -89,12 +89,83 @@ that chat's owner. The tab lists recent debug runs and lets you expand a run into its per-step request, response, token usage, retry attempts, errors, and policy metadata. +### Export debug logs + +You can export the same captured debug data from the UI: + +1. Navigate to **Agents**. +1. Open a chat with debug logging enabled. +1. Open the **Debug** tab in the right panel. +1. Click **Export debug logs** to download the chat's recent debug runs as + JSON, or expand a run and click **Export this run** to download one run. + +The chat-level export includes the full run detail for the runs returned by +the debug run list endpoint. The current list endpoint returns up to 100 of +the newest runs. + +### API access + The same data is available through the experimental API: -- `GET /api/experimental/chats/{chat}/runs` lists the most recent runs for a - chat (up to 100, newest first). -- `GET /api/experimental/chats/{chat}/runs/{debugRun}` returns a single run - with all of its steps, including normalized request and response bodies. +- `GET /api/experimental/chats/{chat}/debug/runs` lists the most recent runs + for a chat (up to 100, newest first). +- `GET /api/experimental/chats/{chat}/debug/runs/{debugRun}` returns a single + run with all of its steps, including normalized request and response bodies. + +Fetch a single run and save it as JSON: + +```sh +export CODER_URL="https://coder.example.com" +export CODER_SESSION_TOKEN="$(coder login token)" +export CHAT_ID="00000000-0000-0000-0000-000000000000" +export RUN_ID="11111111-1111-1111-1111-111111111111" + +curl -fsS \ + -H "Coder-Session-Token: $CODER_SESSION_TOKEN" \ + "$CODER_URL/api/experimental/chats/$CHAT_ID/debug/runs/$RUN_ID" \ + | jq . > "coder-agents-debug-run-$RUN_ID.json" +``` + +Fetch every run returned by the list endpoint and save a chat-level export. +Using the same `CODER_URL`, `CODER_SESSION_TOKEN`, and `CHAT_ID` variables +from above: + +```sh +RUN_IDS=$(curl -fsS \ + -H "Coder-Session-Token: $CODER_SESSION_TOKEN" \ + "$CODER_URL/api/experimental/chats/$CHAT_ID/debug/runs" \ + | jq -r '.[].id') || { + echo "Failed to list debug runs" >&2 + exit 1 +} + +RUN_EXPORTS=$(mktemp) +trap 'rm -f "$RUN_EXPORTS"' EXIT + +for RUN_ID in $RUN_IDS; do + curl -fsS \ + -H "Coder-Session-Token: $CODER_SESSION_TOKEN" \ + "$CODER_URL/api/experimental/chats/$CHAT_ID/debug/runs/$RUN_ID" \ + >> "$RUN_EXPORTS" || { + echo "Failed to fetch debug run $RUN_ID" >&2 + exit 1 + } + echo >> "$RUN_EXPORTS" +done + +jq -s \ + --arg chat_id "$CHAT_ID" \ + --arg exported_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{ + version: 1, + scope: "chat", + exported_at: $exported_at, + chat_id: $chat_id, + run_count: length, + limited_to_most_recent: 100, + runs: . + }' "$RUN_EXPORTS" > "coder-agents-debug-chat-$CHAT_ID.json" +``` Debug runs are stored alongside the chat and are removed when the parent conversation is deleted (manually, by retention, or by chat purge). See diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx index 6d9930ab70..afd658b926 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx @@ -1,5 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; +import { toast } from "sonner"; +import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test"; +import type { Mock } from "vitest"; import { API } from "#/api/api"; import type * as TypesGen from "#/api/typesGenerated"; import { DebugPanel } from "./DebugPanel"; @@ -513,6 +515,21 @@ const meta: Meta = { export default meta; type Story = StoryObj; +const getLastDownloadCall = (download: unknown): [Blob, string] => { + const lastCall = (download as Mock).mock.lastCall; + if (!lastCall) { + throw new Error("Expected debug export download to be called."); + } + const [blob, filename] = lastCall; + if (!(blob instanceof Blob)) { + throw new Error("Expected debug export download to receive a Blob."); + } + if (typeof filename !== "string") { + throw new Error("Expected debug export download to receive a filename."); + } + return [blob, filename]; +}; + export const Empty: Story = { parameters: { queries: [ @@ -745,6 +762,299 @@ export const SingleStepSuccessfulRun: Story = { }, }; +export const ExportAllRuns: Story = { + args: { + download: fn(), + }, + parameters: { + queries: [ + { + key: ["chats", CHAT_ID, "debug-runs"], + data: [ + makeRunSummary({ + id: successfulRunDetail.id, + summary: successfulRunDetail.summary, + }), + makeRunSummary({ + id: richRunDetail.id, + summary: richRunDetail.summary, + }), + ], + }, + ], + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + await user.click( + await canvas.findByRole("button", { name: "Export debug logs" }), + ); + + await waitFor(() => expect(args.download).toHaveBeenCalledTimes(1)); + const [blob, filename] = getLastDownloadCall(args.download); + expect(filename).toMatch(/^coder-agents-debug-chat-debug-ch-.*\.json$/); + expect(blob.type).toBe("application/json"); + + const payload = JSON.parse(await blob.text()); + expect(payload).toMatchObject({ + version: 1, + scope: "chat", + chat_id: CHAT_ID, + run_count: 2, + limited_to_most_recent: 100, + }); + expect(payload.runs).toHaveLength(2); + expect(payload.runs[0].id).toBe(successfulRunDetail.id); + expect(payload.runs[1].id).toBe(richRunDetail.id); + expect(payload.runs[0].steps).toHaveLength(1); + }, +}; + +export const ExportAllRunsUsesCachedTerminalRunDetails: Story = { + args: { + download: fn(), + }, + parameters: { + queries: [ + { + key: ["chats", CHAT_ID, "debug-runs"], + data: [ + makeRunSummary({ + id: successfulRunDetail.id, + summary: successfulRunDetail.summary, + }), + ], + }, + { + key: ["chats", CHAT_ID, "debug-runs", successfulRunDetail.id], + data: successfulRunDetail, + }, + ], + }, + beforeEach: () => { + const getChatDebugRunMock = spyOn( + API.experimental, + "getChatDebugRun", + ).mockRejectedValue(new Error("detail refetch should not happen")); + return () => { + getChatDebugRunMock.mockRestore(); + }; + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + await user.click( + await canvas.findByRole("button", { name: "Export debug logs" }), + ); + + await waitFor(() => expect(args.download).toHaveBeenCalledTimes(1)); + const [blob] = getLastDownloadCall(args.download); + const payload = JSON.parse(await blob.text()); + expect(payload.runs).toHaveLength(1); + expect(payload.runs[0].id).toBe(successfulRunDetail.id); + expect(payload).not.toHaveProperty("failed_runs"); + }, +}; + +export const ExportAllRunsPartialFailure: Story = { + args: { + download: fn(), + }, + parameters: ExportAllRuns.parameters, + beforeEach: () => { + const getChatDebugRunMock = spyOn( + API.experimental, + "getChatDebugRun", + ).mockImplementation(async (_chatID, runID) => { + if (runID === richRunDetail.id) { + throw new Error("run detail unavailable"); + } + return successfulRunDetail; + }); + return () => { + getChatDebugRunMock.mockRestore(); + }; + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + const warningSpy = spyOn(toast, "warning"); + await user.click( + await canvas.findByRole("button", { name: "Export debug logs" }), + ); + + await waitFor(() => expect(args.download).toHaveBeenCalledTimes(1)); + expect(warningSpy).toHaveBeenCalledWith( + "Exported debug logs with missing runs.", + expect.objectContaining({ + description: expect.stringContaining("1 run"), + }), + ); + warningSpy.mockRestore(); + const [blob] = getLastDownloadCall(args.download); + const payload = JSON.parse(await blob.text()); + expect(payload.runs).toHaveLength(1); + expect(payload.run_count).toBe(1); + expect(payload.requested_run_count).toBe(2); + expect(payload.failed_runs).toEqual([ + { run_id: richRunDetail.id, message: "run detail unavailable" }, + ]); + expect( + canvas.getByRole("button", { name: "Export debug logs" }), + ).toBeEnabled(); + }, +}; + +export const ExportAllRunsTotalFetchFailure: Story = { + args: { + download: fn(), + }, + parameters: ExportAllRuns.parameters, + beforeEach: () => { + const getChatDebugRunMock = spyOn( + API.experimental, + "getChatDebugRun", + ).mockRejectedValue(new Error("run details unavailable")); + return () => { + getChatDebugRunMock.mockRestore(); + }; + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + const errorSpy = spyOn(toast, "error"); + + await user.click( + await canvas.findByRole("button", { name: "Export debug logs" }), + ); + + await waitFor(() => + expect(errorSpy).toHaveBeenCalledWith("Failed to export debug logs.", { + description: "No debug run details could be fetched.", + }), + ); + expect(args.download).not.toHaveBeenCalled(); + expect( + canvas.getByRole("button", { name: "Export debug logs" }), + ).toBeEnabled(); + errorSpy.mockRestore(); + }, +}; + +export const ExportAllRunsDownloadError: Story = { + args: { + download: fn(async () => { + throw new Error("download failed"); + }), + }, + parameters: ExportAllRuns.parameters, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + const errorSpy = spyOn(toast, "error"); + await user.click( + await canvas.findByRole("button", { name: "Export debug logs" }), + ); + + await waitFor(() => expect(args.download).toHaveBeenCalledTimes(1)); + expect(errorSpy).toHaveBeenCalledWith("Failed to export debug logs.", { + description: "Please check the developer console for more details.", + }); + errorSpy.mockRestore(); + expect( + canvas.getByRole("button", { name: "Export debug logs" }), + ).toBeEnabled(); + }, +}; + +export const ExportSingleRun: Story = { + args: { + download: fn(), + }, + parameters: { + queries: [ + { + key: ["chats", CHAT_ID, "debug-runs"], + data: [ + makeRunSummary({ + id: successfulRunDetail.id, + summary: successfulRunDetail.summary, + }), + ], + }, + { + key: ["chats", CHAT_ID, "debug-runs", successfulRunDetail.id], + data: successfulRunDetail, + }, + ], + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + await user.click(await canvas.findByRole("button", { name: /Chat Turn/i })); + await user.click( + await canvas.findByRole("button", { name: "Export this run" }), + ); + + await waitFor(() => expect(args.download).toHaveBeenCalledTimes(1)); + const [blob, filename] = getLastDownloadCall(args.download); + expect(filename).toMatch(/^coder-agents-debug-run-run-1-.*\.json$/); + expect(blob.type).toBe("application/json"); + + const payload = JSON.parse(await blob.text()); + expect(payload).toMatchObject({ + version: 1, + scope: "run", + chat_id: CHAT_ID, + run_id: successfulRunDetail.id, + }); + expect(payload.run.steps).toHaveLength(1); + }, +}; + +export const ExportSingleRunDownloadError: Story = { + args: { + download: fn(async () => { + throw new Error("download failed"); + }), + }, + parameters: ExportSingleRun.parameters, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + await user.click(await canvas.findByRole("button", { name: /Chat Turn/i })); + const errorSpy = spyOn(toast, "error"); + await user.click( + await canvas.findByRole("button", { name: "Export this run" }), + ); + + await waitFor(() => expect(args.download).toHaveBeenCalledTimes(1)); + expect(errorSpy).toHaveBeenCalledWith("Failed to export debug run.", { + description: "Please check the developer console for more details.", + }); + errorSpy.mockRestore(); + expect( + canvas.getByRole("button", { name: "Export this run" }), + ).toBeEnabled(); + }, +}; + +// These stories intentionally use the real saveAs default for manual +// agent-browser dogfooding of browser downloads. +export const ExportAllRunsDogfood: Story = { + parameters: ExportAllRuns.parameters, +}; + +export const ExportSingleRunDogfood: Story = { + parameters: ExportSingleRun.parameters, +}; + export const MultiStepRunWithRetries: Story = { parameters: { queries: [ diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.tsx b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.tsx index a62c721454..8e4a868009 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.tsx +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.tsx @@ -1,20 +1,107 @@ -import type { FC, ReactNode } from "react"; -import { useQuery } from "react-query"; -import { getErrorMessage } from "#/api/errors"; -import { chatDebugRuns } from "#/api/queries/chats"; +import { saveAs } from "file-saver"; +import { DownloadIcon } from "lucide-react"; +import { type FC, type ReactNode, useEffect, useRef } from "react"; +import { + type QueryClient, + useMutation, + useQuery, + useQueryClient, +} from "react-query"; +import { toast } from "sonner"; +import { getErrorDetail, getErrorMessage } from "#/api/errors"; +import { + chatDebugRun, + chatDebugRuns, + TERMINAL_RUN_STATUSES, +} from "#/api/queries/chats"; +import type { ChatDebugRun, ChatDebugRunSummary } from "#/api/typesGenerated"; import { Alert } from "#/components/Alert/Alert"; +import { Button } from "#/components/Button/Button"; import { ScrollArea } from "#/components/ScrollArea/ScrollArea"; import { Spinner } from "#/components/Spinner/Spinner"; import { DebugRunList } from "./DebugRunList"; +import { + buildChatDebugExport, + buildDebugExportBlob, + type ChatDebugRunFetchFailure, + type DownloadDebugFile, + debugExportFilename, +} from "./debugExport"; interface DebugPanelProps { chatId: string; isVisible?: boolean; + download?: DownloadDebugFile; } +const DEBUG_RUN_EXPORT_FETCH_CONCURRENCY = 5; + +const getMissingRunsDescription = (failedRunCount: number): string => { + const noun = failedRunCount === 1 ? "run" : "runs"; + return `${failedRunCount} ${noun} could not be fetched. The downloaded JSON lists them in failed_runs.`; +}; + +const isTerminalDebugRun = (run: ChatDebugRunSummary): boolean => { + return TERMINAL_RUN_STATUSES.has(run.status.toLowerCase()); +}; + +const chatDebugRunExportQuery = (chatId: string, run: ChatDebugRunSummary) => ({ + ...chatDebugRun(chatId, run.id), + staleTime: isTerminalDebugRun(run) ? Number.POSITIVE_INFINITY : 0, +}); + +interface DebugRunExportFetchResult { + runDetails: ChatDebugRun[]; + failedRuns: ChatDebugRunFetchFailure[]; +} + +const fetchDebugRunDetailsForExport = async ( + queryClient: QueryClient, + chatId: string, + runs: readonly ChatDebugRunSummary[], + signal: AbortSignal, +): Promise => { + const runDetails: ChatDebugRun[] = []; + const failedRuns: ChatDebugRunFetchFailure[] = []; + + for (let i = 0; i < runs.length; i += DEBUG_RUN_EXPORT_FETCH_CONCURRENCY) { + if (signal.aborted) { + break; + } + const batch = runs.slice(i, i + DEBUG_RUN_EXPORT_FETCH_CONCURRENCY); + const results = await Promise.allSettled( + batch.map((run) => + queryClient.fetchQuery(chatDebugRunExportQuery(chatId, run)), + ), + ); + if (signal.aborted) { + break; + } + + for (let resultIndex = 0; resultIndex < results.length; resultIndex++) { + const result = results[resultIndex]; + const run = batch[resultIndex]; + if (result.status === "fulfilled") { + runDetails.push(result.value); + continue; + } + failedRuns.push({ + run_id: run.id, + message: getErrorMessage( + result.reason, + "Unable to fetch debug run detail.", + ), + }); + } + } + + return { runDetails, failedRuns }; +}; + export const DebugPanel: FC = ({ chatId, isVisible = false, + download = saveAs, }) => { const runsQuery = useQuery({ ...chatDebugRuns(chatId), @@ -83,7 +170,17 @@ export const DebugPanel: FC = ({ content = ( <> {refreshWarning} - + + ); } @@ -100,3 +197,103 @@ export const DebugPanel: FC = ({ ); }; + +interface ExportAllDebugRunsButtonProps { + chatId: string; + runs: readonly ChatDebugRunSummary[]; + download: DownloadDebugFile; +} + +const ExportAllDebugRunsButton: FC = ({ + chatId, + runs, + download, +}) => { + const queryClient = useQueryClient(); + const activeExportControllerRef = useRef(null); + const exportDebugRunsMutation = useMutation({ + mutationFn: async (controller: AbortController) => { + const { signal } = controller; + try { + const { runDetails, failedRuns } = await fetchDebugRunDetailsForExport( + queryClient, + chatId, + runs, + signal, + ); + if (signal.aborted) { + return; + } + if (runDetails.length === 0) { + toast.error("Failed to export debug logs.", { + description: "No debug run details could be fetched.", + }); + return; + } + + const exportedAt = new Date(); + const payload = buildChatDebugExport(chatId, runDetails, exportedAt, { + failedRuns, + requestedRunCount: runs.length, + }); + if (signal.aborted) { + return; + } + await download( + buildDebugExportBlob(payload), + debugExportFilename({ chatId, exportedAt }), + ); + if (signal.aborted) { + return; + } + + if (failedRuns.length > 0) { + toast.warning("Exported debug logs with missing runs.", { + description: getMissingRunsDescription(failedRuns.length), + }); + } + } catch (error) { + console.error(error); + toast.error("Failed to export debug logs.", { + description: getErrorDetail(error), + }); + } + }, + onSettled: (_data, _error, controller) => { + if (activeExportControllerRef.current !== controller) { + return; + } + activeExportControllerRef.current = null; + }, + }); + + useEffect(() => { + return () => { + activeExportControllerRef.current?.abort(); + activeExportControllerRef.current = null; + }; + }, []); + + return ( +
+ +
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunCard.tsx b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunCard.tsx index 97949d7553..2831806256 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunCard.tsx +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunCard.tsx @@ -1,11 +1,14 @@ -import { ChevronDownIcon } from "lucide-react"; +import { saveAs } from "file-saver"; +import { ChevronDownIcon, DownloadIcon } from "lucide-react"; import { type FC, useState } from "react"; import { useQuery } from "react-query"; -import { getErrorMessage } from "#/api/errors"; +import { toast } from "sonner"; +import { getErrorDetail, getErrorMessage } from "#/api/errors"; import { chatDebugRun } from "#/api/queries/chats"; import type { ChatDebugRunSummary } from "#/api/typesGenerated"; import { Alert } from "#/components/Alert/Alert"; import { Badge } from "#/components/Badge/Badge"; +import { Button } from "#/components/Button/Button"; import { Collapsible, CollapsibleContent, @@ -14,6 +17,12 @@ import { import { Spinner } from "#/components/Spinner/Spinner"; import { cn } from "#/utils/cn"; import { DebugStepCard } from "./DebugStepCard"; +import { + buildDebugExportBlob, + buildRunDebugExport, + type DownloadDebugFile, + debugExportFilename, +} from "./debugExport"; import { clampContent, coerceRunSummary, @@ -29,6 +38,7 @@ interface DebugRunCardProps { run: ChatDebugRunSummary; chatId: string; isVisible: boolean; + download?: DownloadDebugFile; } // Max characters shown in the run header label before truncation. @@ -43,8 +53,10 @@ export const DebugRunCard: FC = ({ run, chatId, isVisible, + download = saveAs, }) => { const [isExpanded, setIsExpanded] = useState(false); + const [isExporting, setIsExporting] = useState(false); const runDetailQuery = useQuery({ ...chatDebugRun(chatId, run.id), enabled: isVisible && isExpanded, @@ -93,6 +105,33 @@ export const DebugRunCard: FC = ({ : run.status; const running = isActiveStatus(effectiveStatus); + const exportDebugRun = async () => { + if (!runDetailQuery.data) { + return; + } + try { + const exportedAt = new Date(); + const payload = buildRunDebugExport( + chatId, + runDetailQuery.data, + exportedAt, + ); + await download( + buildDebugExportBlob(payload), + debugExportFilename({ + chatId, + runId: run.id, + exportedAt, + }), + ); + } catch (error) { + console.error(error); + toast.error("Failed to export debug run.", { + description: getErrorDetail(error), + }); + } + }; + return (
@@ -169,6 +208,28 @@ export const DebugRunCard: FC = ({ No steps recorded.

) : null} + {runDetailQuery.data ? ( +
+ +
+ ) : null}
)} diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunList.tsx b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunList.tsx index 0283c32d72..e8fe9c7349 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunList.tsx +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunList.tsx @@ -1,17 +1,20 @@ import type { FC } from "react"; import type { ChatDebugRunSummary } from "#/api/typesGenerated"; import { DebugRunCard } from "./DebugRunCard"; +import type { DownloadDebugFile } from "./debugExport"; interface DebugRunListProps { runs: ChatDebugRunSummary[]; chatId: string; isVisible: boolean; + download?: DownloadDebugFile; } export const DebugRunList: FC = ({ runs, chatId, isVisible, + download, }) => { // Empty state is handled by DebugPanel before rendering this // component. No guard here to avoid duplicated copy that drifts. @@ -23,6 +26,7 @@ export const DebugRunList: FC = ({ run={run} chatId={chatId} isVisible={isVisible} + download={download} /> ))} diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugExport.test.ts b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugExport.test.ts new file mode 100644 index 0000000000..02c5a960b9 --- /dev/null +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugExport.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import type { ChatDebugRun } from "#/api/typesGenerated"; +import { + buildChatDebugExport, + buildDebugExportBlob, + buildRunDebugExport, + DEBUG_RUN_LIST_LIMIT, + debugExportFilename, +} from "./debugExport"; + +const exportedAt = new Date("2026-05-07T10:45:00.000Z"); + +const makeRun = (overrides: Partial = {}): ChatDebugRun => ({ + id: "11111111-1111-1111-1111-111111111111", + chat_id: "00000000-0000-0000-0000-000000000000", + kind: "chat_turn", + status: "completed", + provider: "openai", + model: "gpt-4", + summary: { first_message: "Help debug this" }, + started_at: "2026-05-07T10:40:00Z", + updated_at: "2026-05-07T10:44:00Z", + finished_at: "2026-05-07T10:44:00Z", + steps: [ + { + id: "22222222-2222-2222-2222-222222222222", + run_id: "11111111-1111-1111-1111-111111111111", + chat_id: "00000000-0000-0000-0000-000000000000", + step_number: 1, + operation: "stream", + status: "completed", + normalized_request: { messages: [{ role: "user", content: "hello" }] }, + normalized_response: { content: "hi" }, + usage: { prompt_tokens: 10, completion_tokens: 2 }, + attempts: [ + { + number: 1, + status: "completed", + request_headers: { Authorization: "[REDACTED]" }, + }, + ], + metadata: { provider: "openai" }, + started_at: "2026-05-07T10:41:00Z", + updated_at: "2026-05-07T10:42:00Z", + finished_at: "2026-05-07T10:42:00Z", + }, + ], + ...overrides, +}); + +describe("buildRunDebugExport", () => { + it("wraps a full debug run in a run-level export envelope", () => { + const run = makeRun(); + const payload = buildRunDebugExport(run.chat_id, run, exportedAt); + + expect(payload).toEqual({ + version: 1, + scope: "run", + exported_at: "2026-05-07T10:45:00.000Z", + chat_id: run.chat_id, + run_id: run.id, + run, + }); + }); +}); + +describe("buildChatDebugExport", () => { + it("wraps full debug runs in a chat-level export envelope", () => { + const runs = [makeRun(), makeRun({ id: "run-2" })]; + const payload = buildChatDebugExport(runs[0].chat_id, runs, exportedAt); + + expect(payload.version).toBe(1); + expect(payload.scope).toBe("chat"); + expect(payload.exported_at).toBe("2026-05-07T10:45:00.000Z"); + expect(payload.chat_id).toBe(runs[0].chat_id); + expect(payload.run_count).toBe(2); + expect(payload.requested_run_count).toBe(2); + expect(payload.limited_to_most_recent).toBe(DEBUG_RUN_LIST_LIMIT); + expect(payload.runs).toEqual(runs); + expect(payload.runs[0].steps).toHaveLength(1); + expect(payload).not.toHaveProperty("failed_runs"); + }); + + it("includes failed run metadata when some detail fetches fail", () => { + const runs = [makeRun()]; + const failedRuns = [{ run_id: "run-2", message: "not found" }]; + const payload = buildChatDebugExport(runs[0].chat_id, runs, exportedAt, { + failedRuns, + requestedRunCount: 2, + }); + + expect(payload.run_count).toBe(1); + expect(payload.requested_run_count).toBe(2); + expect(payload.failed_runs).toEqual(failedRuns); + }); +}); + +describe("buildDebugExportBlob", () => { + it("serializes export payloads as formatted JSON blobs", async () => { + const run = makeRun(); + const payload = buildRunDebugExport(run.chat_id, run, exportedAt); + const blob = buildDebugExportBlob(payload); + + expect(blob.type).toBe("application/json"); + const parsed = JSON.parse(await blob.text()); + expect(parsed).toMatchObject({ + version: 1, + scope: "run", + chat_id: run.chat_id, + run_id: run.id, + }); + expect(parsed.run.steps).toHaveLength(1); + }); +}); + +describe("debugExportFilename", () => { + it("generates a chat-level filename", () => { + expect( + debugExportFilename({ + chatId: "abcdef12-3456-7890-abcd-ef1234567890", + exportedAt, + }), + ).toBe("coder-agents-debug-chat-abcdef12-2026-05-07T10-45-00-000Z.json"); + }); + + it("generates a run-level filename", () => { + expect( + debugExportFilename({ + chatId: "abcdef12-3456-7890-abcd-ef1234567890", + runId: "deadbeef-1234-5678-9abc-def012345678", + exportedAt, + }), + ).toBe("coder-agents-debug-run-deadbeef-2026-05-07T10-45-00-000Z.json"); + }); +}); diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugExport.ts b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugExport.ts new file mode 100644 index 0000000000..dc428ef018 --- /dev/null +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugExport.ts @@ -0,0 +1,96 @@ +import type { ChatDebugRun } from "#/api/typesGenerated"; + +// Keep in sync with maxDebugRuns in coderd/exp_chats.go. +export const DEBUG_RUN_LIST_LIMIT = 100; + +const DEBUG_ID_PREFIX_LENGTH = 8; + +export interface ChatDebugRunFetchFailure { + readonly run_id: string; + readonly message: string; +} + +interface DebugRunExport { + readonly version: 1; + readonly scope: "run"; + readonly exported_at: string; + readonly chat_id: string; + readonly run_id: string; + readonly run: ChatDebugRun; +} + +interface DebugChatExport { + readonly version: 1; + readonly scope: "chat"; + readonly exported_at: string; + readonly chat_id: string; + readonly run_count: number; + readonly requested_run_count: number; + readonly limited_to_most_recent: number; + readonly failed_runs?: readonly ChatDebugRunFetchFailure[]; + readonly runs: readonly ChatDebugRun[]; +} + +type ChatDebugExport = DebugRunExport | DebugChatExport; + +export type DownloadDebugFile = ( + blob: Blob, + filename: string, +) => void | Promise; + +export const buildRunDebugExport = ( + chatId: string, + run: ChatDebugRun, + exportedAt = new Date(), +): DebugRunExport => ({ + version: 1, + scope: "run", + exported_at: exportedAt.toISOString(), + chat_id: chatId, + run_id: run.id, + run, +}); + +export const buildChatDebugExport = ( + chatId: string, + runs: readonly ChatDebugRun[], + exportedAt = new Date(), + options: { + readonly failedRuns?: readonly ChatDebugRunFetchFailure[]; + readonly requestedRunCount?: number; + } = {}, +): DebugChatExport => { + const failedRuns = options.failedRuns ?? []; + return { + version: 1, + scope: "chat", + exported_at: exportedAt.toISOString(), + chat_id: chatId, + run_count: runs.length, + requested_run_count: options.requestedRunCount ?? runs.length, + limited_to_most_recent: DEBUG_RUN_LIST_LIMIT, + ...(failedRuns.length > 0 ? { failed_runs: failedRuns } : {}), + runs, + }; +}; + +export const buildDebugExportBlob = (payload: ChatDebugExport): Blob => { + return new Blob([JSON.stringify(payload, null, 2)], { + type: "application/json", + }); +}; + +export const debugExportFilename = ({ + chatId, + runId, + exportedAt = new Date(), +}: { + chatId: string; + runId?: string; + exportedAt?: Date; +}): string => { + const timestamp = exportedAt.toISOString().replace(/[:.]/g, "-"); + const idPrefix = (runId ?? chatId).slice(0, DEBUG_ID_PREFIX_LENGTH); + const scope = runId ? "run" : "chat"; + return `coder-agents-debug-${scope}-${idPrefix}-${timestamp}.json`; +};