mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = () => ({
|
||||
|
||||
Reference in New Issue
Block a user