feat: export Coder Agents debug logs (#25039)

Adds JSON export actions to the Coder Agents Debug panel so users can download either the current chat's recent debug runs or one expanded run for support sharing.

The export reuses the existing chat debug endpoints and react-query cache, adds Storybook and unit coverage for the JSON envelope, and updates the chat debug logging docs with UI and cURL instructions.

Refs CODAGT-280.

Generated by Coder Agents.

<details>
<summary>Implementation notes</summary>

- Chat-level export fetches full detail for each listed debug run with `queryClient.fetchQuery(chatDebugRun(chatId, run.id))` and writes a single JSON file.
- Run-level export uses the already-loaded detail query data from an expanded run card.
- The JSON envelope includes `version`, `scope`, `exported_at`, `chat_id`, and either `runs` or `run`.
- The chat-level export reflects the current backend list endpoint behavior, up to the 100 newest debug runs.
- Agent-browser dogfooding verified files were downloaded and that `jq` validated the chat-level and run-level JSON contents.

</details>
This commit is contained in:
Thomas Kosiewski
2026-05-12 17:39:57 +02:00
committed by GitHub
parent 147f50c5e8
commit 969da320ec
7 changed files with 886 additions and 12 deletions
@@ -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
@@ -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<typeof DebugPanel> = {
export default meta;
type Story = StoryObj<typeof DebugPanel>;
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: [
@@ -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<DebugRunExportFetchResult> => {
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<DebugPanelProps> = ({
chatId,
isVisible = false,
download = saveAs,
}) => {
const runsQuery = useQuery({
...chatDebugRuns(chatId),
@@ -83,7 +170,17 @@ export const DebugPanel: FC<DebugPanelProps> = ({
content = (
<>
{refreshWarning}
<DebugRunList runs={sortedRuns} chatId={chatId} isVisible={isVisible} />
<ExportAllDebugRunsButton
chatId={chatId}
runs={sortedRuns}
download={download}
/>
<DebugRunList
runs={sortedRuns}
chatId={chatId}
isVisible={isVisible}
download={download}
/>
</>
);
}
@@ -100,3 +197,103 @@ export const DebugPanel: FC<DebugPanelProps> = ({
</ScrollArea>
);
};
interface ExportAllDebugRunsButtonProps {
chatId: string;
runs: readonly ChatDebugRunSummary[];
download: DownloadDebugFile;
}
const ExportAllDebugRunsButton: FC<ExportAllDebugRunsButtonProps> = ({
chatId,
runs,
download,
}) => {
const queryClient = useQueryClient();
const activeExportControllerRef = useRef<AbortController | null>(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 (
<div className="flex justify-end px-4 pt-4">
<Button
variant="outline"
size="sm"
disabled={exportDebugRunsMutation.isPending}
onClick={() => {
const controller = new AbortController();
activeExportControllerRef.current?.abort();
activeExportControllerRef.current = controller;
exportDebugRunsMutation.mutate(controller);
}}
>
{exportDebugRunsMutation.isPending ? (
<Spinner size="sm" loading />
) : (
<DownloadIcon className="size-4" />
)}
Export debug logs
</Button>
</div>
);
};
@@ -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<DebugRunCardProps> = ({
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<DebugRunCardProps> = ({
: 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 (
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<div className="overflow-hidden rounded-lg border border-solid border-border-default/40">
@@ -169,6 +208,28 @@ export const DebugRunCard: FC<DebugRunCardProps> = ({
No steps recorded.
</p>
) : null}
{runDetailQuery.data ? (
<div className="flex justify-end pt-1">
<Button
variant="outline"
size="sm"
disabled={isExporting}
onClick={() => {
setIsExporting(true);
void exportDebugRun().finally(() =>
setIsExporting(false),
);
}}
>
{isExporting ? (
<Spinner size="sm" loading />
) : (
<DownloadIcon className="size-4" />
)}
Export this run
</Button>
</div>
) : null}
</div>
)}
</CollapsibleContent>
@@ -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<DebugRunListProps> = ({
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<DebugRunListProps> = ({
run={run}
chatId={chatId}
isVisible={isVisible}
download={download}
/>
))}
</div>
@@ -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> = {}): 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");
});
});
@@ -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<void>;
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`;
};