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

Change-Id: Ic4573ae5d69c1b1a3ba2c32be5fa9ae9b078bea1
Signed-off-by: Thomas Kosiewski <tk@coder.com>
This commit is contained in:
Thomas Kosiewski
2026-04-08 22:31:49 +00:00
parent 74fa6e88e2
commit af1b7a40ad
6 changed files with 1341 additions and 22 deletions
+5 -1
View File
@@ -7,7 +7,11 @@
"./test/**/*.ts",
"./e2e/**/*.ts"
],
"ignore": ["**/*Generated.ts", "src/api/chatModelOptions.ts"],
"ignore": [
"**/*Generated.ts",
"src/api/chatModelOptions.ts",
"src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.ts"
],
"ignoreBinaries": ["protoc"],
"ignoreDependencies": [
"@babel/plugin-syntax-typescript",
+52
View File
@@ -3242,6 +3242,58 @@ class ExperimentalApiMethods {
await this.axios.put("/api/experimental/chats/config/system-prompt", req);
};
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 =
+29 -13
View File
@@ -679,6 +679,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(chatDebugRunsTestKey(chatId), []);
// Diff contents: ["chats", chatId, "diff-contents"]
queryClient.setQueryData(chatDiffContentsKey(chatId), { files: [] });
// Cost summary: ["chats", "costSummary", "me", undefined]
@@ -688,6 +690,9 @@ describe("mutation invalidation scope", () => {
);
};
const chatDebugRunsTestKey = (chatId: string) =>
["chats", chatId, "debug-runs"] as const;
/** Keys that should NEVER be invalidated by chat message mutations
* because they are completely unrelated to the message flow. */
const unrelatedKeys = (chatId: string) => [
@@ -700,13 +705,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(
@@ -716,14 +717,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(chatDebugRunsTestKey(chatId))?.isInvalidated,
"chatDebugRunsKey should be invalidated",
).toBe(true);
const chatState = queryClient.getQueryState(chatKey(chatId));
expect(
@@ -757,7 +762,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);
@@ -767,8 +772,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,
@@ -779,6 +785,11 @@ describe("mutation invalidation scope", () => {
messagesState?.isInvalidated,
"chatMessagesKey should be invalidated",
).toBe(true);
expect(
queryClient.getQueryState(chatDebugRunsTestKey(chatId))?.isInvalidated,
"chatDebugRunsKey should be invalidated",
).toBe(true);
});
// Shared type for the infinite messages cache shape used by
@@ -1131,13 +1142,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(chatDebugRunsTestKey(chatId))?.isInvalidated,
"chatDebugRunsKey should be invalidated",
).toBe(true);
for (const { label, key } of unrelatedKeys(chatId)) {
const state = queryClient.getQueryState(key);
+57 -8
View File
@@ -581,6 +581,15 @@ export const regenerateChatTitle = (queryClient: QueryClient) => ({
},
});
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),
@@ -593,14 +602,17 @@ export const createChat = (queryClient: QueryClient) => ({
});
export const createChatMessage = (
_queryClient: QueryClient,
queryClient: QueryClient,
chatId: string,
) => ({
mutationFn: (req: TypesGen.CreateChatMessageRequest) =>
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: async () => {
await invalidateChatDebugRuns(queryClient, chatId);
},
// The per-chat and sidebar WebSockets cover message/status updates,
// but the Debug panel uses polling. Kick its list query immediately
// so newly-started runs appear without tab switching.
});
type EditChatMessageMutationArgs = {
@@ -684,6 +696,7 @@ export const editChatMessage = (queryClient: QueryClient, chatId: string) => ({
queryKey: chatMessagesKey(chatId),
exact: true,
});
void invalidateChatDebugRuns(queryClient, chatId);
},
});
@@ -713,14 +726,16 @@ 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: async () => {
await invalidateChatDebugRuns(queryClient, chatId);
},
// The caller still upserts the promoted message directly, but the
// Debug panel needs an explicit refresh to discover the new run.
});
export const chatDiffContentsKey = (chatId: string) =>
@@ -764,6 +779,40 @@ export const updateChatDesktopEnabled = (queryClient: QueryClient) => ({
},
});
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,
});
},
});
const chatWorkspaceTTLKey = ["chat-workspace-ttl"] as const;
export const chatWorkspaceTTL = () => ({
@@ -0,0 +1,25 @@
import { coerceStepResponse } 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({});
});
});
File diff suppressed because it is too large Load Diff