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:
+5
-1
@@ -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",
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user