From 5c316d42525375cb8548ea5a990f38fcdfac78d8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 22 Apr 2026 15:44:32 +0200 Subject: [PATCH] test(site/src/pages/AgentsPage): add Debug panel Storybook stories (#23921) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add comprehensive Storybook stories covering the Debug panel in various states: loading, empty, single/multiple runs, expanded steps, tool calls, error states, and streaming indicators. This is PR 9/9 in the chat debug logging stack. ### Changes - **DebugPanel stories** (`site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx`): Storybook stories with `play` functions for interaction testing, covering: - Loading state - Empty / disabled state - Single-step successful run - Multi-step run with tool calls - Error state with redacted headers - Compaction and title generation run kind badges - Long raw request/response payload rendering - Streaming / in-progress indicators ### Stack overview 1. Database schema & SDK types 2. Types, context, and model normalization 3. Recorder, transport, and redaction 4. Service and summary aggregation 5. Chat lifecycle wiring 6. HTTP handlers and API docs 7. Frontend API layer and panel utilities 8. Debug panel components and settings 9. **→ Storybook stories** (this PR) --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `anthropic:claude-opus-4-6` • Thinking: `xhigh`_ --- .../AgentSettingsBehaviorPageView.stories.tsx | 15 + .../AgentsPage/AgentsPageView.stories.tsx | 2 +- .../DebugPanel/DebugPanel.stories.tsx | 1342 +++++++++++++++++ 3 files changed, 1358 insertions(+), 1 deletion(-) create mode 100644 site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx diff --git a/site/src/pages/AgentsPage/AgentSettingsBehaviorPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsBehaviorPageView.stories.tsx index 31c1f55e31..b2a0dab68e 100644 --- a/site/src/pages/AgentsPage/AgentSettingsBehaviorPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsBehaviorPageView.stories.tsx @@ -22,6 +22,15 @@ const baseProps = { desktopEnabledData: { enable_desktop: false, } as TypesGen.ChatDesktopEnabledResponse, + debugLoggingData: { + allow_users: false, + forced_by_deployment: false, + } as TypesGen.ChatDebugLoggingAdminSettings, + userDebugLoggingData: { + debug_logging_enabled: false, + user_toggle_allowed: false, + forced_by_deployment: false, + } as TypesGen.UserChatDebugLoggingSettings, workspaceTTLData: { workspace_ttl_ms: 0, } as TypesGen.ChatWorkspaceTTLResponse, @@ -46,6 +55,10 @@ const baseProps = { isSaveUserPromptError: false, isSavingDesktopEnabled: false, isSaveDesktopEnabledError: false, + isSavingDebugLogging: false, + isSaveDebugLoggingError: false, + isSavingUserDebugLogging: false, + isSaveUserDebugLoggingError: false, isSavingWorkspaceTTL: false, isSaveWorkspaceTTLError: false, isSavingRetentionDays: false, @@ -61,6 +74,8 @@ const meta = { onSavePlanModeInstructions: fn(), onSaveUserPrompt: fn(), onSaveDesktopEnabled: fn(), + onSaveDebugLogging: fn(), + onSaveUserDebugLogging: fn(), onSaveWorkspaceTTL: fn(), onSaveRetentionDays: fn(), onSaveThreshold: fn(), diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index e5f81f6ec6..a5c382a86b 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -174,8 +174,8 @@ const BehaviorRouteElement = () => { }} userDebugLoggingData={{ debug_logging_enabled: false, - forced_by_deployment: false, user_toggle_allowed: false, + forced_by_deployment: false, }} workspaceTTLData={{ workspace_ttl_ms: 0 }} isWorkspaceTTLLoading={false} diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx new file mode 100644 index 0000000000..e97b41b4e4 --- /dev/null +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.stories.tsx @@ -0,0 +1,1342 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; +import { API } from "#/api/api"; +import type * as TypesGen from "#/api/typesGenerated"; +import { DebugPanel } from "./DebugPanel"; + +const FIXTURE_NOW = Date.parse("2026-03-05T12:00:10.000Z"); + +const CHAT_ID = "debug-chat-1"; + +const makeRunSummary = ( + overrides: Partial, +): TypesGen.ChatDebugRunSummary => ({ + id: "run-1", + chat_id: CHAT_ID, + kind: "chat_turn", + status: "completed", + provider: "openai", + model: "gpt-4", + summary: {}, + started_at: "2026-03-05T12:00:05Z", + updated_at: "2026-03-05T12:00:08Z", + finished_at: "2026-03-05T12:00:08Z", + ...overrides, +}); + +const makeStep = ( + overrides: Partial, +): TypesGen.ChatDebugStep => ({ + id: "step-1", + run_id: "run-1", + chat_id: CHAT_ID, + step_number: 1, + operation: "stream", + status: "completed", + normalized_request: { model: "gpt-4", prompt: "Hello" }, + normalized_response: { content: "Hi there!", finish_reason: "stop" }, + usage: { prompt_tokens: "10", completion_tokens: "5", total_tokens: "15" }, + attempts: [], + metadata: { provider: "openai" }, + started_at: "2026-03-05T12:00:06Z", + updated_at: "2026-03-05T12:00:08Z", + finished_at: "2026-03-05T12:00:08Z", + ...overrides, +}); + +const makeRun = ( + overrides: Partial, +): TypesGen.ChatDebugRun => ({ + id: "run-1", + chat_id: CHAT_ID, + kind: "chat_turn", + status: "completed", + provider: "openai", + model: "gpt-4", + summary: { result: "Generated response successfully" }, + started_at: "2026-03-05T12:00:05Z", + updated_at: "2026-03-05T12:00:08Z", + finished_at: "2026-03-05T12:00:08Z", + steps: [makeStep({})], + ...overrides, +}); + +type StoryAttempt = Record; + +const makeAttempts = ( + attempts: readonly Record[], +): TypesGen.ChatDebugStep["attempts"] => { + return attempts.map((attempt, index) => ({ + ...attempt, + attempt_number: + typeof attempt.attempt_number === "number" + ? attempt.attempt_number + : typeof attempt.number === "number" + ? attempt.number + : index + 1, + status: typeof attempt.status === "string" ? attempt.status : "completed", + started_at: + typeof attempt.started_at === "string" + ? attempt.started_at + : "2026-03-05T12:00:06Z", + })) as readonly StoryAttempt[]; +}; + +const makeLargeRecord = ( + prefix: string, + count: number, +): Record => { + return Object.fromEntries( + Array.from({ length: count }, (_, index) => [ + `${prefix}_${index + 1}`, + `${prefix}-value-${index + 1}-${"x".repeat(24)}`, + ]), + ); +}; + +type StoryCanvas = ReturnType; +type StoryUser = ReturnType; + +// Story fixtures use structured normalized payloads even though the generated +// API type still models them as string records. +const makeNormalizedPayloadFixture = ( + payload: Record, +): TypesGen.ChatDebugStep["normalized_request"] => { + return payload as TypesGen.ChatDebugStep["normalized_request"]; +}; + +// DebugRunCard renders DebugStepCard with defaultOpen={false}, so nested step +// content is only visible after the step trigger is opened explicitly. +const expandStep = async ( + canvas: StoryCanvas, + user: StoryUser, + stepName: RegExp | string = /Step 1/i, +) => { + const stepTrigger = await canvas.findByRole("button", { name: stepName }); + await user.click(stepTrigger); + return stepTrigger; +}; + +// --------------------------------------------------------------------------- +// Rich-payload fixtures (messages, tools, usage, firstMessage). +// --------------------------------------------------------------------------- + +const richRequest: Record = { + model: "gpt-4", + messages: JSON.stringify([ + { role: "system", content: "You are a helpful coding assistant." }, + { + role: "user", + content: "Write me a hello world function in Python", + }, + ]), + tools: JSON.stringify([ + { + type: "function", + function: { + name: "run_code", + description: "Execute Python code in a sandbox", + }, + }, + { + type: "function", + function: { + name: "search_docs", + description: "Search documentation", + }, + }, + ]), + temperature: "0.7", + max_output_tokens: "4096", + tool_choice: "auto", +}; + +const richResponse: Record = { + content: + "Here's a hello world function:\n\n```python\ndef hello():\n print('Hello, world!')\n```", + finish_reason: "stop", + model: "gpt-4", +}; + +const toolCallResponse: Record = { + content: "", + tool_calls: JSON.stringify([ + { + id: "call_1", + function: { + name: "run_code", + arguments: '{"code":"print(\'hello\')"}', + }, + }, + ]), + finish_reason: "tool_calls", + model: "gpt-4", +}; + +// --------------------------------------------------------------------------- +// Pre-built run details. +// --------------------------------------------------------------------------- + +const successfulRunDetail = makeRun({ + summary: { + result: "Generated response successfully", + latency: "5s", + }, + steps: [ + makeStep({ + attempts: makeAttempts([ + { + attempt_number: 1, + status: "completed", + raw_request: { + url: "https://api.openai.com/v1/chat/completions", + method: "POST", + }, + raw_response: { + status: "200", + request_id: "req-success-1", + }, + duration_ms: 1500, + started_at: "2026-03-05T12:00:06Z", + finished_at: "2026-03-05T12:00:08Z", + }, + ]), + metadata: { + provider: "openai", + region: "us-east-1", + }, + }), + ], +}); + +const richRunDetail = makeRun({ + id: "run-rich", + summary: { + first_message: "Write me a hello world function in Python", + prompt_tokens: "150", + completion_tokens: "42", + }, + steps: [ + makeStep({ + id: "step-rich-1", + run_id: "run-rich", + normalized_request: richRequest, + normalized_response: richResponse, + usage: { + prompt_tokens: "150", + completion_tokens: "42", + total_tokens: "192", + }, + attempts: makeAttempts([ + { + attempt_number: 1, + status: "completed", + raw_request: { + url: "https://api.openai.com/v1/chat/completions", + }, + raw_response: { status: "200" }, + duration_ms: 2200, + started_at: "2026-03-05T12:00:06Z", + finished_at: "2026-03-05T12:00:08Z", + }, + ]), + }), + ], +}); + +const toolCallRunDetail = makeRun({ + id: "run-tool", + summary: { + first_message: "Run some code for me", + }, + steps: [ + makeStep({ + id: "step-tool-1", + run_id: "run-tool", + normalized_request: richRequest, + normalized_response: toolCallResponse, + usage: { + prompt_tokens: "200", + completion_tokens: "30", + total_tokens: "230", + }, + attempts: makeAttempts([]), + }), + ], +}); + +const multiStepRunDetail = makeRun({ + id: "run-2", + status: "completed", + started_at: "2026-03-02T09:00:00Z", + updated_at: "2026-03-02T09:00:12Z", + finished_at: "2026-03-02T09:00:12Z", + summary: { + result: "Recovered after retries", + retries: "2", + }, + steps: [ + makeStep({ + id: "step-2-1", + run_id: "run-2", + step_number: 1, + status: "completed", + normalized_request: { + model: "gpt-4", + prompt: "Retry this call until success", + }, + normalized_response: { + content: "Retry succeeded on attempt 3", + finish_reason: "stop", + }, + attempts: makeAttempts([ + { + attempt_number: 1, + status: "failed", + raw_request: { url: "https://api.openai.com/v1/chat/completions" }, + raw_response: { status: "500" }, + error: { message: "upstream timeout" }, + duration_ms: 1200, + started_at: "2026-03-02T09:00:01Z", + finished_at: "2026-03-02T09:00:02.200Z", + }, + { + attempt_number: 2, + status: "failed", + raw_request: { url: "https://api.openai.com/v1/chat/completions" }, + raw_response: { status: "429" }, + error: { message: "rate limited" }, + duration_ms: 900, + started_at: "2026-03-02T09:00:03Z", + finished_at: "2026-03-02T09:00:03.900Z", + }, + { + attempt_number: 3, + status: "succeeded", + raw_request: { url: "https://api.openai.com/v1/chat/completions" }, + raw_response: { status: "200" }, + duration_ms: 1400, + started_at: "2026-03-02T09:00:04Z", + finished_at: "2026-03-02T09:00:05.400Z", + }, + ]), + }), + makeStep({ + id: "step-2-2", + run_id: "run-2", + step_number: 2, + operation: "generate", + status: "completed", + normalized_request: { action: "annotate", content: "Final answer" }, + normalized_response: { result: "Annotated response" }, + attempts: makeAttempts([ + { + attempt_number: 1, + status: "completed", + raw_request: { phase: "generate" }, + raw_response: { status: "200" }, + duration_ms: 500, + started_at: "2026-03-02T09:00:06Z", + finished_at: "2026-03-02T09:00:06.500Z", + }, + ]), + }), + ], +}); + +const errorRunDetail = makeRun({ + id: "run-3", + status: "error", + started_at: "2026-03-03T14:00:00Z", + updated_at: "2026-03-03T14:00:07Z", + finished_at: "2026-03-03T14:00:07Z", + summary: { + result: "Provider request failed", + authorization: "[REDACTED]", + }, + steps: [ + makeStep({ + id: "step-3-1", + run_id: "run-3", + status: "error", + normalized_request: { + model: "gpt-4", + authorization: "[REDACTED]", + x_trace: "trace-123", + }, + normalized_response: { status: "401", detail: "Unauthorized" }, + error: { + message: "Provider request failed", + code: "upstream_unauthorized", + }, + attempts: makeAttempts([ + { + attempt_number: 1, + status: "failed", + raw_request: { + authorization: "***", + url: "https://api.openai.com/v1/chat/completions", + }, + raw_response: { status: "401" }, + error: { message: "invalid auth header" }, + duration_ms: 800, + started_at: "2026-03-03T14:00:01Z", + finished_at: "2026-03-03T14:00:01.800Z", + }, + ]), + }), + ], +}); + +const longPayloadRunDetail = makeRun({ + id: "run-4", + status: "completed", + started_at: "2026-03-04T08:30:00Z", + updated_at: "2026-03-04T08:30:20Z", + finished_at: "2026-03-04T08:30:20Z", + summary: { + result: "Large payload rendered", + size: "large", + }, + steps: [ + makeStep({ + id: "step-4-1", + run_id: "run-4", + normalized_request: makeLargeRecord("request", 24), + normalized_response: makeLargeRecord("response", 24), + metadata: makeLargeRecord("metadata", 12), + usage: { + prompt_tokens: "512", + completion_tokens: "256", + total_tokens: "768", + }, + attempts: makeAttempts([ + { + attempt_number: 1, + status: "completed", + raw_request: makeLargeRecord("raw_request_chunk", 20), + raw_response: makeLargeRecord("raw_response_chunk", 20), + duration_ms: 3200, + started_at: "2026-03-04T08:30:02Z", + finished_at: "2026-03-04T08:30:05.200Z", + }, + ]), + }), + ], +}); + +const getAllRunDetails = () => [ + successfulRunDetail, + richRunDetail, + toolCallRunDetail, + multiStepRunDetail, + errorRunDetail, + longPayloadRunDetail, + backendShapeRunDetail, +]; + +const getAllRunSummaries = () => + getAllRunDetails().map((run) => + makeRunSummary({ + id: run.id, + kind: run.kind, + status: run.status, + provider: run.provider, + model: run.model, + summary: run.summary, + started_at: run.started_at, + updated_at: run.updated_at, + finished_at: run.finished_at, + }), + ); + +const getDebugRunDetailById = () => + new Map(getAllRunDetails().map((run) => [run.id, run])); + +const debugRunsQueryKey = ["chats", CHAT_ID, "debug-runs"] as const; + +const getSeededRunSummaries = ( + queries: readonly { key: readonly unknown[]; data: unknown }[] | undefined, +): TypesGen.ChatDebugRunSummary[] => { + const seeded = queries?.find( + (query) => + query.key.length === debugRunsQueryKey.length && + query.key.every((part, index) => part === debugRunsQueryKey[index]), + )?.data; + return Array.isArray(seeded) + ? [...(seeded as TypesGen.ChatDebugRunSummary[])] + : getAllRunSummaries(); +}; + +const meta: Meta = { + title: "pages/AgentsPage/DebugPanel", + component: DebugPanel, + args: { + chatId: CHAT_ID, + isVisible: true, + }, + beforeEach: (ctx) => { + const real = Date.now; + Date.now = () => FIXTURE_NOW; + const getChatDebugRunsMock = spyOn( + API.experimental, + "getChatDebugRuns", + ).mockResolvedValue(getSeededRunSummaries(ctx.parameters.queries)); + const getChatDebugRunMock = spyOn( + API.experimental, + "getChatDebugRun", + ).mockImplementation(async (_chatID, runID) => { + return ( + getDebugRunDetailById().get(runID) ?? + makeRun({ + id: runID, + summary: { result: `Unknown debug run fixture: ${runID}` }, + steps: [], + }) + ); + }); + return () => { + Date.now = real; + getChatDebugRunsMock.mockRestore(); + getChatDebugRunMock.mockRestore(); + }; + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Empty: Story = { + parameters: { + queries: [ + { + key: ["chats", CHAT_ID, "debug-runs"], + data: [], + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/no debug runs/i)).toBeInTheDocument(); + }, +}; + +export const Disabled: Story = { + args: { + isVisible: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText(/no debug runs recorded yet/i), + ).toBeInTheDocument(); + }, +}; + +export const ErrorState: Story = { + beforeEach: () => { + const getChatDebugRunsMock = spyOn( + API.experimental, + "getChatDebugRuns", + ).mockRejectedValue(new Error("Network failure")); + return () => { + getChatDebugRunsMock.mockRestore(); + }; + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // `getErrorMessage` treats any object with a string `message` + // property as an `ApiErrorResponse`, which includes plain `Error` + // instances, so the rejection surfaces via `error.message`. + await waitFor(() => { + expect(canvas.getByText(/network failure/i)).toBeInTheDocument(); + }); + }, +}; + +export const Loading: Story = { + beforeEach: () => { + const pendingRequest = () => new Promise(() => {}); + const getChatDebugRunsMock = spyOn( + API.experimental, + "getChatDebugRuns", + ).mockImplementation(pendingRequest); + return () => { + getChatDebugRunsMock.mockRestore(); + }; + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/loading debug/i)).toBeInTheDocument(); + }, +}; + +// --------------------------------------------------------------------------- +// Run-detail branch stories. +// +// After a run card expands, `DebugRunCard` renders one of three branches +// based on its detail query: a loading spinner, an error Alert, or the +// empty-steps fallback. Each story below pins the detail query into one +// of those states to lock in coverage of the branching logic. +// --------------------------------------------------------------------------- + +const detailProbeRunId = "run-detail-probe"; +const detailProbeSummary = makeRunSummary({ + id: detailProbeRunId, + summary: { first_message: "Detail state probe" }, +}); + +export const RunDetailLoading: Story = { + parameters: { + queries: [ + { + key: ["chats", CHAT_ID, "debug-runs"], + data: [detailProbeSummary], + }, + ], + }, + beforeEach: () => { + const pendingRequest = () => new Promise(() => {}); + const getChatDebugRunMock = spyOn( + API.experimental, + "getChatDebugRun", + ).mockImplementation(pendingRequest); + return () => { + getChatDebugRunMock.mockRestore(); + }; + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + const runTrigger = await canvas.findByRole("button", { + name: /Detail state probe/i, + }); + await user.click(runTrigger); + + await waitFor(() => { + expect(canvas.getByText(/Loading run details/i)).toBeVisible(); + }); + }, +}; + +export const RunDetailError: Story = { + parameters: { + queries: [ + { + key: ["chats", CHAT_ID, "debug-runs"], + data: [detailProbeSummary], + }, + ], + }, + beforeEach: () => { + const getChatDebugRunMock = spyOn( + API.experimental, + "getChatDebugRun", + ).mockRejectedValue(new Error("Unable to fetch run detail")); + return () => { + getChatDebugRunMock.mockRestore(); + }; + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + const runTrigger = await canvas.findByRole("button", { + name: /Detail state probe/i, + }); + await user.click(runTrigger); + + await waitFor(() => { + expect(canvas.getByText(/Unable to fetch run detail/i)).toBeVisible(); + }); + }, +}; + +export const RunWithNoSteps: Story = { + parameters: { + queries: [ + { + key: ["chats", CHAT_ID, "debug-runs"], + data: [detailProbeSummary], + }, + { + key: ["chats", CHAT_ID, "debug-runs", detailProbeRunId], + data: makeRun({ + id: detailProbeRunId, + summary: { first_message: "Detail state probe" }, + steps: [], + }), + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + const runTrigger = await canvas.findByRole("button", { + name: /Detail state probe/i, + }); + await user.click(runTrigger); + + await waitFor(() => { + expect(canvas.getByText(/No steps recorded/i)).toBeVisible(); + }); + }, +}; + +// --------------------------------------------------------------------------- +// Core state stories. +// --------------------------------------------------------------------------- + +export const SingleStepSuccessfulRun: Story = { + 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 }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + // Expand the run and open the first step before checking nested + // content. + const runTrigger = await canvas.findByRole("button", { + name: /Chat Turn/i, + }); + await user.click(runTrigger); + await expandStep(canvas, user); + + await waitFor(() => { + expect(canvas.getByText("Step 1")).toBeVisible(); + expect(canvas.getAllByText(/^Input$/)[0]).toBeVisible(); + expect(canvas.getAllByText(/^Output$/)[0]).toBeVisible(); + }); + + // Request body toggle should be available once the step is open. + expect(canvas.getByText("Request body")).toBeVisible(); + + // Verify a copy button is reachable for normalized body sections. + await user.click(canvas.getByText("Request body")); + await waitFor(() => { + expect( + canvas.getByRole("button", { name: /Copy request body JSON/i }), + ).toBeVisible(); + }); + }, +}; + +export const MultiStepRunWithRetries: Story = { + parameters: { + queries: [ + { + key: ["chats", CHAT_ID, "debug-runs"], + data: [ + makeRunSummary({ + id: multiStepRunDetail.id, + status: multiStepRunDetail.status, + summary: multiStepRunDetail.summary, + started_at: multiStepRunDetail.started_at, + updated_at: multiStepRunDetail.updated_at, + finished_at: multiStepRunDetail.finished_at, + }), + ], + }, + { + key: ["chats", CHAT_ID, "debug-runs", multiStepRunDetail.id], + data: multiStepRunDetail, + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + await user.click(await canvas.findByRole("button", { name: /Chat Turn/i })); + + // Both steps render as collapsed headers after the run expands. + await waitFor(() => { + expect(canvas.getByText("Step 1")).toBeVisible(); + expect(canvas.getByText("Step 2")).toBeVisible(); + }); + await expandStep(canvas, user); + + // Open Step 1 before asserting on its raw attempt content. + await waitFor(() => { + expect(canvas.getByText(/Attempt 1/)).toBeVisible(); + expect(canvas.getByText(/Attempt 2/)).toBeVisible(); + expect(canvas.getByText(/Attempt 3/)).toBeVisible(); + }); + }, +}; + +export const ErrorStateWithRedactedHeaders: Story = { + parameters: { + queries: [ + { + key: ["chats", CHAT_ID, "debug-runs"], + data: [ + makeRunSummary({ + id: errorRunDetail.id, + status: errorRunDetail.status, + summary: errorRunDetail.summary, + started_at: errorRunDetail.started_at, + updated_at: errorRunDetail.updated_at, + finished_at: errorRunDetail.finished_at, + }), + ], + }, + { + key: ["chats", CHAT_ID, "debug-runs", errorRunDetail.id], + data: errorRunDetail, + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + await user.click(await canvas.findByRole("button", { name: /Chat Turn/i })); + await expandStep(canvas, user); + + // Open the step before checking the error section and redaction markers. + // `DebugStepCard` renders `step.error` through `getErrorMessage`, which + // surfaces `error.message` when present. The fixture's `code` + // ("upstream_unauthorized") only appears if the message is missing, so + // assert on the message that the user actually sees. + await waitFor(() => { + expect(canvas.getByText(/Provider request failed/i)).toBeVisible(); + }); + + // Expand request body to reveal the redacted headers. + await user.click(canvas.getByText("Request body")); + await waitFor(() => { + expect( + canvas.getByRole("button", { name: /Copy request body JSON/i }), + ).toBeVisible(); + }); + + // After expanding, verify [REDACTED] markers appear in the + // rendered output (Radix Collapsible hides content until open). + // Use regex since [REDACTED] appears inside larger JSON text + // nodes, not as standalone text content. + await waitFor(() => { + const redactedMarkers = canvas.getAllByText(/\[REDACTED\]/); + expect(redactedMarkers.length).toBeGreaterThan(0); + }); + }, +}; + +export const CompactionAndTitleGenerationBadges: Story = { + parameters: { + queries: [ + { + key: ["chats", CHAT_ID, "debug-runs"], + data: [ + makeRunSummary({ + id: "run-compaction", + kind: "compaction", + status: "in_progress", + provider: "anthropic", + model: "claude-sonnet-4", + started_at: "2026-03-05T12:00:03Z", + updated_at: "2026-03-05T12:00:05Z", + }), + makeRunSummary({ + id: "run-chat-turn", + kind: "chat_turn", + status: "completed", + provider: "openai", + model: "gpt-4.1", + started_at: "2026-03-05T12:00:01Z", + updated_at: "2026-03-05T12:00:02Z", + finished_at: "2026-03-05T12:00:02Z", + }), + makeRunSummary({ + id: "run-title", + kind: "title_generation", + status: "error", + provider: "openai", + model: "gpt-4o-mini", + started_at: "2026-03-05T12:00:02Z", + updated_at: "2026-03-05T12:00:04Z", + finished_at: "2026-03-05T12:00:04Z", + }), + ], + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Verify all three kind badge labels render. + await expect(canvas.getByText(/compaction/i)).toBeInTheDocument(); + await expect(canvas.getByText(/chat turn/i)).toBeInTheDocument(); + await expect(canvas.getByText(/title generation/i)).toBeInTheDocument(); + }, +}; + +export const LongRawPayloads: Story = { + parameters: { + queries: [ + { + key: ["chats", CHAT_ID, "debug-runs"], + data: [ + makeRunSummary({ + id: longPayloadRunDetail.id, + summary: longPayloadRunDetail.summary, + started_at: longPayloadRunDetail.started_at, + updated_at: longPayloadRunDetail.updated_at, + finished_at: longPayloadRunDetail.finished_at, + }), + ], + }, + { + key: ["chats", CHAT_ID, "debug-runs", longPayloadRunDetail.id], + data: longPayloadRunDetail, + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + await user.click(await canvas.findByRole("button", { name: /Chat Turn/i })); + await expandStep(canvas, user); + + await waitFor(() => { + expect(canvas.getByText("Request body")).toBeVisible(); + }); + + // Expand request body to see large payloads. + await user.click(canvas.getByText("Request body")); + await waitFor(() => { + expect(canvas.getByText(/request_24/i)).toBeVisible(); + }); + }, +}; + +// --------------------------------------------------------------------------- +// Payload-specific stories. +// --------------------------------------------------------------------------- + +export const RichPayloadWithTranscript: Story = { + parameters: { + queries: [ + { + key: ["chats", CHAT_ID, "debug-runs"], + data: [ + makeRunSummary({ + id: richRunDetail.id, + summary: richRunDetail.summary, + }), + ], + }, + { + key: ["chats", CHAT_ID, "debug-runs", richRunDetail.id], + data: richRunDetail, + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + // Run header should show the firstMessage as primary label. + const runTrigger = await canvas.findByRole("button", { + name: /Write me a hello world function/i, + }); + await user.click(runTrigger); + const stepTrigger = await expandStep(canvas, user); + + await waitFor(() => { + expect(canvas.getByText("system")).toBeVisible(); + expect(canvas.getByText("user")).toBeVisible(); + }); + + // Message content is rendered. + expect( + canvas.getByText(/You are a helpful coding assistant/), + ).toBeVisible(); + expect( + canvas.getAllByText(/Write me a hello world function in Python/)[0], + ).toBeVisible(); + + // Output section shows response content. + expect(canvas.getByText(/Hello, world!/)).toBeVisible(); + + // The compact step header keeps model/tokens inline and omits the + // operation label. + expect(stepTrigger).toHaveTextContent(/gpt-4/i); + expect(stepTrigger).toHaveTextContent("150→42 tok"); + expect(stepTrigger).not.toHaveTextContent(/LLM Call/i); + + // Pill toggles for Tools and Options are present. + const toolsButton = canvas.getByRole("button", { name: /Tools/i }); + expect(toolsButton).toBeVisible(); + await user.click(toolsButton); + + await waitFor(() => { + expect(canvas.getByText("run_code")).toBeVisible(); + expect(canvas.getByText("search_docs")).toBeVisible(); + }); + + // Toggle Options. + const optionsButton = canvas.getByRole("button", { name: /Options/i }); + await user.click(optionsButton); + await waitFor(() => { + expect(canvas.getByText("temperature")).toBeVisible(); + }); + + // Toggle Usage. + const usageButton = canvas.getByRole("button", { name: /Usage/i }); + await user.click(usageButton); + await waitFor(() => { + expect(canvas.getByText("prompt_tokens")).toBeVisible(); + }); + }, +}; + +export const ToolCallStep: Story = { + parameters: { + queries: [ + { + key: ["chats", CHAT_ID, "debug-runs"], + data: [ + makeRunSummary({ + id: toolCallRunDetail.id, + summary: toolCallRunDetail.summary, + }), + ], + }, + { + key: ["chats", CHAT_ID, "debug-runs", toolCallRunDetail.id], + data: toolCallRunDetail, + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + await user.click( + await canvas.findByRole("button", { name: /Run some code/i }), + ); + await expandStep(canvas, user); + + // Open the step before checking the tool call output. + await waitFor(() => { + expect(canvas.getByText("run_code")).toBeVisible(); + }); + + // Finish reason shown. + expect(canvas.getByText(/tool_calls/)).toBeVisible(); + }, +}; + +export const FallbackLabeledRun: Story = { + parameters: { + queries: [ + { + key: ["chats", CHAT_ID, "debug-runs"], + data: [ + makeRunSummary({ + id: "run-fallback", + summary: {}, + provider: "anthropic", + model: "claude-sonnet-4", + }), + ], + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Without firstMessage, the run header should fall back to the run kind + // while keeping the model inline and omitting the provider label. + const runTrigger = await canvas.findByRole("button", { + name: /Chat Turn/i, + }); + expect(runTrigger).toHaveTextContent(/claude-sonnet-4/i); + expect(runTrigger).not.toHaveTextContent(/Anthropic/i); + }, +}; + +export const InProgressRun: Story = { + parameters: { + queries: [ + { + key: ["chats", CHAT_ID, "debug-runs"], + data: [ + makeRunSummary({ + id: "run-progress", + status: "in_progress", + provider: "openai", + model: "gpt-4", + finished_at: undefined, + }), + ], + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // The compact run header keeps the model and running status inline. + const runTrigger = await canvas.findByRole("button", { + name: /Chat Turn/i, + }); + expect(runTrigger).toHaveTextContent(/gpt-4/i); + expect(runTrigger).toHaveTextContent(/in_progress/i); + expect(runTrigger).not.toHaveTextContent(/Openai/i); + }, +}; + +// --------------------------------------------------------------------------- +// Backend-normalized shape fixtures (messages with parts, content parts, +// attempts with method/path/status). +// --------------------------------------------------------------------------- + +const longToolResultPayload = JSON.stringify({ + value: 4, + explanation: + "Explained via calculator tool. ".repeat(24) + + "The debug panel should clamp this payload until expanded.", + steps: ["parse expression", "compute result", "return integer"], +}); + +const backendNormalizedRequest = makeNormalizedPayloadFixture({ + messages: [ + { + role: "system", + parts: [ + { + type: "text", + text: "You are a calculator. Only output numbers.", + text_length: 42, + }, + ], + }, + { + role: "user", + parts: [ + { + type: "text", + text: "What is 2 + 2?", + text_length: 14, + }, + ], + }, + { + role: "assistant", + parts: [ + { + type: "tool-call", + tool_call_id: "call_abc123", + tool_name: "calculator", + arguments: JSON.stringify({ expression: "2 + 2", format: "integer" }), + }, + ], + }, + { + role: "tool", + parts: [ + { + type: "tool-result", + tool_call_id: "call_abc123", + result: longToolResultPayload, + }, + ], + }, + ], + tools: [ + { + type: "function", + name: "calculator", + description: "Evaluate math", + input_schema: { + type: "object", + properties: { + expression: { type: "string" }, + format: { type: "string", enum: ["integer", "float"] }, + }, + required: ["expression"], + }, + }, + ], + options: { max_output_tokens: 128, temperature: 0 }, + tool_choice: "auto", + provider_option_count: 0, +}); + +const backendNormalizedResponse = makeNormalizedPayloadFixture({ + content: [ + { + type: "tool_call", + tool_call_id: "call_abc123", + tool_name: "calculator", + arguments: JSON.stringify({ expression: "2 + 2", format: "integer" }), + }, + ], + finish_reason: "tool_calls", + usage: { + input_tokens: 42, + output_tokens: 1, + total_tokens: 43, + reasoning_tokens: 0, + cache_creation_tokens: 0, + cache_read_tokens: 0, + }, +}); + +const backendNormalizedAttempts = [ + { + number: 1, + status: "completed", + method: "POST", + url: "https://api.anthropic.com/v1/messages", + path: "/v1/messages", + started_at: "2026-03-05T12:00:06Z", + finished_at: "2026-03-05T12:00:08Z", + request_headers: { "content-type": "application/json" }, + request_body: + '{"model":"claude-sonnet-4","messages":[{"role":"user","content":"What is 2 + 2?"}]}', + response_status: 200, + response_headers: { "content-type": "application/json" }, + response_body: + '{"content":[{"type":"text","text":"4"}],"stop_reason":"end_turn"}', + duration_ms: 1500, + }, +]; + +const backendShapeRunDetail = makeRun({ + id: "run-backend", + provider: "anthropic", + model: "claude-sonnet-4", + summary: { + first_message: "What is 2 + 2?", + endpoint_label: "POST /v1/messages", + step_count: "1", + total_input_tokens: "42", + total_output_tokens: "1", + }, + steps: [ + makeStep({ + id: "step-backend-1", + run_id: "run-backend", + operation: "stream", + normalized_request: backendNormalizedRequest, + normalized_response: backendNormalizedResponse, + usage: { + input_tokens: "42", + output_tokens: "1", + total_tokens: "43", + }, + attempts: makeAttempts(backendNormalizedAttempts), + }), + ], +}); + +export const BackendNormalizedShape: Story = { + parameters: { + queries: [ + { + key: ["chats", CHAT_ID, "debug-runs"], + data: [ + makeRunSummary({ + id: backendShapeRunDetail.id, + provider: "anthropic", + model: "claude-sonnet-4", + summary: backendShapeRunDetail.summary, + }), + ], + }, + { + key: ["chats", CHAT_ID, "debug-runs", backendShapeRunDetail.id], + data: backendShapeRunDetail, + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + // Run header should keep the message and model inline, not provider or + // endpoint labels. + const runTrigger = await canvas.findByRole("button", { + name: /What is 2 \+ 2/i, + }); + expect(runTrigger).toHaveTextContent(/claude-sonnet-4/i); + expect(runTrigger).not.toHaveTextContent(/Anthropic/i); + expect(runTrigger).not.toHaveTextContent(/POST \/v1\/messages/i); + + // Expand the run and open the first step before checking transcript + // content. + await user.click(runTrigger); + await expandStep(canvas, user); + + // Only last 2 messages visible by default. The 4-message transcript + // should be truncated. + await waitFor(() => { + expect(canvas.getByText(/Show all 4 messages/)).toBeVisible(); + }); + + // Expand transcript to show all messages. + await user.click(canvas.getByText(/Show all 4 messages/)); + + await waitFor(() => { + expect(canvas.getByText("system")).toBeVisible(); + expect(canvas.getByText("user")).toBeVisible(); + }); + + // Verify request message text is visible (not just role badges). + expect(canvas.getByText(/You are a calculator/)).toBeVisible(); + // "What is 2 + 2?" appears in both the run header and transcript. + const questionMatches = canvas.getAllByText(/What is 2 \+ 2/); + expect(questionMatches.length).toBeGreaterThanOrEqual(2); + + // The Tools pill exposes the normalized JSON schema. + await user.click(canvas.getByRole("button", { name: /Tools/i })); + await waitFor(() => { + expect(canvas.getAllByText(/expression/).length).toBeGreaterThan(0); + }); + + // Tool transcript rows are structured cards instead of placeholders. + expect(canvas.queryByText(/\[tool call:/)).not.toBeInTheDocument(); + expect(canvas.queryByText(/\[tool result:/)).not.toBeInTheDocument(); + await waitFor(() => { + expect(canvas.getByText(/Explained via calculator tool/)).toBeVisible(); + }); + // Finish reason shown. + expect(canvas.getByText(/Finish.*tool_calls/)).toBeVisible(); + + // Attempt shows method/path and status. + await waitFor(() => { + expect(canvas.getByText(/Attempt 1/)).toBeVisible(); + }); + // "POST /v1/messages" now appears only in the attempt header. + const postMatches = canvas.getAllByText("POST /v1/messages"); + expect(postMatches.length).toBe(1); + expect(canvas.getAllByText("42→1 tok").length).toBeGreaterThan(0); + expect(canvas.getByText("200")).toBeVisible(); + }, +};