feat(site): add chat debug API layer and panel utilities (#23919)

## 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`_
This commit is contained in:
Thomas Kosiewski
2026-04-22 15:24:32 +02:00
committed by GitHub
parent b7c2c59931
commit 8c0fe6d5f2
7 changed files with 2357 additions and 30 deletions
+51
View File
@@ -3301,6 +3301,57 @@ class ExperimentalApiMethods {
);
};
getChatDebugLogging =
async (): Promise<TypesGen.ChatDebugLoggingAdminSettings> => {
const response =
await this.axios.get<TypesGen.ChatDebugLoggingAdminSettings>(
"/api/experimental/chats/config/debug-logging",
);
return response.data;
};
updateChatDebugLogging = async (
req: TypesGen.UpdateChatDebugLoggingAllowUsersRequest,
): Promise<void> => {
await this.axios.put("/api/experimental/chats/config/debug-logging", req);
};
getUserChatDebugLogging =
async (): Promise<TypesGen.UserChatDebugLoggingSettings> => {
const response =
await this.axios.get<TypesGen.UserChatDebugLoggingSettings>(
"/api/experimental/chats/config/user-debug-logging",
);
return response.data;
};
updateUserChatDebugLogging = async (
req: TypesGen.UpdateUserChatDebugLoggingRequest,
): Promise<void> => {
await this.axios.put(
"/api/experimental/chats/config/user-debug-logging",
req,
);
};
getChatDebugRuns = async (
chatId: string,
): Promise<TypesGen.ChatDebugRunSummary[]> => {
const response = await this.axios.get<TypesGen.ChatDebugRunSummary[]>(
`/api/experimental/chats/${chatId}/debug/runs`,
);
return response.data;
};
getChatDebugRun = async (
chatId: string,
runId: string,
): Promise<TypesGen.ChatDebugRun> => {
const response = await this.axios.get<TypesGen.ChatDebugRun>(
`/api/experimental/chats/${chatId}/debug/runs/${runId}`,
);
return response.data;
};
getChatDesktopEnabled =
async (): Promise<TypesGen.ChatDesktopEnabledResponse> => {
const response =
+36
View File
@@ -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,
});
},
});
+56 -17
View File
@@ -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";
+25 -12
View File
@@ -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 = () => ({