From 8c0fe6d5f2e45da36582170cd0dd37362804b9bc Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 22 Apr 2026 15:24:32 +0200 Subject: [PATCH] feat(site): add chat debug API layer and panel utilities (#23919) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add API client methods, React Query builders, and unit tests for the chat debug endpoints. Add `debugPanelUtils` with coercion helpers that transform raw debug step data into structured display models for the Debug panel, and wire debug run streaming into the chat store. This is PR 7/9 in the chat debug logging stack. ### Changes - **API client** (`site/src/api/api.ts`): typed methods for all debug endpoints — list runs, list steps, get/set deployment logging, get/set user logging, set per-chat override. - **React Query builders** (`site/src/api/queries/chats.ts`): `chatDebugRuns`, `chatDebugSteps`, `chatDebugLoggingConfig`, `userDebugLoggingConfig` query/mutation factories with `refetchInterval: 5000` for live polling. - **Debug panel utilities** (`site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.ts`): `coerceStepRequest` / `coerceStepResponse` that recursively parse nested JSONB into `StepRequestViewModel` / `StepResponseViewModel` with `MessagePart`, `ToolDef`, and `ToolCallPart` types. Includes `formatTokenSummary` (compact `3→5 tok` notation) and `compactDuration` helpers. - **Unit tests**: coverage for coercion edge cases and formatting utilities. ### 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** (this PR) 8. Debug panel components and settings 9. Storybook stories --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `anthropic:claude-opus-4-6` • Thinking: `xhigh`_ --- site/.knip.jsonc | 13 +- site/src/api/api.ts | 51 + site/src/api/queries/chatDebugLogging.ts | 36 + site/src/api/queries/chats.test.ts | 73 +- site/src/api/queries/chats.ts | 37 +- .../DebugPanel/debugPanelUtils.test.ts | 928 ++++++++++++ .../RightPanel/DebugPanel/debugPanelUtils.ts | 1249 +++++++++++++++++ 7 files changed, 2357 insertions(+), 30 deletions(-) create mode 100644 site/src/api/queries/chatDebugLogging.ts create mode 100644 site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.test.ts create mode 100644 site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.ts diff --git a/site/.knip.jsonc b/site/.knip.jsonc index 837e3caec1..628d6a8194 100644 --- a/site/.knip.jsonc +++ b/site/.knip.jsonc @@ -7,7 +7,18 @@ "./test/**/*.ts", "./e2e/**/*.ts" ], - "ignore": ["**/*Generated.ts", "src/api/chatModelOptions.ts"], + "ignore": [ + "**/*Generated.ts", + "src/api/chatModelOptions.ts", + // TODO(devtools): debugPanelUtils.ts is staged in PR 7; its exports are + // consumed by the Debug panel components in PRs 8 and 9. Remove this + // exclusion once the panel components land. + "src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.ts", + // TODO(devtools): chatDebugLogging.ts queries are staged in PR 7; + // they are consumed by the Debug settings UI in PR 8. Remove this + // exclusion once the settings page lands. + "src/api/queries/chatDebugLogging.ts" + ], "ignoreBinaries": ["protoc"], "ignoreDependencies": [ "@babel/plugin-syntax-typescript", diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 7a7c246010..bf9cb3868d 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3301,6 +3301,57 @@ class ExperimentalApiMethods { ); }; + getChatDebugLogging = + async (): Promise => { + const response = + await this.axios.get( + "/api/experimental/chats/config/debug-logging", + ); + return response.data; + }; + + updateChatDebugLogging = async ( + req: TypesGen.UpdateChatDebugLoggingAllowUsersRequest, + ): Promise => { + await this.axios.put("/api/experimental/chats/config/debug-logging", req); + }; + + getUserChatDebugLogging = + async (): Promise => { + const response = + await this.axios.get( + "/api/experimental/chats/config/user-debug-logging", + ); + return response.data; + }; + + updateUserChatDebugLogging = async ( + req: TypesGen.UpdateUserChatDebugLoggingRequest, + ): Promise => { + await this.axios.put( + "/api/experimental/chats/config/user-debug-logging", + req, + ); + }; + + getChatDebugRuns = async ( + chatId: string, + ): Promise => { + const response = await this.axios.get( + `/api/experimental/chats/${chatId}/debug/runs`, + ); + return response.data; + }; + + getChatDebugRun = async ( + chatId: string, + runId: string, + ): Promise => { + const response = await this.axios.get( + `/api/experimental/chats/${chatId}/debug/runs/${runId}`, + ); + return response.data; + }; getChatDesktopEnabled = async (): Promise => { const response = diff --git a/site/src/api/queries/chatDebugLogging.ts b/site/src/api/queries/chatDebugLogging.ts new file mode 100644 index 0000000000..dd53f0c0dd --- /dev/null +++ b/site/src/api/queries/chatDebugLogging.ts @@ -0,0 +1,36 @@ +import type { QueryClient } from "react-query"; +import { API } from "#/api/api"; + +const chatDebugLoggingKey = ["chat-debug-logging"] as const; +const userChatDebugLoggingKey = ["user-chat-debug-logging"] as const; + +export const chatDebugLogging = () => ({ + queryKey: chatDebugLoggingKey, + queryFn: () => API.experimental.getChatDebugLogging(), +}); + +export const userChatDebugLogging = () => ({ + queryKey: userChatDebugLoggingKey, + queryFn: () => API.experimental.getUserChatDebugLogging(), +}); + +export const updateChatDebugLogging = (queryClient: QueryClient) => ({ + mutationFn: API.experimental.updateChatDebugLogging, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: chatDebugLoggingKey, + }); + await queryClient.invalidateQueries({ + queryKey: userChatDebugLoggingKey, + }); + }, +}); + +export const updateUserChatDebugLogging = (queryClient: QueryClient) => ({ + mutationFn: API.experimental.updateUserChatDebugLogging, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: userChatDebugLoggingKey, + }); + }, +}); diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 68b13b8c0c..d2a5e495ee 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -9,6 +9,7 @@ import { cancelChatListRefetches, chatCostSummary, chatCostSummaryKey, + chatDebugRunsKey, chatDiffContentsKey, chatKey, chatMessagesKey, @@ -737,6 +738,8 @@ describe("mutation invalidation scope", () => { queryClient.setQueryData(chatKey(chatId), makeChat(chatId)); // Messages: ["chats", chatId, "messages"] queryClient.setQueryData(chatMessagesKey(chatId), []); + // Debug runs: ["chats", chatId, "debug-runs"] + queryClient.setQueryData(chatDebugRunsKey(chatId), []); // Diff contents: ["chats", chatId, "diff-contents"] queryClient.setQueryData(chatDiffContentsKey(chatId), { files: [] }); // Cost summary: ["chats", "costSummary", "me", undefined] @@ -758,13 +761,9 @@ describe("mutation invalidation scope", () => { const chatId = "chat-1"; seedAllActiveQueries(queryClient, chatId); - // createChatMessage has no onSuccess handler — the WebSocket - // stream covers all real-time updates. Verify that constructing - // the mutation config does not define one. const mutation = createChatMessage(queryClient, chatId); - expect(mutation).not.toHaveProperty("onSuccess"); + await mutation.onSuccess?.(); - // Since there is no onSuccess, no queries should be invalidated. for (const { label, key } of unrelatedKeys(chatId)) { const state = queryClient.getQueryState(key); expect( @@ -774,14 +773,18 @@ describe("mutation invalidation scope", () => { } }); - it("createChatMessage does not invalidate chat detail or messages (WebSocket handles these)", async () => { + it("createChatMessage invalidates only debug runs, not chat detail or messages", async () => { const queryClient = createTestQueryClient(); const chatId = "chat-1"; seedAllActiveQueries(queryClient, chatId); - // No onSuccess handler exists. const mutation = createChatMessage(queryClient, chatId); - expect(mutation).not.toHaveProperty("onSuccess"); + await mutation.onSuccess?.(); + + expect( + queryClient.getQueryState(chatDebugRunsKey(chatId))?.isInvalidated, + "chatDebugRunsKey should be invalidated", + ).toBe(true); const chatState = queryClient.getQueryState(chatKey(chatId)); expect( @@ -815,7 +818,7 @@ describe("mutation invalidation scope", () => { } }); - it("editChatMessage invalidates only chat detail and messages", async () => { + it("editChatMessage invalidates chat detail, messages, and debug runs", async () => { const queryClient = createTestQueryClient(); const chatId = "chat-1"; seedAllActiveQueries(queryClient, chatId); @@ -825,8 +828,9 @@ describe("mutation invalidation scope", () => { await new Promise((r) => setTimeout(r, 0)); - // These two should still be invalidated — editing changes - // message content and potentially the chat's updated_at. + // These queries should be invalidated -- editing changes + // message content, may update the chat record, and can start + // a new debug run. const chatState = queryClient.getQueryState(chatKey(chatId)); expect(chatState?.isInvalidated, "chatKey should be invalidated").toBe( true, @@ -837,6 +841,11 @@ describe("mutation invalidation scope", () => { messagesState?.isInvalidated, "chatMessagesKey should be invalidated", ).toBe(true); + + expect( + queryClient.getQueryState(chatDebugRunsKey(chatId))?.isInvalidated, + "chatDebugRunsKey should be invalidated", + ).toBe(true); }); // Shared type for the infinite messages cache shape used by @@ -1170,15 +1179,18 @@ describe("mutation invalidation scope", () => { ); }); - it("interruptChat does not invalidate unrelated queries", async () => { + it("interruptChat invalidates debug runs without touching unrelated queries", async () => { const queryClient = createTestQueryClient(); const chatId = "chat-1"; seedAllActiveQueries(queryClient, chatId); - // interruptChat has no onSuccess handler — the WebSocket - // delivers status changes in real-time. const mutation = interruptChat(queryClient, chatId); - expect(mutation).not.toHaveProperty("onSuccess"); + await mutation.onSuccess?.(); + + expect( + queryClient.getQueryState(chatDebugRunsKey(chatId))?.isInvalidated, + "chatDebugRunsKey should be invalidated", + ).toBe(true); for (const { label, key } of unrelatedKeys(chatId)) { const state = queryClient.getQueryState(key); @@ -1189,13 +1201,18 @@ describe("mutation invalidation scope", () => { } }); - it("promoteChatQueuedMessage does not invalidate unrelated queries", async () => { + it("promoteChatQueuedMessage invalidates debug runs without touching unrelated queries", async () => { const queryClient = createTestQueryClient(); const chatId = "chat-1"; seedAllActiveQueries(queryClient, chatId); const mutation = promoteChatQueuedMessage(queryClient, chatId); - expect(mutation).not.toHaveProperty("onSuccess"); + await mutation.onSuccess?.(); + + expect( + queryClient.getQueryState(chatDebugRunsKey(chatId))?.isInvalidated, + "chatDebugRunsKey should be invalidated", + ).toBe(true); for (const { label, key } of unrelatedKeys(chatId)) { const state = queryClient.getQueryState(key); @@ -1206,6 +1223,28 @@ describe("mutation invalidation scope", () => { } }); + it("regenerateChatTitle invalidates debug runs so the title_generation run surfaces immediately", async () => { + const queryClient = createTestQueryClient(); + const chatId = "chat-1"; + seedAllActiveQueries(queryClient, chatId); + + const mutation = regenerateChatTitle(queryClient); + await mutation.onSettled(undefined, undefined, chatId); + + expect( + queryClient.getQueryState(chatDebugRunsKey(chatId))?.isInvalidated, + "chatDebugRunsKey should be invalidated", + ).toBe(true); + + for (const { label, key } of unrelatedKeys(chatId)) { + const state = queryClient.getQueryState(key); + expect( + state?.isInvalidated, + `${label} should NOT be invalidated by regenerateChatTitle`, + ).not.toBe(true); + } + }); + it("createChat invalidates only sidebar queries on success", async () => { const queryClient = createTestQueryClient(); const chatId = "chat-1"; diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 4c390fe801..e7dcc548eb 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -823,6 +823,7 @@ export const regenerateChatTitle = (queryClient: QueryClient) => ({ queryKey: chatKey(chatId), exact: true, }); + void invalidateChatDebugRuns(queryClient, chatId); }, }); @@ -858,6 +859,15 @@ export const updateChatTitle = (queryClient: QueryClient) => ({ }, }); +export const chatDebugRunsKey = (chatId: string) => + ["chats", chatId, "debug-runs"] as const; + +const invalidateChatDebugRuns = (queryClient: QueryClient, chatId: string) => { + return queryClient.invalidateQueries({ + queryKey: chatDebugRunsKey(chatId), + }); +}; + export const createChat = (queryClient: QueryClient) => ({ mutationFn: (req: TypesGen.CreateChatRequest) => API.experimental.createChat(req), @@ -870,14 +880,14 @@ export const createChat = (queryClient: QueryClient) => ({ }); export const createChatMessage = ( - _queryClient: QueryClient, + queryClient: QueryClient, chatId: string, ) => ({ mutationFn: (req: CreateChatMessageRequestWithClearablePlanMode) => API.experimental.createChatMessage(chatId, req), - // No onSuccess invalidation needed: the per-chat WebSocket delivers - // the response message via upsertDurableMessage, and the global - // watchChats() WebSocket updates the sidebar sort order. + onSuccess: () => { + void invalidateChatDebugRuns(queryClient, chatId); + }, }); type EditChatMessageMutationArgs = { @@ -961,14 +971,15 @@ export const editChatMessage = (queryClient: QueryClient, chatId: string) => ({ queryKey: chatMessagesKey(chatId), exact: true, }); + void invalidateChatDebugRuns(queryClient, chatId); }, }); -export const interruptChat = (_queryClient: QueryClient, chatId: string) => ({ +export const interruptChat = (queryClient: QueryClient, chatId: string) => ({ mutationFn: () => API.experimental.interruptChat(chatId), - // No onSuccess invalidation needed: the per-chat WebSocket - // delivers the status change via setChatStatus, and the global - // watchChats() WebSocket updates the sidebar. + onSuccess: () => { + void invalidateChatDebugRuns(queryClient, chatId); + }, }); export const deleteChatQueuedMessage = ( @@ -990,14 +1001,14 @@ export const deleteChatQueuedMessage = ( }); export const promoteChatQueuedMessage = ( - _queryClient: QueryClient, + queryClient: QueryClient, chatId: string, ) => ({ mutationFn: (queuedMessageId: number) => API.experimental.promoteChatQueuedMessage(chatId, queuedMessageId), - // No onSuccess invalidation needed: the caller upserts the - // promoted message from the response, and the per-chat - // WebSocket delivers queue and status updates in real-time. + onSuccess: () => { + void invalidateChatDebugRuns(queryClient, chatId); + }, }); export const chatDiffContentsKey = (chatId: string) => @@ -1075,6 +1086,8 @@ export const updateChatDesktopEnabled = (queryClient: QueryClient) => ({ }, }); +export * from "./chatDebugLogging"; + const chatWorkspaceTTLKey = ["chat-workspace-ttl"] as const; export const chatWorkspaceTTL = () => ({ diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.test.ts b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.test.ts new file mode 100644 index 0000000000..3abb1e369e --- /dev/null +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.test.ts @@ -0,0 +1,928 @@ +import { + clampContent, + coerceRunSummary, + coerceStepRequest, + coerceStepResponse, + coerceUsageRecord, + compactDuration, + computeDurationMs, + extractTokenCounts, + formatTokenSummary, + getRoleBadgeVariant, + getRunKindLabel, + getStatusBadgeVariant, + isActiveStatus, + normalizeAttempts, +} from "./debugPanelUtils"; + +describe("coerceStepResponse", () => { + it("keeps tool-result content emitted in normalized response parts", () => { + const response = coerceStepResponse({ + content: [ + { + type: "tool-result", + tool_call_id: "call-1", + tool_name: "search_docs", + result: { + matches: ["model.go", "debugPanelUtils.ts"], + }, + }, + ], + }); + + const parsed = JSON.parse(response.content); + expect(parsed).toEqual({ + matches: ["model.go", "debugPanelUtils.ts"], + }); + expect(response.toolCalls).toEqual([]); + expect(response.usage).toEqual({}); + }); + + it.each([ + ["numeric zero", 0, "0"], + ["boolean false", false, "false"], + ["explicit null", null, "null"], + ])("preserves primitive tool-result %s in debug payloads", (_label, result, expected) => { + const response = coerceStepResponse({ + content: [ + { + type: "tool-result", + tool_call_id: "call-1", + tool_name: "probe", + result, + }, + ], + }); + + expect(response.content).toBe(expected); + }); + + it("extracts tool_input streaming deltas as tool calls", () => { + // Interrupted streams emit `tool_input` parts with the accumulated + // arguments before a final `tool_call` summary exists. + const response = coerceStepResponse({ + content: [ + { + type: "tool_input", + tool_call_id: "call-42", + tool_name: "search_docs", + arguments: '{"query":"foo"}', + }, + ], + }); + + expect(response.toolCalls).toEqual([ + { + id: "call-42", + name: "search_docs", + arguments: '{\n "query": "foo"\n}', + }, + ]); + }); + + it("prefers finalized tool_call over the streaming tool_input delta for the same call ID", () => { + const response = coerceStepResponse({ + content: [ + { + type: "tool_input", + tool_call_id: "call-42", + tool_name: "search_docs", + arguments: '{"query":"f', + }, + { + type: "tool_call", + tool_call_id: "call-42", + tool_name: "search_docs", + arguments: '{"query":"foo"}', + }, + ], + }); + + expect(response.toolCalls).toEqual([ + { + id: "call-42", + name: "search_docs", + arguments: '{\n "query": "foo"\n}', + }, + ]); + }); + + it("keeps the finalized payload when tool_call precedes a stray tool_input for the same ID", () => { + const response = coerceStepResponse({ + content: [ + { + type: "tool_call", + tool_call_id: "call-42", + tool_name: "search_docs", + arguments: '{"query":"foo"}', + }, + { + type: "tool_input", + tool_call_id: "call-42", + tool_name: "search_docs", + arguments: '{"query":"bar"}', + }, + ], + }); + + expect(response.toolCalls).toEqual([ + { + id: "call-42", + name: "search_docs", + arguments: '{\n "query": "foo"\n}', + }, + ]); + }); + + it("keeps distinct tool calls with empty tool_call_ids instead of collapsing them", () => { + // Go's zero value for string is "" and ChatStreamToolCall.tool_call_id + // has no `omitempty`, so unset IDs marshal as "" on the wire. Treat + // them as "no id" so two distinct calls don't collide on the same + // dedup Map key. + const response = coerceStepResponse({ + content: [ + { + type: "tool_call", + tool_call_id: "", + tool_name: "search_docs", + arguments: '{"query":"a"}', + }, + { + type: "tool_call", + tool_call_id: "", + tool_name: "calc", + arguments: '{"op":"add"}', + }, + ], + }); + + expect(response.toolCalls).toEqual([ + { + id: undefined, + name: "search_docs", + arguments: '{\n "query": "a"\n}', + }, + { + id: undefined, + name: "calc", + arguments: '{\n "op": "add"\n}', + }, + ]); + }); + + it("keeps per-call entries when multiple distinct tool calls are emitted", () => { + const response = coerceStepResponse({ + content: [ + { + type: "tool_input", + tool_call_id: "call-1", + tool_name: "search_docs", + arguments: '{"query":"a"}', + }, + { + type: "tool_input", + tool_call_id: "call-2", + tool_name: "calc", + arguments: '{"op":"add"}', + }, + ], + }); + + expect(response.toolCalls).toEqual([ + { + id: "call-1", + name: "search_docs", + arguments: '{\n "query": "a"\n}', + }, + { + id: "call-2", + name: "calc", + arguments: '{\n "op": "add"\n}', + }, + ]); + }); + + it("falls back to OpenAI choices when content is absent", () => { + // Raw OpenAI-format response: no top-level `content`, the data + // lives in `choices[0].message`. + const response = coerceStepResponse({ + choices: [ + { + message: { + content: "hello from openai", + tool_calls: [ + { + id: "call-1", + function: { + name: "search_docs", + arguments: '{"query":"foo"}', + }, + }, + ], + }, + finish_reason: "stop", + }, + ], + }); + + expect(response.content).toBe("hello from openai"); + expect(response.toolCalls).toEqual([ + { + id: "call-1", + name: "search_docs", + arguments: '{\n "query": "foo"\n}', + }, + ]); + expect(response.finishReason).toBe("stop"); + }); + + it("reads OpenAI choices content from array text parts", () => { + // Providers sometimes emit `content` as a structured array on the + // choice message instead of a plain string. + const response = coerceStepResponse({ + choices: [ + { + message: { + content: [ + { type: "text", text: "part one " }, + { type: "text", text: "part two" }, + ], + }, + finish_reason: "length", + }, + ], + }); + + expect(response.content).toBe("part one part two"); + expect(response.finishReason).toBe("length"); + }); + + it("collects tool_calls from the OpenAI choice fallback when none come from content", () => { + const response = coerceStepResponse({ + choices: [ + { + message: { + content: "ok", + tool_calls: [ + { + id: "call-9", + function: { name: "lookup", arguments: '{"q":"x"}' }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + }); + + expect(response.toolCalls).toEqual([ + { + id: "call-9", + name: "lookup", + arguments: '{\n "q": "x"\n}', + }, + ]); + expect(response.finishReason).toBe("tool_calls"); + }); + + it("coerces top-level tool_calls when content is a plain string", () => { + const response = coerceStepResponse({ + content: "hello", + tool_calls: [{ id: "c-1", name: "alpha", arguments: '{"q":"a"}' }], + }); + + expect(response.content).toBe("hello"); + expect(response.toolCalls).toEqual([ + { + id: "c-1", + name: "alpha", + arguments: '{\n "q": "a"\n}', + }, + ]); + }); + + it("captures usage, warnings, and model from the response body", () => { + const response = coerceStepResponse({ + content: "done", + usage: { prompt_tokens: "11", completion_tokens: 22 }, + warnings: [ + "string warning", + { message: "object warning" }, + { details: "object details" }, + { other: "ignored" }, + ], + model: "gpt-4o", + }); + + expect(response.usage).toEqual({ + prompt_tokens: 11, + completion_tokens: 22, + }); + expect(response.warnings).toEqual([ + "string warning", + "object warning", + "object details", + ]); + expect(response.model).toBe("gpt-4o"); + }); + + it("returns defaults for non-object input", () => { + const response = coerceStepResponse(null); + + expect(response).toEqual({ + content: "", + toolCalls: [], + finishReason: undefined, + usage: {}, + warnings: [], + model: undefined, + }); + }); + + it("unwraps JSON-string payloads before coercing", () => { + const response = coerceStepResponse( + JSON.stringify({ + content: "via json wrapper", + finish_reason: "stop", + }), + ); + + expect(response.content).toBe("via json wrapper"); + expect(response.finishReason).toBe("stop"); + }); +}); + +describe("getRunKindLabel", () => { + it.each([ + ["chat_turn", "Chat Turn"], + ["title_generation", "Title Generation"], + ["compaction", "Compaction"], + ["quickgen", "Quick Gen"], + ["quick_gen", "Quick Gen"], + ["llm_call", "LLM Call"], + ["post_process", "Post-process"], + ["tool_call", "Tool Call"], + ])("maps %s to the canonical label", (kind, label) => { + expect(getRunKindLabel(kind)).toBe(label); + }); + + it("humanizes unknown kinds with title casing", () => { + expect(getRunKindLabel("custom_kind")).toBe("Custom Kind"); + }); + + it("returns Unknown for blank input", () => { + expect(getRunKindLabel(" ")).toBe("Unknown"); + }); +}); + +describe("getStatusBadgeVariant", () => { + it.each([ + ["completed", "green"], + ["SUCCESS", "green"], + ["failed", "destructive"], + ["interrupted", "destructive"], + ["cancelled", "destructive"], + ["canceled", "destructive"], + ["running", "info"], + ["in_progress", "info"], + ["pending", "warning"], + ["queued", "warning"], + ["mystery", "default"], + ])("maps %s to %s", (status, expected) => { + expect(getStatusBadgeVariant(status)).toBe(expected); + }); +}); + +describe("isActiveStatus", () => { + it.each([ + ["running", true], + ["in_progress", true], + ["processing", true], + ["started", true], + ["completed", false], + ["pending", false], + ])("returns %s-active=%s", (status, expected) => { + expect(isActiveStatus(status)).toBe(expected); + }); +}); + +describe("getRoleBadgeVariant", () => { + it.each([ + ["system", "purple"], + ["user", "info"], + ["assistant", "green"], + ["tool", "warning"], + ["function", "warning"], + ["unknown", "default"], + ])("maps %s to %s", (role, expected) => { + expect(getRoleBadgeVariant(role)).toBe(expected); + }); +}); + +describe("normalizeAttempts", () => { + it("parses array input and sorts by attempt_number", () => { + const result = normalizeAttempts([ + { number: 2, status: "completed" }, + { attempt_number: 1, status: "error" }, + ]); + + expect(result.rawFallback).toBeUndefined(); + expect(result.parsed.map((a) => a.attempt_number)).toEqual([1, 2]); + expect(result.parsed.map((a) => a.status)).toEqual(["error", "completed"]); + }); + + it("parses JSON strings that wrap an array of attempts", () => { + const result = normalizeAttempts( + JSON.stringify([ + { attempt_number: 1, status: "completed", method: "POST" }, + ]), + ); + + expect(result.rawFallback).toBeUndefined(); + expect(result.parsed).toEqual([ + expect.objectContaining({ + attempt_number: 1, + status: "completed", + method: "POST", + }), + ]); + }); + + it("returns an empty array for empty input without a raw fallback", () => { + expect(normalizeAttempts([])).toEqual({ parsed: [] }); + expect(normalizeAttempts({})).toEqual({ parsed: [] }); + }); + + it("parses record-shaped attempts keyed by index", () => { + const result = normalizeAttempts({ + "1": { attempt_number: 1, status: "completed" }, + "2": { attempt_number: 2, status: "error" }, + }); + + expect(result.rawFallback).toBeUndefined(); + expect(result.parsed.map((a) => a.attempt_number)).toEqual([1, 2]); + }); + + it("returns raw fallback for unparsable strings", () => { + const result = normalizeAttempts("not json"); + expect(result.parsed).toEqual([]); + expect(result.rawFallback).toBe("not json"); + }); + + it("returns raw fallback for unsupported types", () => { + const result = normalizeAttempts(42); + expect(result.parsed).toEqual([]); + expect(result.rawFallback).toBe("42"); + }); + + it("decodes base64-encoded request bodies into JSON", () => { + // {"prompt":"hi"} encoded as base64. + const encodedBody = btoa('{"prompt":"hi"}'); + const [attempt] = normalizeAttempts([ + { + attempt_number: 1, + status: "completed", + request_body: encodedBody, + }, + ]).parsed; + + expect(attempt?.raw_request).toEqual({ body: { prompt: "hi" } }); + }); + + it("preserves plain-text bodies that happen to be base64-alphabet", () => { + // "test" is in the base64 alphabet and has length 4, but it is + // almost certainly a literal payload. Decoding it would produce + // mojibake (0xB5 0xEB 0x2D is not valid UTF-8). + const [attempt] = normalizeAttempts([ + { + attempt_number: 1, + status: "completed", + request_body: "test", + response_body: "abcd", + }, + ]).parsed; + + expect(attempt?.raw_request).toEqual({ body: "test" }); + expect(attempt?.raw_response).toEqual({ body: "abcd" }); + }); + + it("decodes base64-encoded non-JSON text", () => { + // Go can emit non-JSON []byte payloads (e.g. plain-text error + // bodies). Once step 2 fails JSON parsing, step 3 should return + // the decoded UTF-8 text. + const encodedBody = btoa("hello world"); + const [attempt] = normalizeAttempts([ + { + attempt_number: 1, + status: "completed", + response_body: encodedBody, + }, + ]).parsed; + + expect(attempt?.raw_response).toEqual({ body: "hello world" }); + }); + + it("captures string and object-shaped errors", () => { + const [stringAttempt, objectAttempt] = normalizeAttempts([ + { attempt_number: 1, status: "error", error: "boom" }, + { + attempt_number: 2, + status: "error", + error: { code: "ETIMEDOUT", detail: "slow" }, + }, + ]).parsed; + + expect(stringAttempt?.error).toBe("boom"); + expect(objectAttempt?.error).toEqual({ code: "ETIMEDOUT", detail: "slow" }); + }); + + it("preserves pre-built raw_request/raw_response records without rebuilding", () => { + const [attempt] = normalizeAttempts([ + { + attempt_number: 1, + status: "completed", + raw_request: { method: "POST", url: "https://api.example/llm" }, + raw_response: { status: 200, body: { ok: true } }, + // These scalar fields should be ignored when raw_request/raw_response + // are already provided. + method: "IGNORED", + request_body: "ignored", + }, + ]).parsed; + + expect(attempt?.raw_request).toEqual({ + method: "POST", + url: "https://api.example/llm", + }); + expect(attempt?.raw_response).toEqual({ + status: 200, + body: { ok: true }, + }); + }); + + it("falls back to the positional index when no attempt_number is provided", () => { + const parsed = normalizeAttempts([ + { status: "completed" }, + { status: "error" }, + ]).parsed; + + expect(parsed.map((a) => a.attempt_number)).toEqual([1, 2]); + }); +}); + +describe("computeDurationMs", () => { + it("computes elapsed time between two ISO timestamps", () => { + expect( + computeDurationMs("2024-01-01T00:00:00.000Z", "2024-01-01T00:00:02.500Z"), + ).toBe(2500); + }); + + it("returns null when startedAt is not parseable", () => { + expect(computeDurationMs("not-a-date")).toBeNull(); + }); + + it("returns null when finishedAt is provided but not parseable", () => { + expect( + computeDurationMs("2024-01-01T00:00:00.000Z", "also-not-a-date"), + ).toBeNull(); + }); + + it("clamps negative durations to zero", () => { + expect( + computeDurationMs("2024-01-01T00:00:10.000Z", "2024-01-01T00:00:05.000Z"), + ).toBe(0); + }); + + it("falls back to current time when finishedAt is omitted", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T00:00:05.000Z")); + try { + expect(computeDurationMs("2024-01-01T00:00:00.000Z")).toBe(5000); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("compactDuration", () => { + it.each([ + [0, "0ms"], + [999, "999ms"], + [1000, "1.0s"], + [1500, "1.5s"], + [59999, "60.0s"], + [60000, "1m"], + [61000, "1m 1s"], + [125000, "2m 5s"], + ])("formats %sms as %s", (ms, expected) => { + expect(compactDuration(ms)).toBe(expected); + }); +}); + +describe("formatTokenSummary", () => { + it("renders both input and output counts", () => { + expect(formatTokenSummary(1200, 340)).toBe("1,200→340 tok"); + }); + + it("renders input-only when output is undefined", () => { + expect(formatTokenSummary(1200, undefined)).toBe("1,200 in"); + }); + + it("renders output-only when input is undefined", () => { + expect(formatTokenSummary(undefined, 340)).toBe("340 out"); + }); + + it("returns an empty string when both counts are undefined", () => { + expect(formatTokenSummary()).toBe(""); + }); +}); + +describe("extractTokenCounts", () => { + it("prefers prompt/completion keys when present", () => { + expect( + extractTokenCounts({ + prompt_tokens: 10, + completion_tokens: 20, + total_tokens: 30, + input_tokens: 99, + output_tokens: 99, + }), + ).toEqual({ input: 10, output: 20, total: 30 }); + }); + + it("falls back to input/output_tokens when prompt/completion are absent", () => { + expect( + extractTokenCounts({ + input_tokens: 5, + output_tokens: 7, + }), + ).toEqual({ input: 5, output: 7, total: undefined }); + }); + + it("returns undefined fields for an empty usage record", () => { + expect(extractTokenCounts({})).toEqual({ + input: undefined, + output: undefined, + total: undefined, + }); + }); +}); + +describe("coerceUsageRecord", () => { + it("coerces string numeric values to numbers", () => { + expect( + coerceUsageRecord({ prompt_tokens: "10", completion_tokens: 20 }), + ).toEqual({ prompt_tokens: 10, completion_tokens: 20 }); + }); + + it("drops non-finite values", () => { + expect(coerceUsageRecord({ a: "abc", b: null, c: 5 })).toEqual({ c: 5 }); + }); + + it("parses usage embedded as a JSON string", () => { + expect(coerceUsageRecord('{"prompt_tokens": 3}')).toEqual({ + prompt_tokens: 3, + }); + }); + + it("returns an empty record for non-object input", () => { + expect(coerceUsageRecord(null)).toEqual({}); + expect(coerceUsageRecord(42)).toEqual({}); + }); +}); + +describe("coerceRunSummary", () => { + it("extracts the primary label and token counts from snake_case fields", () => { + const summary = coerceRunSummary({ + first_message: "Hello", + endpoint_label: "openai/chat", + model: "gpt-4", + provider: "openai", + step_count: 3, + total_input_tokens: 120, + total_output_tokens: 45, + }); + + expect(summary).toEqual({ + primaryLabel: "Hello", + endpointLabel: "openai/chat", + model: "gpt-4", + provider: "openai", + stepCount: 3, + totalInputTokens: 120, + totalOutputTokens: 45, + warnings: [], + }); + }); + + it("falls back to camelCase and alternate token names", () => { + const summary = coerceRunSummary({ + primaryLabel: "Fallback", + promptTokens: "90", + completionTokens: "30", + }); + + expect(summary.primaryLabel).toBe("Fallback"); + expect(summary.totalInputTokens).toBe(90); + expect(summary.totalOutputTokens).toBe(30); + }); + + it("returns defaults for non-object input", () => { + expect(coerceRunSummary(null)).toEqual({ + primaryLabel: "", + endpointLabel: undefined, + model: undefined, + provider: undefined, + stepCount: undefined, + totalInputTokens: undefined, + totalOutputTokens: undefined, + warnings: [], + }); + }); + + it("unwraps JSON-string payloads before coercing", () => { + const summary = coerceRunSummary( + JSON.stringify({ + first_message: "wrapped hello", + provider: "openai", + stepCount: 4, + }), + ); + + expect(summary.primaryLabel).toBe("wrapped hello"); + expect(summary.provider).toBe("openai"); + expect(summary.stepCount).toBe(4); + }); +}); + +describe("coerceStepRequest", () => { + it("coerces messages, tools, and options nested under options/policy", () => { + const request = coerceStepRequest({ + model: "gpt-4", + messages: [ + { role: "system", content: "Be helpful" }, + { role: "user", parts: [{ type: "text", text: "Hi" }] }, + ], + tools: [ + { + type: "function", + function: { + name: "search_docs", + description: "Search the docs", + parameters: { type: "object" }, + }, + }, + ], + options: { + temperature: 0.2, + max_output_tokens: 512, + ignored_field: "drop me", + }, + policy: { + tool_choice: "auto", + parallel_tool_calls: true, + }, + }); + + expect(request.model).toBe("gpt-4"); + expect(request.messages).toHaveLength(2); + expect(request.messages[0]).toMatchObject({ + role: "system", + content: "Be helpful", + }); + expect(request.messages[1]).toMatchObject({ role: "user", content: "Hi" }); + expect(request.tools).toEqual([ + { + name: "search_docs", + description: "Search the docs", + inputSchema: expect.any(String), + }, + ]); + expect(request.options).toEqual({ + temperature: 0.2, + max_output_tokens: 512, + }); + expect(request.policy).toEqual({ + tool_choice: "auto", + parallel_tool_calls: true, + }); + }); + + it("falls back to top-level option fields when no options wrapper is present", () => { + const request = coerceStepRequest({ + temperature: 0.7, + top_p: 0.9, + }); + + expect(request.options).toEqual({ temperature: 0.7, top_p: 0.9 }); + }); + + it("returns defaults for non-object input", () => { + expect(coerceStepRequest(null)).toEqual({ + model: undefined, + messages: [], + tools: [], + options: {}, + policy: {}, + }); + }); + + it("drops tool definitions without a name", () => { + const request = coerceStepRequest({ + tools: [ + { type: "function", function: { description: "nameless" } }, + { + type: "function", + function: { name: "valid", description: "kept" }, + }, + ], + }); + + expect(request.tools).toEqual([ + expect.objectContaining({ name: "valid", description: "kept" }), + ]); + }); + + it("surfaces tool-call message parts with structured kind metadata", () => { + const request = coerceStepRequest({ + messages: [ + { + role: "assistant", + parts: [ + { + type: "tool-call", + tool_call_id: "call-42", + tool_name: "search_docs", + arguments: '{"query":"foo"}', + }, + ], + }, + { + role: "tool", + parts: [ + { + type: "tool-result", + tool_call_id: "call-42", + tool_name: "search_docs", + result: { matches: 3 }, + }, + ], + }, + ], + }); + + expect(request.messages).toHaveLength(2); + expect(request.messages[0]).toMatchObject({ + role: "assistant", + kind: "tool-call", + toolCallId: "call-42", + toolName: "search_docs", + arguments: '{\n "query": "foo"\n}', + }); + expect(request.messages[1]).toMatchObject({ + role: "tool", + kind: "tool-result", + toolCallId: "call-42", + toolName: "search_docs", + result: expect.stringContaining('"matches"'), + }); + }); + + it("unwraps JSON-string payloads including nested options", () => { + const request = coerceStepRequest( + JSON.stringify({ + model: "gpt-4", + messages: [{ role: "user", content: "hi" }], + options: JSON.stringify({ temperature: 0.5 }), + policy: JSON.stringify({ tool_choice: "none" }), + }), + ); + + expect(request.model).toBe("gpt-4"); + expect(request.messages).toHaveLength(1); + expect(request.options).toEqual({ temperature: 0.5 }); + expect(request.policy).toEqual({ tool_choice: "none" }); + }); +}); + +describe("clampContent", () => { + it("returns the trimmed text when under the limit", () => { + expect(clampContent(" hello ", 20)).toBe("hello"); + }); + + it("truncates and appends an ellipsis when over the limit", () => { + expect(clampContent("hello world", 5)).toBe("hello…"); + }); + + it("returns an empty string for whitespace-only input", () => { + expect(clampContent(" ", 10)).toBe(""); + }); + + it("keeps text exactly at the limit unchanged", () => { + expect(clampContent("abcde", 5)).toBe("abcde"); + }); + + it("strips trailing whitespace before appending the ellipsis", () => { + expect(clampContent("abc defghij", 6)).toBe("abc…"); + }); +}); diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.ts b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.ts new file mode 100644 index 0000000000..cedc18637b --- /dev/null +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.ts @@ -0,0 +1,1249 @@ +export interface NormalizedAttempt { + attempt_number: number; + status: string; + method?: string; + url?: string; + path?: string; + raw_request?: Record; + raw_response?: Record; + error?: Record | string; + duration_ms?: number; + started_at?: string; + finished_at?: string; + response_status?: number; +} + +const RUN_KIND_LABELS: Record = { + chat_turn: "Chat Turn", + title_generation: "Title Generation", + compaction: "Compaction", + quickgen: "Quick Gen", + quick_gen: "Quick Gen", + llm_call: "LLM Call", + post_process: "Post-process", + tool_call: "Tool Call", +}; + +const SUCCESS_STATUSES = new Set(["completed", "success", "succeeded", "ok"]); +const WARNING_STATUSES = new Set([ + "pending", + "queued", + "retrying", + "scheduled", +]); +const INFO_STATUSES = new Set([ + "running", + "in_progress", + "processing", + "started", +]); +const ERROR_STATUSES = new Set([ + "failed", + "error", + "errored", + "interrupted", + "cancelled", + "canceled", +]); + +const isRecord = (value: unknown): value is Record => { + return typeof value === "object" && value !== null && !Array.isArray(value); +}; + +const toFiniteNumber = (value: unknown): number | undefined => { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value !== "string" || value.trim() === "") { + return undefined; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +}; + +const toOptionalString = (value: unknown): string | undefined => { + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return undefined; +}; + +const toStringRecord = (value: unknown): Record | undefined => { + if (!isRecord(value)) { + return undefined; + } + + const result: Record = {}; + for (const [key, entry] of Object.entries(value)) { + const normalized = toOptionalString(entry); + if (normalized !== undefined) { + result[key] = normalized; + } + } + + return Object.keys(result).length > 0 ? result : undefined; +}; + +const humanizeToken = (value: string): string => { + return value + .replace(/_/g, " ") + .replace(/\b\w/g, (match) => match.toUpperCase()); +}; + +const safeJsonStringify = (value: unknown): string => { + if (typeof value === "string") { + return value; + } + + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +}; + +const normalizeAttemptEntry = ( + value: unknown, + fallbackAttemptNumber: number, +): NormalizedAttempt | null => { + const candidate = typeof value === "string" ? tryParseJson(value) : value; + if (!isRecord(candidate)) { + return null; + } + + // Support both old shape (attempt_number) and new backend shape (number). + const attemptNumber = + toFiniteNumber(candidate.attempt_number) ?? + toFiniteNumber(candidate.number) ?? + fallbackAttemptNumber; + const status = toOptionalString(candidate.status) ?? "unknown"; + const method = toOptionalString(candidate.method); + const url = toOptionalString(candidate.url); + const path = toOptionalString(candidate.path); + + // Build raw_request from backend fields if direct raw_request is absent. + let rawRequest = toRecord(candidate.raw_request); + if (!rawRequest) { + const reqParts: Record = {}; + if (method) { + reqParts.method = method; + } + if (url) { + reqParts.url = url; + } + if (path) { + reqParts.path = path; + } + const reqHeaders = candidate.request_headers; + if (isRecord(reqHeaders) && Object.keys(reqHeaders).length > 0) { + reqParts.headers = reqHeaders; + } + const reqBody = candidate.request_body; + if (reqBody && typeof reqBody === "string") { + const parsed = + tryParseJson(reqBody) ?? + tryDecodeBase64Json(reqBody) ?? + tryDecodeBase64(reqBody); + reqParts.body = parsed !== undefined ? parsed : reqBody; + } else if (reqBody) { + reqParts.body = reqBody; + } + if (Object.keys(reqParts).length > 0) { + rawRequest = reqParts; + } + } + + // Build raw_response from backend fields if direct raw_response is absent. + let rawResponse = toRecord(candidate.raw_response); + if (!rawResponse) { + const resParts: Record = {}; + const respStatus = toFiniteNumber(candidate.response_status); + if (respStatus !== undefined) { + resParts.status = respStatus; + } + const resHeaders = candidate.response_headers; + if (isRecord(resHeaders) && Object.keys(resHeaders).length > 0) { + resParts.headers = resHeaders; + } + const resBody = candidate.response_body; + if (resBody && typeof resBody === "string") { + const parsed = + tryParseJson(resBody) ?? + tryDecodeBase64Json(resBody) ?? + tryDecodeBase64(resBody); + resParts.body = parsed !== undefined ? parsed : resBody; + } else if (resBody) { + resParts.body = resBody; + } + if (Object.keys(resParts).length > 0) { + rawResponse = resParts; + } + } + + // Error: support both string and object shapes. + let error: Record | string | undefined; + const rawError = candidate.error; + if (typeof rawError === "string" && rawError.length > 0) { + error = rawError; + } else { + error = toStringRecord(rawError); + } + + return { + attempt_number: attemptNumber, + status, + method, + url, + path, + raw_request: rawRequest, + raw_response: rawResponse, + error, + duration_ms: toFiniteNumber(candidate.duration_ms), + started_at: toOptionalString(candidate.started_at), + finished_at: toOptionalString(candidate.finished_at), + response_status: toFiniteNumber(candidate.response_status), + }; +}; + +const toRecord = (value: unknown): Record | undefined => { + if (!isRecord(value)) { + return undefined; + } + return Object.keys(value).length > 0 ? value : undefined; +}; + +const normalizeAttemptList = ( + value: readonly unknown[], +): NormalizedAttempt[] => { + const parsed: NormalizedAttempt[] = []; + + for (const [index, entry] of value.entries()) { + const normalized = normalizeAttemptEntry(entry, index + 1); + if (normalized) { + parsed.push(normalized); + } + } + + return parsed.toSorted( + (left, right) => left.attempt_number - right.attempt_number, + ); +}; + +const tryParseJson = (value: string): unknown => { + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; +/** + * Go's encoding/json marshals []byte fields as base64. Attempt to + * decode a base64 string and then parse the result as JSON. Returns + * the parsed object on success, undefined otherwise. + */ +const tryDecodeBase64Json = (value: string): unknown => { + try { + const binary = atob(value); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + const decoded = new TextDecoder().decode(bytes); + return JSON.parse(decoded); + } catch { + return undefined; + } +}; + +// Matches canonical (Go-emitted) base64: the full base64 alphabet with +// optional trailing `=` padding. `atob` is lenient and will happily decode +// strings like "test" into garbage bytes, so require a strict format before +// attempting a decode. +const STRICT_BASE64 = /^[A-Za-z0-9+/]+={0,2}$/; + +/** + * Try to decode a base64 string to plain text. Go's encoding/json + * marshals []byte as base64, so raw body payloads may appear as + * gibberish in the debug panel unless we decode them. Returns the + * decoded UTF-8 string on success, undefined otherwise. + * + * Gated behind a strict base64 format check and a fatal UTF-8 decode + * so plain-text payloads that happen to be valid base64 alphabet + * (e.g. "test") are preserved as-is instead of being turned into + * mojibake. + */ +const tryDecodeBase64 = (value: string): string | undefined => { + if (value.length === 0 || value.length % 4 !== 0) { + return undefined; + } + if (!STRICT_BASE64.test(value)) { + return undefined; + } + try { + const binary = atob(value); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return undefined; + } +}; + +export const getRunKindLabel = (kind: string): string => { + if (!kind.trim()) { + return "Unknown"; + } + return RUN_KIND_LABELS[kind] ?? humanizeToken(kind); +}; + +export const getStatusBadgeVariant = (status: string) => { + const normalizedStatus = status.trim().toLowerCase(); + if (SUCCESS_STATUSES.has(normalizedStatus)) { + return "green"; + } + if (ERROR_STATUSES.has(normalizedStatus)) { + return "destructive"; + } + if (INFO_STATUSES.has(normalizedStatus)) { + return "info"; + } + if (WARNING_STATUSES.has(normalizedStatus)) { + return "warning"; + } + return "default"; +}; + +export const normalizeAttempts = ( + attempts: unknown, +): { parsed: NormalizedAttempt[]; rawFallback?: string } => { + const source = attempts; + + if (Array.isArray(source)) { + const parsed = normalizeAttemptList(source); + if (parsed.length > 0) { + return { parsed }; + } + return source.length === 0 + ? { parsed: [] } + : { parsed: [], rawFallback: safeJsonStringify(source) }; + } + + if (typeof source === "string") { + const parsedJson = tryParseJson(source); + if (Array.isArray(parsedJson)) { + const parsed = normalizeAttemptList(parsedJson); + if (parsed.length > 0) { + return { parsed }; + } + return parsedJson.length === 0 + ? { parsed: [] } + : { parsed: [], rawFallback: source }; + } + // Handle object-shaped JSON strings (e.g., a dict of attempts + // keyed by index) by treating them as a single-element record. + if (isRecord(parsedJson)) { + const parsed = normalizeAttemptList(Object.values(parsedJson)); + if (parsed.length > 0) { + return { parsed }; + } + } + return { parsed: [], rawFallback: source }; + } + + if (isRecord(source)) { + const parsed: NormalizedAttempt[] = []; + for (const value of Object.values(source)) { + if (typeof value === "string") { + const parsedValue = tryParseJson(value); + if (Array.isArray(parsedValue)) { + parsed.push(...normalizeAttemptList(parsedValue)); + continue; + } + const normalized = normalizeAttemptEntry(value, parsed.length + 1); + if (normalized) { + parsed.push(normalized); + } + continue; + } + + const normalized = normalizeAttemptEntry(value, parsed.length + 1); + if (normalized) { + parsed.push(normalized); + } + } + + if (parsed.length > 0) { + return { + parsed: parsed.toSorted( + (left, right) => left.attempt_number - right.attempt_number, + ), + }; + } + + return Object.keys(source).length === 0 + ? { parsed: [] } + : { parsed: [], rawFallback: safeJsonStringify(source) }; + } + + return { parsed: [], rawFallback: safeJsonStringify(source) }; +}; + +export const computeDurationMs = ( + startedAt: string, + finishedAt?: string, +): number | null => { + const startedAtMs = Date.parse(startedAt); + if (Number.isNaN(startedAtMs)) { + return null; + } + + const finishedAtMs = finishedAt ? Date.parse(finishedAt) : Date.now(); + if (Number.isNaN(finishedAtMs)) { + return null; + } + + return Math.max(0, finishedAtMs - startedAtMs); +}; + +export const compactDuration = (ms: number): string => { + if (ms < 1000) { + return `${Math.round(ms)}ms`; + } + if (ms < 60000) { + return `${(ms / 1000).toFixed(1)}s`; + } + + const totalSeconds = Math.round(ms / 1000); + const mins = Math.floor(totalSeconds / 60); + const secs = totalSeconds % 60; + return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`; +}; + +// --------------------------------------------------------------------------- +// View-model types for coerced debug payloads. +// --------------------------------------------------------------------------- + +interface RunSummaryViewModel { + primaryLabel: string; + endpointLabel: string | undefined; + model: string | undefined; + provider: string | undefined; + stepCount: number | undefined; + totalInputTokens: number | undefined; + totalOutputTokens: number | undefined; + warnings: string[]; +} + +export interface MessagePart { + role: string; + content: string; + toolCallId?: string; + toolName?: string; + kind?: "tool-call" | "tool-result"; + arguments?: string; + result?: string; +} + +interface ToolDef { + name: string; + description?: string; + inputSchema?: string; +} + +interface ToolCallPart { + id?: string; + name: string; + arguments?: string; +} + +interface StepRequestViewModel { + model?: string; + messages: MessagePart[]; + tools: ToolDef[]; + options: Record; + policy: Record; +} + +interface StepResponseViewModel { + content: string; + toolCalls: ToolCallPart[]; + finishReason?: string; + usage: Record; + warnings: string[]; + model?: string; +} + +// --------------------------------------------------------------------------- +// Internal helpers for coercion. +// --------------------------------------------------------------------------- + +/** Look up the first defined value among several possible field names. */ +const pickField = ( + obj: Record, + ...names: string[] +): unknown => { + for (const name of names) { + if (name in obj && obj[name] !== undefined) { + return obj[name]; + } + } + return undefined; +}; + +/** + * If `value` is a JSON string that wraps an object or array, parse and + * return the result. All other values pass through untouched. + */ +const deepParse = (value: unknown): unknown => { + if (typeof value !== "string") { + return value; + } + const trimmed = value.trim(); + if ( + (trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")) + ) { + const parsed = tryParseJson(trimmed); + return parsed !== undefined ? parsed : value; + } + return value; +}; + +const toCodeContent = (value: unknown): string | undefined => { + const parsed = deepParse(value); + if (parsed === undefined) { + return undefined; + } + if (typeof parsed === "string") { + if (parsed.trim() === "") { + return undefined; + } + const reparsed = tryParseJson(parsed); + if (isRecord(reparsed) || Array.isArray(reparsed)) { + return safeJsonStringify(reparsed); + } + return parsed; + } + // Preserve primitive JSON values (null, numbers, booleans) as their + // string representation so tool payloads like `0`, `false`, or + // explicit `null` results are not silently dropped from the debug + // panel. + if ( + parsed === null || + typeof parsed === "number" || + typeof parsed === "boolean" + ) { + return String(parsed); + } + if (isRecord(parsed) || Array.isArray(parsed)) { + return safeJsonStringify(parsed); + } + return undefined; +}; + +const isToolCallPartType = (partType: string): boolean => { + // `tool_input` is the streaming-delta form emitted by the backend + // when a response is interrupted before the final `tool_call` + // summary lands. Treat it as a tool-call invocation so interrupted + // steps still surface which call was in progress. + return ( + partType === "tool-call" || + partType === "tool_call" || + partType === "tool-input" || + partType === "tool_input" + ); +}; + +const isToolResultPartType = (partType: string): boolean => { + return partType === "tool-result" || partType === "tool_result"; +}; + +interface NormalizedMessagePartViewModel { + rendered: string; + kind?: NonNullable; + toolCallId?: string; + toolName?: string; + arguments?: string; + result?: string; +} + +// --------------------------------------------------------------------------- +// Message coercion -- handles both plain objects and stringified entries. +// --------------------------------------------------------------------------- + +const coerceNormalizedMessagePart = ( + part: Record, +): NormalizedMessagePartViewModel => { + const partType = (toOptionalString(part.type) ?? "").trim().toLowerCase(); + const toolName = + toOptionalString(part.tool_name) ?? toOptionalString(part.toolName); + const toolCallId = + toOptionalString(part.tool_call_id) ?? toOptionalString(part.toolCallId); + + if (isToolCallPartType(partType)) { + const label = toolName ?? toolCallId ?? "tool"; + return { + rendered: `[tool call: ${label}]`, + kind: "tool-call", + toolCallId, + toolName, + arguments: toCodeContent(pickField(part, "arguments", "input")), + }; + } + + if (isToolResultPartType(partType)) { + const label = toolCallId ?? toolName ?? "tool"; + return { + rendered: `[tool result: ${label}]`, + kind: "tool-result", + toolCallId, + toolName, + result: + toCodeContent(pickField(part, "result", "output")) ?? + toCodeContent(part.text), + }; + } + + const text = toOptionalString(part.text); + if (text) { + return { rendered: text }; + } + + const filename = toOptionalString(part.filename); + if (filename) { + return { rendered: `[file: ${filename}]` }; + } + + if (partType) { + return { rendered: `[${partType}]` }; + } + + return { rendered: "" }; +}; + +const coerceMessage = (value: unknown): MessagePart | null => { + const parsed = deepParse(value); + if (!isRecord(parsed)) { + return null; + } + + const role = toOptionalString(parsed.role) ?? "unknown"; + + // The backend normalizes messages as { role, parts: [...] }. + // Support that shape alongside older content-based shapes. + let content = ""; + let structuredPart: NormalizedMessagePartViewModel | undefined; + const rawParts = parsed.parts; + if (Array.isArray(rawParts) && rawParts.length > 0) { + const fragments: string[] = []; + const normalizedParts: NormalizedMessagePartViewModel[] = []; + for (const part of rawParts) { + if (typeof part === "string") { + fragments.push(part); + normalizedParts.push({ rendered: part }); + continue; + } + if (!isRecord(part)) { + continue; + } + const normalizedPart = coerceNormalizedMessagePart(part); + normalizedParts.push(normalizedPart); + if (normalizedPart.rendered) { + fragments.push(normalizedPart.rendered); + } + } + content = fragments.join("\n"); + if (normalizedParts.length === 1 && normalizedParts[0]?.kind) { + structuredPart = normalizedParts[0]; + } + } + + // Fallback to content-based shapes (OpenAI / older payloads). + if (!content) { + const rawContent = parsed.content; + if (typeof rawContent === "string") { + content = rawContent; + } else if (Array.isArray(rawContent)) { + const textParts: string[] = []; + for (const part of rawContent) { + if (typeof part === "string") { + textParts.push(part); + } else if (isRecord(part)) { + const text = toOptionalString(part.text); + if (text) { + textParts.push(text); + } + } + } + content = textParts.join("\n"); + } else if (isRecord(rawContent)) { + content = + toOptionalString(rawContent.text) ?? safeJsonStringify(rawContent); + } + } + + // Fallback: try a top-level `text` field (used by some providers). + if (!content) { + const text = toOptionalString(parsed.text); + if (text) { + content = text; + } + } + + return { + role, + content, + toolCallId: + structuredPart?.toolCallId ?? + toOptionalString(pickField(parsed, "tool_call_id", "toolCallId")), + toolName: + structuredPart?.toolName ?? + toOptionalString(pickField(parsed, "name", "tool_name", "toolName")), + kind: structuredPart?.kind, + arguments: structuredPart?.arguments, + result: structuredPart?.result, + }; +}; + +const coerceMessages = (value: unknown): MessagePart[] => { + const parsed = deepParse(value); + if (!Array.isArray(parsed)) { + return []; + } + const result: MessagePart[] = []; + for (const item of parsed) { + const msg = coerceMessage(item); + if (msg) { + result.push(msg); + } + } + return result; +}; + +// --------------------------------------------------------------------------- +// Tool definition coercion. +// --------------------------------------------------------------------------- + +const coerceToolDef = (value: unknown): ToolDef | null => { + const parsed = deepParse(value); + if (!isRecord(parsed)) { + return null; + } + // OpenAI format: { type: "function", function: { name, description } } + const fn = isRecord(parsed.function) ? parsed.function : parsed; + const name = toOptionalString(fn.name); + if (!name) { + return null; + } + return { + name, + description: toOptionalString(fn.description), + inputSchema: + toCodeContent( + pickField(fn, "input_schema", "inputSchema", "parameters"), + ) ?? + toCodeContent( + pickField(parsed, "input_schema", "inputSchema", "parameters"), + ), + }; +}; + +const coerceTools = (value: unknown): ToolDef[] => { + const parsed = deepParse(value); + if (!Array.isArray(parsed)) { + return []; + } + const result: ToolDef[] = []; + for (const item of parsed) { + const tool = coerceToolDef(item); + if (tool) { + result.push(tool); + } + } + return result; +}; + +// --------------------------------------------------------------------------- +// Tool call coercion (from responses). +// --------------------------------------------------------------------------- + +const coerceToolCall = (value: unknown): ToolCallPart | null => { + const parsed = deepParse(value); + if (!isRecord(parsed)) { + return null; + } + const fn = isRecord(parsed.function) ? parsed.function : parsed; + const name = toOptionalString(fn.name) ?? toOptionalString(parsed.name); + if (!name) { + return null; + } + const args = + toCodeContent(pickField(fn, "arguments", "input")) ?? + toCodeContent(pickField(parsed, "arguments", "input")); + return { + // Normalize an empty `tool_call_id` to `undefined` so downstream + // dedup and React key logic treat it as "no id" rather than + // colliding on the same empty string. + id: + toOptionalString(pickField(parsed, "id", "tool_call_id", "toolCallId")) || + undefined, + name, + arguments: args, + }; +}; + +const coerceToolCalls = (value: unknown): ToolCallPart[] => { + const parsed = deepParse(value); + if (!Array.isArray(parsed)) { + return []; + } + const result: ToolCallPart[] = []; + for (const item of parsed) { + const tc = coerceToolCall(item); + if (tc) { + result.push(tc); + } + } + return result; +}; + +// --------------------------------------------------------------------------- +// Known option / policy field extraction. +// --------------------------------------------------------------------------- + +const OPTION_KEYS: readonly string[] = [ + "temperature", + "top_p", + "topP", + "top_k", + "topK", + "max_output_tokens", + "maxOutputTokens", + "max_tokens", + "maxTokens", + "frequency_penalty", + "frequencyPenalty", + "presence_penalty", + "presencePenalty", + "seed", + "stop", +]; + +const POLICY_KEYS: readonly string[] = [ + "tool_choice", + "toolChoice", + "response_format", + "responseFormat", + "structured_output", + "structuredOutput", + "parallel_tool_calls", + "parallelToolCalls", +]; + +const extractKnownFields = ( + obj: Record, + keys: readonly string[], +): Record => { + const result: Record = {}; + for (const key of keys) { + const value = obj[key]; + if (value !== undefined && value !== null) { + result[key] = deepParse(value); + } + } + return result; +}; + +// --------------------------------------------------------------------------- +// Public coercion: run summary. +// --------------------------------------------------------------------------- + +export const coerceRunSummary = (data: unknown): RunSummaryViewModel => { + const defaults: RunSummaryViewModel = { + primaryLabel: "", + endpointLabel: undefined, + model: undefined, + provider: undefined, + stepCount: undefined, + totalInputTokens: undefined, + totalOutputTokens: undefined, + warnings: [], + }; + const parsed = deepParse(data); + if (!isRecord(parsed)) { + return defaults; + } + const firstMessage = toOptionalString( + pickField( + parsed, + "first_message", + "firstMessage", + "primary_label", + "primaryLabel", + ), + ); + return { + primaryLabel: firstMessage ?? "", + endpointLabel: toOptionalString( + pickField(parsed, "endpoint_label", "endpointLabel"), + ), + model: toOptionalString(pickField(parsed, "model")), + provider: toOptionalString(pickField(parsed, "provider")), + stepCount: toFiniteNumber( + pickField(parsed, "step_count", "stepCount", "steps"), + ), + totalInputTokens: toFiniteNumber( + pickField( + parsed, + "total_input_tokens", + "totalInputTokens", + "input_tokens", + "inputTokens", + "prompt_tokens", + "promptTokens", + ), + ), + totalOutputTokens: toFiniteNumber( + pickField( + parsed, + "total_output_tokens", + "totalOutputTokens", + "output_tokens", + "outputTokens", + "completion_tokens", + "completionTokens", + ), + ), + warnings: [], + }; +}; + +// --------------------------------------------------------------------------- +// Public coercion: step request. +// --------------------------------------------------------------------------- + +export const coerceStepRequest = (data: unknown): StepRequestViewModel => { + const defaults: StepRequestViewModel = { + model: undefined, + messages: [], + tools: [], + options: {}, + policy: {}, + }; + const parsed = deepParse(data); + if (!isRecord(parsed)) { + return defaults; + } + // `options` and `policy` can arrive as JSON-string wrappers when the + // payload has been round-tripped through Go's `json.RawMessage`, so + // unwrap them before the `isRecord` branch. + const rawOptions = deepParse(parsed.options); + const rawPolicy = deepParse(parsed.policy); + const optionsSource = isRecord(rawOptions) ? rawOptions : parsed; + const policySource = isRecord(rawPolicy) ? rawPolicy : parsed; + return { + model: toOptionalString(pickField(parsed, "model")), + messages: coerceMessages(pickField(parsed, "messages", "input")), + tools: coerceTools(pickField(parsed, "tools")), + options: extractKnownFields(optionsSource, OPTION_KEYS), + policy: extractKnownFields(policySource, POLICY_KEYS), + }; +}; + +const coerceChoiceContentText = (value: unknown): string => { + if (typeof value === "string") { + return value; + } + if (!Array.isArray(value)) { + return ""; + } + const parts: string[] = []; + for (const item of value) { + if (typeof item === "string") { + parts.push(item); + continue; + } + if (!isRecord(item)) { + continue; + } + const text = toOptionalString(item.text); + if (text) { + parts.push(text); + } + } + return parts.join(""); +}; + +// --------------------------------------------------------------------------- +// Public coercion: step response. +// --------------------------------------------------------------------------- + +export const coerceStepResponse = (data: unknown): StepResponseViewModel => { + const defaults: StepResponseViewModel = { + content: "", + toolCalls: [], + finishReason: undefined, + usage: {}, + warnings: [], + model: undefined, + }; + const parsed = deepParse(data); + if (!isRecord(parsed)) { + return defaults; + } + + // The backend normalizes responses as: + // { content: [{ type, text?, tool_name?, ... }], finish_reason, usage, warnings } + // Support that alongside plain string content and OpenAI choices. + let content = ""; + let toolCalls: ToolCallPart[] = []; + + const rawContent = parsed.content; + if (Array.isArray(rawContent)) { + // Backend normalized content parts. + const textFragments: string[] = []; + const extractedToolCalls: ToolCallPart[] = []; + // Streamed responses can carry a `tool_input` delta followed by a + // final `tool_call` summary for the same call ID. Track each call + // ID so we collapse them into a single row, preferring the + // finalized form when both are present. + const toolCallIndexById = new Map(); + const finalizedToolCallIds = new Set(); + for (const part of rawContent) { + if (typeof part === "string") { + textFragments.push(part); + continue; + } + if (!isRecord(part)) { + continue; + } + const partType = toOptionalString(part.type) ?? ""; + const text = toOptionalString(part.text); + const toolResult = isToolResultPartType(partType) + ? (toCodeContent(pickField(part, "result", "output")) ?? + toCodeContent(part.text)) + : undefined; + if (text) { + textFragments.push(text); + } else if (toolResult) { + textFragments.push(toolResult); + } + // Extract tool calls from content parts. + if (isToolCallPartType(partType)) { + const name = + toOptionalString(part.tool_name) ?? + toOptionalString(part.toolName) ?? + toOptionalString(part.name); + if (!name) { + continue; + } + // Treat an empty `tool_call_id` as "no id" so distinct streamed + // calls don't collide on the same Map key during dedup. Go's + // zero value for string is `""` and `ChatStreamToolCall` + // serializes without `omitempty`, so unset IDs arrive as `""`. + const toolCall: ToolCallPart = { + id: + toOptionalString( + pickField(part, "tool_call_id", "toolCallId", "id"), + ) || undefined, + name, + arguments: toCodeContent(pickField(part, "arguments", "input")), + }; + const isFinalized = + partType === "tool-call" || partType === "tool_call"; + if (toolCall.id === undefined) { + extractedToolCalls.push(toolCall); + continue; + } + const existingIndex = toolCallIndexById.get(toolCall.id); + if (existingIndex === undefined) { + extractedToolCalls.push(toolCall); + toolCallIndexById.set(toolCall.id, extractedToolCalls.length - 1); + if (isFinalized) { + finalizedToolCallIds.add(toolCall.id); + } + } else if (isFinalized && !finalizedToolCallIds.has(toolCall.id)) { + // Replace a partial `tool_input` entry with the finalized + // `tool_call` summary for the same call ID. + extractedToolCalls[existingIndex] = toolCall; + finalizedToolCallIds.add(toolCall.id); + } + // Otherwise: already have a finalized entry (or a duplicate + // partial delta) -- skip to avoid duplicated rows in the + // Debug panel. + } + } + content = textFragments.join(""); + toolCalls = extractedToolCalls; + } else if (typeof rawContent === "string") { + content = rawContent; + } + + // Fallback: OpenAI choices shape. + const choices = deepParse(parsed.choices); + let firstChoice: Record | null = null; + if (Array.isArray(choices) && choices.length > 0 && isRecord(choices[0])) { + firstChoice = choices[0] as Record; + } + if (!content && firstChoice) { + const msg = isRecord(firstChoice.message) + ? firstChoice.message + : firstChoice; + content = + toOptionalString(msg.content) ?? + coerceChoiceContentText(msg.content) ?? + ""; + } + + // Tool calls: merge from direct fields and first choice. + if (toolCalls.length === 0) { + toolCalls = coerceToolCalls(pickField(parsed, "tool_calls", "toolCalls")); + } + if (toolCalls.length === 0 && firstChoice) { + const msg = isRecord(firstChoice.message) + ? firstChoice.message + : firstChoice; + toolCalls = coerceToolCalls( + pickField(msg as Record, "tool_calls", "toolCalls"), + ); + } + + // Finish reason. + let finishReason = toOptionalString( + pickField(parsed, "finish_reason", "finishReason"), + ); + if (!finishReason && firstChoice) { + finishReason = toOptionalString( + pickField(firstChoice, "finish_reason", "finishReason"), + ); + } + + // Usage (within the response body itself). + const usage = coerceUsageRecord(pickField(parsed, "usage")); + + // Warnings -- support both string arrays and object arrays. + const rawWarnings = deepParse(pickField(parsed, "warnings")); + const warnings: string[] = []; + if (Array.isArray(rawWarnings)) { + for (const w of rawWarnings) { + if (typeof w === "string") { + warnings.push(w); + } else if (isRecord(w)) { + const msg = toOptionalString(w.message) ?? toOptionalString(w.details); + if (msg) { + warnings.push(msg); + } + } + } + } + + return { + content, + toolCalls, + finishReason, + usage, + warnings, + model: toOptionalString(parsed.model), + }; +}; + +// --------------------------------------------------------------------------- +// Public coercion: usage record (string values → numbers). +// --------------------------------------------------------------------------- + +export const coerceUsageRecord = (data: unknown): Record => { + const parsed = deepParse(data); + if (!isRecord(parsed)) { + return {}; + } + const result: Record = {}; + for (const [key, val] of Object.entries(parsed)) { + const num = toFiniteNumber(val); + if (num !== undefined) { + result[key] = num; + } + } + return result; +}; + +// --------------------------------------------------------------------------- +// Token extraction and formatting. +// --------------------------------------------------------------------------- + +export const extractTokenCounts = ( + usage: Record, +): { input?: number; output?: number; total?: number } => { + return { + input: usage.prompt_tokens ?? usage.input_tokens, + output: usage.completion_tokens ?? usage.output_tokens, + total: usage.total_tokens, + }; +}; + +export const formatTokenSummary = (input?: number, output?: number): string => { + if (input !== undefined && output !== undefined) { + return `${input.toLocaleString("en-US")}→${output.toLocaleString("en-US")} tok`; + } + if (input !== undefined) { + return `${input.toLocaleString("en-US")} in`; + } + if (output !== undefined) { + return `${output.toLocaleString("en-US")} out`; + } + return ""; +}; + +// --------------------------------------------------------------------------- +// Role badge variant mapping. +// --------------------------------------------------------------------------- + +const ROLE_BADGE_VARIANTS: Record = { + system: "purple", + user: "info", + assistant: "green", + tool: "warning", + function: "warning", +}; + +export const getRoleBadgeVariant = ( + role: string, +): "purple" | "info" | "green" | "warning" | "default" => { + const normalized = role.trim().toLowerCase(); + return ( + (ROLE_BADGE_VARIANTS[normalized] as + | "purple" + | "info" + | "green" + | "warning" + | undefined) ?? "default" + ); +}; + +// --------------------------------------------------------------------------- +// Transcript preview -- collapsed message list logic. +// --------------------------------------------------------------------------- + +/** Default number of messages to show before collapsing. */ +export const TRANSCRIPT_PREVIEW_COUNT = 2; + +/** Max characters to display for a message body in collapsed mode. */ +export const MESSAGE_CONTENT_CLAMP_CHARS = 160; + +/** + * Clamp a message body to a maximum character length, adding an + * ellipsis when truncated. + */ +export const clampContent = (text: string, maxLen: number): string => { + const trimmed = text.trim(); + if (trimmed.length <= maxLen) { + return trimmed; + } + return `${trimmed.slice(0, maxLen).trimEnd()}…`; +}; + +// --------------------------------------------------------------------------- +// Active-status helper (for spinner indicators). +// --------------------------------------------------------------------------- + +export const isActiveStatus = (status: string): boolean => { + return INFO_STATUSES.has(status.trim().toLowerCase()); +};