diff --git a/site/src/api/queries/chatMessageEdits.test.ts b/site/src/api/queries/chatMessageEdits.test.ts index 0cf726ff85c..480bfaa82a6 100644 --- a/site/src/api/queries/chatMessageEdits.test.ts +++ b/site/src/api/queries/chatMessageEdits.test.ts @@ -1,6 +1,10 @@ +import type { InfiniteData } from "react-query"; import { describe, expect, it } from "vitest"; import type * as TypesGen from "#/api/typesGenerated"; -import { buildOptimisticEditedMessage } from "./chatMessageEdits"; +import { + buildOptimisticEditedMessage, + reconcileEditedMessageInCache, +} from "./chatMessageEdits"; const makeUserMessage = ( content: readonly TypesGen.ChatMessagePart[] = [ @@ -42,3 +46,50 @@ describe("buildOptimisticEditedMessage", () => { expect(message.content).toEqual([existingFilePart]); }); }); + +describe("reconcileEditedMessageInCache", () => { + it("drops messages the edit deleted, such as stale hook notices", () => { + const staleNotice: TypesGen.ChatMessage = { + id: 2, + chat_id: "chat-1", + created_at: "2025-01-01T00:00:00.000Z", + role: "system", + content: [{ type: "text", text: "old hook notice" }], + }; + const newNotice: TypesGen.ChatMessage = { + id: 5, + chat_id: "chat-1", + created_at: "2025-01-01T00:01:00.000Z", + role: "system", + content: [{ type: "text", text: "new hook notice" }], + }; + const replacement: TypesGen.ChatMessage = { + id: 6, + chat_id: "chat-1", + created_at: "2025-01-01T00:01:00.000Z", + role: "user", + content: [{ type: "text", text: "edited prompt" }], + }; + const currentData: InfiniteData = { + pages: [ + { + messages: [staleNotice, makeUserMessage()], + queued_messages: [], + has_more: false, + }, + ], + pageParams: [undefined], + }; + + const reconciled = reconcileEditedMessageInCache({ + currentData, + optimisticMessageId: 1, + responseMessages: [newNotice, replacement], + deletedMessageIds: [staleNotice.id, 1], + }); + + const ids = reconciled?.pages[0]?.messages.map((message) => message.id); + // Reversed from responseMessages: the first page is newest first. + expect(ids).toEqual([replacement.id, newNotice.id]); + }); +}); diff --git a/site/src/api/queries/chatMessageEdits.ts b/site/src/api/queries/chatMessageEdits.ts index 2fbefa12741..84235a007c2 100644 --- a/site/src/api/queries/chatMessageEdits.ts +++ b/site/src/api/queries/chatMessageEdits.ts @@ -117,28 +117,35 @@ export const projectEditedConversationIntoCache = ({ export const reconcileEditedMessageInCache = ({ currentData, optimisticMessageId, - responseMessage, + responseMessages, + deletedMessageIds, }: { currentData: InfiniteData | undefined; optimisticMessageId: number; - responseMessage: TypesGen.ChatMessage; + responseMessages: readonly TypesGen.ChatMessage[]; + deletedMessageIds?: readonly number[]; }): InfiniteData | undefined => { - if (!currentData?.pages?.length) { + if (!currentData?.pages?.length || responseMessages.length === 0) { return currentData; } + const responseIDs = new Set(responseMessages.map((message) => message.id)); + const deletedIDs = new Set(deletedMessageIds ?? []); const replacedPages = currentData.pages.map((page, pageIndex) => { const preservedMessages = page.messages.filter( (message) => - message.id !== optimisticMessageId && message.id !== responseMessage.id, + message.id !== optimisticMessageId && + !responseIDs.has(message.id) && + !deletedIDs.has(message.id), ); if (pageIndex !== 0) { return { ...page, messages: preservedMessages }; } - return { - ...page, - messages: upsertFirstPageMessage(preservedMessages, responseMessage), - }; + let messages = preservedMessages; + for (const responseMessage of responseMessages) { + messages = upsertFirstPageMessage(messages, responseMessage); + } + return { ...page, messages }; }); return { diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index a23e7efd7d3..c382e11e7bd 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -23,6 +23,9 @@ export const chatMessagesKey = (chatId: string) => export const chatPromptsKey = (chatId: string) => ["chats", chatId, "prompts"] as const; +const chatQueueConvergenceKey = (chatId: string) => + ["chats", chatId, "queue-convergence"] as const; + export const chatACLKey = (chatId: string) => ["chats", chatId, "acl"] as const; export type ChatListPRStatusFilter = "draft" | "open" | "merged" | "closed"; @@ -748,6 +751,15 @@ export const chatACL = (chatId: string) => ({ const MESSAGES_PAGE_SIZE = 50; +// The queued messages ride on the uncursored page of the messages endpoint, +// so settling the queue after a promote needs its own request. Refetching +// chatMessagesForInfiniteScroll would reload every page already scrolled. +export const chatQueueConvergence = (chatId: string) => ({ + queryKey: chatQueueConvergenceKey(chatId), + queryFn: () => API.experimental.getChatMessages(chatId), + gcTime: 0, +}); + export const chatMessagesForInfiniteScroll = (chatId: string) => ({ queryKey: chatMessagesKey(chatId), initialPageParam: undefined as number | undefined, @@ -1427,7 +1439,8 @@ export const editChatMessage = (queryClient: QueryClient, chatId: string) => ({ reconcileEditedMessageInCache({ currentData: current, optimisticMessageId: variables.messageId, - responseMessage: response.message, + responseMessages: response.messages ?? [response.message], + deletedMessageIds: response.deleted_message_ids, }), ); }, diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 8187e3ad5fc..7364b71aa42 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { FC } from "react"; import { useRef } from "react"; -import { Outlet } from "react-router"; +import { Outlet, useNavigate } from "react-router"; import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; import { reactRouterOutlet, @@ -83,8 +83,25 @@ const AgentChatPageLayout: FC = () => { // Shared mock data // --------------------------------------------------------------------------- const CHAT_ID = "chat-1"; +const SWITCHED_CHAT_ID = "chat-2"; const MODEL_CONFIG_ID = "model-config-1"; +const AgentChatSwitchHarness: FC = () => { + const navigate = useNavigate(); + return ( + <> + + + + ); +}; + const mockWorkspace: TypesGen.Workspace = { ...MockWorkspace, id: "workspace-1", @@ -2849,3 +2866,237 @@ export const SlashCompactYieldsToPersonalSkill: Story = { expect(compactSpy).not.toHaveBeenCalled(); }, }; + +const promotedQueueHeadChat: TypesGen.Chat = { + id: CHAT_ID, + ...baseChatFields, + title: "Promoted queue head", + status: "error", +}; + +const promotedQueueHeadMessages: TypesGen.ChatMessagesResponse = { + messages: compactCommandMessages.messages, + queued_messages: [ + { + ...MockChatQueuedMessage, + id: 41, + chat_id: CHAT_ID, + content: [{ type: "text", text: "Queued head prompt" }], + }, + ], + has_more: false, +}; + +export const QueuedSendPromotesPreviousHead: Story = { + parameters: { + queries: buildQueries(promotedQueueHeadChat, promotedQueueHeadMessages, { + diffUrl: undefined, + }), + }, + beforeEach: () => { + spyOn(API.experimental, "getUserSkills").mockResolvedValue([]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const promotedHead: TypesGen.ChatMessage = { + ...MockChatMessage, + id: 42, + chat_id: CHAT_ID, + role: "user", + created_at: "2024-01-01T00:01:00Z", + content: [{ type: "text", text: "Queued head prompt" }], + }; + const followUp: TypesGen.ChatQueuedMessage = { + ...MockChatQueuedMessage, + id: 43, + chat_id: CHAT_ID, + content: [{ type: "text", text: "Follow-up prompt" }], + }; + const otherTabPrompt: TypesGen.ChatQueuedMessage = { + ...MockChatQueuedMessage, + id: 44, + chat_id: CHAT_ID, + content: [{ type: "text", text: "Other tab prompt" }], + }; + spyOn(API.experimental, "getChatMessages").mockResolvedValue({ + ...promotedQueueHeadMessages, + queued_messages: [followUp, otherTabPrompt], + }); + const sendSpy = spyOn( + API.experimental, + "createChatMessage", + ).mockResolvedValue({ + queued: true, + messages: [promotedHead], + queued_message: followUp, + }); + + expect(await canvas.findByText("Queued head prompt")).toBeVisible(); + + const editor = await canvas.findByTestId("chat-message-input"); + await userEvent.click(editor); + await userEvent.type(editor, "Follow-up prompt"); + await userEvent.keyboard("{Enter}"); + await waitFor(() => { + expect(sendSpy).toHaveBeenCalledTimes(1); + }); + + const timeline = within(await canvas.findByTestId("conversation-timeline")); + await waitFor(() => { + expect(timeline.getByText("Queued head prompt")).toBeVisible(); + expect(canvas.getAllByText("Queued head prompt")).toHaveLength(1); + expect(canvas.getAllByText("Follow-up prompt")).toHaveLength(1); + expect(timeline.queryByText("Follow-up prompt")).not.toBeInTheDocument(); + expect(canvas.getByText("Other tab prompt")).toBeVisible(); + expect(timeline.queryByText("Other tab prompt")).not.toBeInTheDocument(); + }); + expect(await canvas.findByTestId("live-activity-slot")).toBeVisible(); + }, +}; + +const switchedChat: TypesGen.Chat = { + id: SWITCHED_CHAT_ID, + ...baseChatFields, + title: "Switched chat", + status: "waiting", +}; + +const switchedChatMessage: TypesGen.ChatMessage = { + ...MockChatMessage, + id: 50, + chat_id: SWITCHED_CHAT_ID, + role: "assistant", + content: [{ type: "text", text: "Current chat message" }], +}; + +export const SendResponseAfterChatSwitch: Story = { + render: () => , + parameters: { + queries: [ + ...buildQueries( + { + id: CHAT_ID, + ...baseChatFields, + title: "Original chat", + status: "waiting", + }, + { messages: [], queued_messages: [], has_more: false }, + { diffUrl: undefined }, + ), + { key: chatKey(SWITCHED_CHAT_ID), data: switchedChat }, + { + key: chatMessagesKey(SWITCHED_CHAT_ID), + data: { + pages: [ + { + messages: [switchedChatMessage], + queued_messages: [], + has_more: false, + }, + ], + pageParams: [undefined], + }, + }, + { + key: chatPromptsKey(SWITCHED_CHAT_ID), + data: { prompts: [] } satisfies TypesGen.ChatPromptsResponse, + }, + { + key: chatDiffContentsKey(SWITCHED_CHAT_ID), + data: { chat_id: SWITCHED_CHAT_ID } satisfies TypesGen.ChatDiffContents, + }, + ], + }, + beforeEach: () => { + spyOn(API.experimental, "getUserSkills").mockResolvedValue([]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + let releaseSend: (() => void) | undefined; + const sendGate = new Promise((resolve) => { + releaseSend = resolve; + }); + const sendSpy = spyOn( + API.experimental, + "createChatMessage", + ).mockImplementation(async () => { + await sendGate; + return { + queued: false, + message: { + ...MockChatMessage, + id: 51, + chat_id: CHAT_ID, + role: "user", + content: [ + { type: "text", text: "Stale response from previous chat" }, + ], + }, + }; + }); + + const editor = await canvas.findByTestId("chat-message-input"); + await userEvent.click(editor); + await userEvent.type(editor, "Send before switching"); + await userEvent.keyboard("{Enter}"); + await waitFor(() => { + expect(sendSpy).toHaveBeenCalledTimes(1); + }); + + await userEvent.click(canvas.getByRole("button", { name: "Switch chat" })); + const timeline = within(await canvas.findByTestId("conversation-timeline")); + expect(await timeline.findByText("Current chat message")).toBeVisible(); + + releaseSend?.(); + await waitFor(() => { + expect( + timeline.queryByText("Stale response from previous chat"), + ).not.toBeInTheDocument(); + expect( + canvas.queryByTestId("live-activity-slot"), + ).not.toBeInTheDocument(); + }); + }, +}; + +export const SendRejectedByHookDispatchFailure: Story = { + parameters: { + queries: buildQueries( + { + id: CHAT_ID, + ...baseChatFields, + title: "Hook failure", + status: "waiting", + }, + { messages: [], queued_messages: [], has_more: false }, + { diffUrl: undefined }, + ), + }, + beforeEach: () => { + spyOn(API.experimental, "getUserSkills").mockResolvedValue([]); + spyOn(API.experimental, "createChatMessage").mockRejectedValue({ + isAxiosError: true, + response: { + status: 502, + data: { + message: "Lifecycle hook dispatch failed.", + detail: "Dispatch 0f2c1f3e timed out after 1.5s.", + kind: "hook_dispatch_failed", + }, + }, + }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const editor = await canvas.findByTestId("chat-message-input"); + await userEvent.click(editor); + await userEvent.type(editor, "Trigger the hook failure"); + await userEvent.keyboard("{Enter}"); + + expect(await canvas.findByText("Lifecycle hook failed")).toBeVisible(); + expect( + await canvas.findByText("Dispatch 0f2c1f3e timed out after 1.5s."), + ).toBeVisible(); + expect(canvas.queryByText("Request failed")).not.toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentChatPage.test.ts b/site/src/pages/AgentsPage/AgentChatPage.test.ts index 945a5724e6e..294678d1b07 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.test.ts +++ b/site/src/pages/AgentsPage/AgentChatPage.test.ts @@ -1,16 +1,22 @@ import { act, renderHook } from "@testing-library/react"; import { createRef } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { ChatQueuedMessage } from "#/api/typesGenerated"; -import { MockChatQueuedMessage } from "#/testHelpers/chatEntities"; +import type { ChatMessage, ChatQueuedMessage } from "#/api/typesGenerated"; +import { + MockChatMessage, + MockChatQueuedMessage, +} from "#/testHelpers/chatEntities"; import { createDeferred } from "#/testHelpers/deferred"; import { MockUserOwner, MockWorkspace } from "#/testHelpers/entities"; import { + buildInactiveChatQueueReconciliation, draftInputStorageKeyPrefix, getPersistedDraftInputValue, getWorkspaceOptionsWithLinkedWorkspace, + reconcilePromotedQueueHead, restoreOptimisticRequestSnapshot, runPromoteQueuedMessage, + settlePromotedQueueHead, submitEditAndScroll, useConversationEditingState, waitForPendingChatSettingsSyncs, @@ -284,6 +290,278 @@ describe("runPromoteQueuedMessage", () => { }); }); +describe("reconcilePromotedQueueHead", () => { + const buildQueuedMessage = (id: number, text: string): ChatQueuedMessage => ({ + ...MockChatQueuedMessage, + id, + content: [{ type: "text", text }], + }); + const userMessage: ChatMessage = { ...MockChatMessage, id: 10, role: "user" }; + const toolMessage: ChatMessage = { ...MockChatMessage, id: 9, role: "tool" }; + + it("suppresses the captured head and appends the queued tail", () => { + const store = createChatStore(); + const a = buildQueuedMessage(1, "A"); + const b = buildQueuedMessage(2, "B"); + const tail = buildQueuedMessage(3, "C"); + store.setQueuedMessages([a, b]); + + const reconciled = reconcilePromotedQueueHead( + store, + [toolMessage, userMessage], + a.id, + tail, + ); + + const snapshot = store.getSnapshot(); + expect(snapshot.queuedMessages.map((m) => m.id)).toEqual([b.id, tail.id]); + expect(snapshot.suppressedQueuedMessageIDs.has(a.id)).toBe(true); + expect(reconciled?.map((m) => m.id)).toEqual([b.id, tail.id]); + }); + + it("does not suppress the rotated head when a queue_update already applied", () => { + const store = createChatStore(); + const a = buildQueuedMessage(1, "A"); + const b = buildQueuedMessage(2, "B"); + const c = buildQueuedMessage(3, "C"); + store.setQueuedMessages([b, c]); + + reconcilePromotedQueueHead(store, [userMessage], a.id, c); + + const snapshot = store.getSnapshot(); + expect(snapshot.queuedMessages.map((m) => m.id)).toEqual([b.id, c.id]); + expect(snapshot.suppressedQueuedMessageIDs.has(a.id)).toBe(true); + expect(snapshot.suppressedQueuedMessageIDs.has(b.id)).toBe(false); + expect(snapshot.suppressedQueuedMessageIDs.has(c.id)).toBe(false); + + store.applyAuthoritativeQueuedMessages([a, b, c]); + expect(store.getSnapshot().queuedMessages.map((m) => m.id)).toEqual([ + b.id, + c.id, + ]); + store.applyAuthoritativeQueuedMessages([b, c]); + expect(store.getSnapshot().suppressedQueuedMessageIDs.size).toBe(0); + }); + + it("keeps the response tail when a stale snapshot arrived mid-request", () => { + const store = createChatStore(); + const a = buildQueuedMessage(1, "A"); + const b = buildQueuedMessage(2, "B"); + const c = buildQueuedMessage(3, "C"); + // A pre-send snapshot lands while the POST is in flight; it cannot + // mention the tail the send just created. + store.setQueuedMessages([a]); + store.applyAuthoritativeQueuedMessages([a, b]); + + const next = reconcilePromotedQueueHead(store, [userMessage], a.id, c); + + expect(next?.map((m) => m.id)).toEqual([b.id, c.id]); + }); + + it("drops the response tail once the server reported and removed it", () => { + const store = createChatStore(); + const a = buildQueuedMessage(1, "A"); + const c = buildQueuedMessage(3, "C"); + store.applyAuthoritativeQueuedMessages([a, c]); + store.applyAuthoritativeQueuedMessages([a]); + + const next = reconcilePromotedQueueHead(store, [userMessage], a.id, c); + + expect(next).toEqual([]); + }); + + it("omits the response tail when a newer queue update was observed", () => { + const store = createChatStore(); + const a = buildQueuedMessage(1, "A"); + store.setQueuedMessages([a]); + + const next = reconcilePromotedQueueHead( + store, + [userMessage], + a.id, + undefined, + ); + + expect(next).toEqual([]); + expect(store.getSnapshot().queuedMessages).toEqual([]); + }); + + it("does nothing when no user row was inserted", () => { + const store = createChatStore(); + const a = buildQueuedMessage(1, "A"); + store.setQueuedMessages([a]); + + const reconciled = reconcilePromotedQueueHead( + store, + [toolMessage], + a.id, + buildQueuedMessage(2, "B"), + ); + + const snapshot = store.getSnapshot(); + expect(snapshot.queuedMessages.map((m) => m.id)).toEqual([a.id]); + expect(snapshot.suppressedQueuedMessageIDs.size).toBe(0); + expect(reconciled).toBeUndefined(); + }); + + it("does nothing when no head was captured before the send", () => { + const store = createChatStore(); + + const reconciled = reconcilePromotedQueueHead( + store, + [userMessage], + undefined, + buildQueuedMessage(1, "A"), + ); + + const snapshot = store.getSnapshot(); + expect(snapshot.queuedMessages).toEqual([]); + expect(snapshot.suppressedQueuedMessageIDs.size).toBe(0); + expect(reconciled).toBeUndefined(); + }); +}); + +describe("buildInactiveChatQueueReconciliation", () => { + const buildQueuedMessage = (id: number, text: string): ChatQueuedMessage => ({ + ...MockChatQueuedMessage, + id, + content: [{ type: "text", text }], + }); + const userMessage: ChatMessage = { + ...MockChatMessage, + id: 42, + role: "user", + }; + + it("keeps a message queued while the send was in flight", () => { + const a = buildQueuedMessage(1, "A"); + const b = buildQueuedMessage(2, "B"); + const c = buildQueuedMessage(3, "C"); + + const next = buildInactiveChatQueueReconciliation( + [a, b, c], + [a, b], + [userMessage], + a.id, + undefined, + ); + + expect(next?.map((m) => m.id)).toEqual([b.id, c.id]); + }); + + it("falls back to the pre-send queue when nothing is cached", () => { + const a = buildQueuedMessage(1, "A"); + const b = buildQueuedMessage(2, "B"); + + const next = buildInactiveChatQueueReconciliation( + undefined, + [a, b], + [userMessage], + a.id, + undefined, + ); + + expect(next?.map((m) => m.id)).toEqual([b.id]); + }); +}); + +describe("settlePromotedQueueHead", () => { + const buildQueuedMessage = (id: number, text: string): ChatQueuedMessage => ({ + ...MockChatQueuedMessage, + id, + content: [{ type: "text", text }], + }); + const chatID = "chat-abc-123"; + + it("restores a head the server still has queued", async () => { + const store = createChatStore(); + const a = buildQueuedMessage(1, "A"); + const b = buildQueuedMessage(2, "B"); + store.setActiveChatID(chatID); + store.setQueuedMessages([b]); + store.markQueuedMessagePromoted(a.id); + + const settled = await settlePromotedQueueHead( + store, + chatID, + a.id, + async () => ({ messages: [], has_more: false, queued_messages: [a, b] }), + ); + + expect(settled?.map((m) => m.id)).toEqual([a.id, b.id]); + expect(store.getSnapshot().queuedMessages.map((m) => m.id)).toEqual([ + a.id, + b.id, + ]); + }); + + it("leaves the queue alone when the fetch fails", async () => { + const store = createChatStore(); + const a = buildQueuedMessage(1, "A"); + const b = buildQueuedMessage(2, "B"); + store.setActiveChatID(chatID); + store.setQueuedMessages([b]); + store.markQueuedMessagePromoted(a.id); + + const settled = await settlePromotedQueueHead(store, chatID, a.id, () => + Promise.reject(new Error("offline")), + ); + + expect(settled).toBeUndefined(); + expect(store.getSnapshot().queuedMessages.map((m) => m.id)).toEqual([b.id]); + expect(store.getSnapshot().promotedQueuedMessageIDs.has(a.id)).toBe(true); + }); + + it("returns the filtered queue the store applied", async () => { + const store = createChatStore(); + const a = buildQueuedMessage(1, "A"); + const b = buildQueuedMessage(2, "B"); + const c = buildQueuedMessage(3, "C"); + store.setActiveChatID(chatID); + store.setQueuedMessages([b]); + store.markQueuedMessagePromoted(a.id); + // An overlapping explicit promotion suppresses C, which the server has + // not deleted yet, so the caller must not cache it back. + store.suppressQueuedMessageID(c.id); + + const settled = await settlePromotedQueueHead( + store, + chatID, + a.id, + async () => ({ + messages: [], + has_more: false, + queued_messages: [a, b, c], + }), + ); + + expect(settled?.map((m) => m.id)).toEqual([a.id, b.id]); + }); + + it("discards a response that resolves after navigating to another chat", async () => { + const store = createChatStore(); + const a = buildQueuedMessage(1, "A"); + const b = buildQueuedMessage(2, "B"); + store.setActiveChatID(chatID); + store.setQueuedMessages([b]); + store.markQueuedMessagePromoted(a.id); + + const settled = await settlePromotedQueueHead( + store, + chatID, + a.id, + async () => { + store.setActiveChatID("chat-other"); + store.setQueuedMessages([]); + return { messages: [], has_more: false, queued_messages: [a, b] }; + }, + ); + + expect(settled).toBeUndefined(); + expect(store.getSnapshot().queuedMessages).toEqual([]); + }); +}); + describe("useConversationEditingState", () => { const chatID = "chat-abc-123"; const expectedKey = `${draftInputStorageKeyPrefix}${chatID}`; diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index c96001d1ce2..c7b526579e1 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -7,6 +7,7 @@ import { useState, } from "react"; +import type { QueryClient } from "react-query"; import { useInfiniteQuery, useMutation, @@ -31,6 +32,7 @@ import { chatModelConfigs, chatModels, chatProviderConfigs, + chatQueueConvergence, compactChat, createChatMessage, deleteChatQueuedMessage, @@ -115,6 +117,8 @@ import { import { type ChatDetailError, formatUsageLimitMessage, + isChatHookDeniedResponse, + isChatHookDispatchFailedResponse, isChatUsageLimitExceededResponse, } from "./utils/usageLimitMessage"; @@ -232,6 +236,108 @@ export const runPromoteQueuedMessage = async (params: { } }; +const buildPromotedQueueReconciliation = ( + queuedMessages: readonly TypesGen.ChatQueuedMessage[], + insertedMessages: readonly TypesGen.ChatMessage[], + promotedHeadID: number | undefined, + queuedTail: TypesGen.ChatQueuedMessage | undefined, + hasObservedQueuedMessageID: (id: number) => boolean, +): readonly TypesGen.ChatQueuedMessage[] | undefined => { + if (promotedHeadID === undefined) { + return undefined; + } + if (!insertedMessages.some((message) => message.role === "user")) { + return undefined; + } + const remaining = queuedMessages.filter( + (message) => message.id !== promotedHeadID, + ); + const tailPending = + queuedTail !== undefined && + !remaining.some((message) => message.id === queuedTail.id) && + !hasObservedQueuedMessageID(queuedTail.id); + return tailPending ? [...remaining, queuedTail] : remaining; +}; + +// Prefer an inactive chat's cached queue so messages queued during the send +// are not dropped; fall back when no cache exists. +export const buildInactiveChatQueueReconciliation = ( + cachedQueuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined, + queuedMessagesBeforeSend: readonly TypesGen.ChatQueuedMessage[], + insertedMessages: readonly TypesGen.ChatMessage[], + promotedHeadID: number | undefined, + queuedTail: TypesGen.ChatQueuedMessage | undefined, +): readonly TypesGen.ChatQueuedMessage[] | undefined => + buildPromotedQueueReconciliation( + cachedQueuedMessages ?? queuedMessagesBeforeSend, + insertedMessages, + promotedHeadID, + queuedTail, + () => false, + ); + +// Queue updates may rotate the head before the response arrives. +export const reconcilePromotedQueueHead = ( + store: Pick< + ChatStore, + | "batch" + | "getSnapshot" + | "setQueuedMessages" + | "markQueuedMessagePromoted" + | "hasObservedQueuedMessageID" + >, + insertedMessages: readonly TypesGen.ChatMessage[], + promotedHeadID: number | undefined, + queuedTail: TypesGen.ChatQueuedMessage | undefined, +): readonly TypesGen.ChatQueuedMessage[] | undefined => { + const next = buildPromotedQueueReconciliation( + store.getSnapshot().queuedMessages, + insertedMessages, + promotedHeadID, + queuedTail, + store.hasObservedQueuedMessageID, + ); + if (!next || promotedHeadID === undefined) { + return next; + } + store.batch(() => { + // The promoted user row proves the server deleted its queue row. + store.markQueuedMessagePromoted(promotedHeadID); + store.setQueuedMessages(next); + }); + return next; +}; + +const fetchChatMessages = (queryClient: QueryClient) => (chatID: string) => + queryClient.fetchQuery(chatQueueConvergence(chatID)); + +// A promoted head is suppressed locally, but another tab can queue or +// promote concurrently, so only the server knows the resulting queue. +export const settlePromotedQueueHead = async ( + store: Pick< + ChatStore, + "getQueueConvergenceFence" | "applyPromoteRefetchQueuedMessages" + >, + chatID: string, + promotedHeadID: number, + fetchMessages: (chatID: string) => Promise, +): Promise => { + const baselineFence = store.getQueueConvergenceFence(); + let response: TypesGen.ChatMessagesResponse; + try { + response = await fetchMessages(chatID); + } catch { + // Convergence is best effort; a later authoritative update can correct it. + return undefined; + } + return store.applyPromoteRefetchQueuedMessages( + chatID, + promotedHeadID, + response.queued_messages ?? [], + baselineFence, + ); +}; + export async function submitEditAndScroll({ editMessage, editArgs, @@ -1102,10 +1208,18 @@ const AgentChatPage: FC = () => { }; const aiGatewayDisabled = !useAIGatewayEnabled(); - const { store, clearStreamError, upsertCacheMessages } = useChatStore({ + const { + store, + acceptServerChatStatus, + clearStreamError, + setCacheQueuedMessages, + getCacheQueuedMessages, + upsertCacheMessages, + } = useChatStore({ chatID: agentId, chatMessages: chatMessagesList, chatRecord, + chatRecordUpdatedAt: chatQuery.dataUpdatedAt, chatMessagesData, chatQueuedMessages, setChatErrorReason, @@ -1253,8 +1367,13 @@ const AgentChatPage: FC = () => { setChatErrorReason(agentId, reason); } else if (isApiError(error)) { const detail = error.response?.data?.detail?.trim() || undefined; + const kind = isChatHookDeniedResponse(error.response?.data) + ? "hook_denied" + : isChatHookDispatchFailedResponse(error.response?.data) + ? "hook_dispatch_failed" + : "generic"; const reason: ChatDetailError = { - kind: "generic", + kind, message: getErrorMessage(error, "An unexpected error occurred."), ...(detail ? { detail } : {}), }; @@ -1596,6 +1715,12 @@ const AgentChatPage: FC = () => { onError: (error) => { restoreOptimisticRequestSnapshot(store, previousSnapshot); handleUsageLimitError(error); + // Hook dispatch failures can park an idle chat in error before returning the request error. + acceptServerChatStatus(); + void queryClient.invalidateQueries({ + queryKey: chatKey(agentId), + exact: true, + }); }, }); if (editSelectedModelConfigID) { @@ -1627,6 +1752,11 @@ const AgentChatPage: FC = () => { clearStreamError(); scrollToBottomRef.current?.(); + // An errored-chat send may promote the queue head that existed when the request began. + const queuedMessagesBeforeSend = store.getSnapshot().queuedMessages; + const queueHeadIDBeforeSend = queuedMessagesBeforeSend[0]?.id; + const statusVersionBeforeSend = store.getServerChatStatusVersion(); + // Don't clear stream state before the POST completes. // For queued sends the WebSocket status events handle // clearing; for non-queued sends we clear explicitly @@ -1636,13 +1766,17 @@ const AgentChatPage: FC = () => { response = await sendMessage(request); } catch (error) { handleUsageLimitError(error); + // Hook dispatch failures can park an idle chat in error before returning the request error. + acceptServerChatStatus(); + void queryClient.invalidateQueries({ + queryKey: chatKey(agentId), + exact: true, + }); throw error; } - // When the server accepts the message immediately (not - // queued), clear the stream and insert the user's message - // so it appears in the timeline without waiting for the - // WebSocket stream. - if (!response.queued) { + const isActiveChat = store.getActiveChatID() === agentId; + // Waiting for the WebSocket on non-queued sends leaves stale stream state visible. + if (!response.queued && isActiveChat) { store.clearStreamState(); // Optimistically set status to "running" so the // Thinking indicator appears immediately. @@ -1653,9 +1787,55 @@ const AgentChatPage: FC = () => { // to error/pending instead, the WebSocket event // overrides this optimistic value. store.setChatStatus("running"); - if (response.message) { - store.upsertDurableMessage(response.message); - upsertCacheMessages([response.message]); + } + // Upsert the full batch because a queued send can insert a promoted head below + // the highest cached ID, which a reconnect would skip. + const insertedMessages = + response.messages ?? (response.message ? [response.message] : []); + if (insertedMessages.length > 0) { + upsertCacheMessages(insertedMessages); + if (isActiveChat) { + store.upsertDurableMessages(insertedMessages); + } + if (response.queued) { + const reconciledQueue = isActiveChat + ? reconcilePromotedQueueHead( + store, + insertedMessages, + queueHeadIDBeforeSend, + response.queued_message, + ) + : buildInactiveChatQueueReconciliation( + getCacheQueuedMessages(), + queuedMessagesBeforeSend, + insertedMessages, + queueHeadIDBeforeSend, + response.queued_message, + ); + if (reconciledQueue) { + setCacheQueuedMessages(reconciledQueue); + // A promoted head starts a turn, but any server status received during the + // request is newer and must win. + if ( + isActiveChat && + store.getServerChatStatusVersion() === statusVersionBeforeSend + ) { + store.clearStreamState(); + store.setChatStatus("running"); + } + if (isActiveChat && queueHeadIDBeforeSend !== undefined) { + void settlePromotedQueueHead( + store, + agentId, + queueHeadIDBeforeSend, + fetchChatMessages(queryClient), + ).then((settled) => { + if (settled) { + setCacheQueuedMessages(settled); + } + }); + } + } } } if (selectedModelConfigID) { diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 5e72778eb50..56524349131 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -816,6 +816,84 @@ export const UsageLimitExceeded: Story = { }, }; +export const HookDispatchFailed: Story = { + args: { + ...defaultArgs, + createError: Object.assign( + new Error("Request failed with status code 502"), + { + isAxiosError: true, + response: { + status: 502, + statusText: "Bad Gateway", + data: { + kind: "hook_dispatch_failed", + message: "Chat lifecycle hook dispatch failed.", + detail: + "Lifecycle hook dispatch 00000000-0000-0000-0000-000000000001 failed (http_error).", + }, + headers: {}, + config: {}, + }, + config: {}, + toJSON: () => ({}), + }, + ), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Lifecycle hook failed")).toBeVisible(); + await expect( + canvas.getByText("Chat lifecycle hook dispatch failed."), + ).toBeVisible(); + await expect( + canvas.getByText( + "Lifecycle hook dispatch 00000000-0000-0000-0000-000000000001 failed (http_error).", + ), + ).toBeVisible(); + await expect(canvas.queryByText("Stack Trace")).not.toBeInTheDocument(); + await expect(canvas.queryByText("Response data")).not.toBeInTheDocument(); + }, +}; + +export const HookDenied: Story = { + args: { + ...defaultArgs, + createError: Object.assign( + new Error("Request failed with status code 403"), + { + isAxiosError: true, + response: { + status: 403, + statusText: "Forbidden", + data: { + kind: "hook_denied", + message: "This prompt is blocked by policy.", + }, + headers: {}, + config: {}, + }, + config: {}, + toJSON: () => ({}), + }, + ), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText("This prompt is blocked by policy."), + ).toBeVisible(); + await expect( + canvas.queryByText("Blocked by policy"), + ).not.toBeInTheDocument(); + await expect( + canvas.queryByText("Go to workspaces"), + ).not.toBeInTheDocument(); + await expect(canvas.queryByText("Stack Trace")).not.toBeInTheDocument(); + await expect(canvas.queryByText("Response data")).not.toBeInTheDocument(); + }, +}; + export const ForbiddenErrorWithRole: Story = { args: { ...defaultArgs, diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 358a1d70b3c..7ce7a50971f 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -6,7 +6,7 @@ import { isApiError } from "#/api/errors"; import { permittedOrganizations } from "#/api/queries/organizations"; import type * as TypesGen from "#/api/typesGenerated"; import type { AgentChatSendShortcut } from "#/api/typesGenerated"; -import { Alert, AlertDescription } from "#/components/Alert/Alert"; +import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Button } from "#/components/Button/Button"; import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; @@ -27,10 +27,13 @@ import { } from "../utils/reasoningEffort"; import { formatUsageLimitMessage, + isChatHookDeniedResponse, + isChatHookDispatchFailedResponse, isChatUsageLimitExceededResponse, } from "../utils/usageLimitMessage"; import { AgentChatInput } from "./AgentChatInput"; import { ChatAccessDeniedAlert } from "./ChatAccessDeniedAlert"; +import { getErrorTitle } from "./ChatConversation/chatStatusHelpers"; import type { ModelSelectorOption } from "./ChatElements"; import { CompactOrgSelector } from "./ChatElements"; import { @@ -527,6 +530,30 @@ export const AgentCreateForm: FC = ({ {formatUsageLimitMessage(createError.response.data)} + ) : isApiError(createError) && + createError.response.status === 502 && + isChatHookDispatchFailedResponse(createError.response.data) ? ( + + + {getErrorTitle("hook_dispatch_failed", "error")} + + + {createError.response.data.message} + {createError.response.data.detail && ( + + {createError.response.data.detail} + + )} + + + ) : isApiError(createError) && + createError.response.status === 403 && + isChatHookDeniedResponse(createError.response.data) ? ( + + + {createError.response.data.message} + + ) : ( ) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index e58d45bdd1c..2e120d34a57 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -405,6 +405,108 @@ const meta: Meta = { export default meta; type Story = StoryObj; +export const LifecycleHookNotice: Story = { + args: { + ...defaultArgs, + parsedMessages: buildMessages([ + { + ...baseMessage, + id: 1, + role: "system", + content: [ + { + type: "text", + text: "Your organization requires an approval before deployment.", + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const notice = canvas.getByRole("note"); + expect(notice).toBeVisible(); + expect(within(notice).getByText("Lifecycle hook")).toBeVisible(); + expect( + within(notice).getByText( + "Your organization requires an approval before deployment.", + ), + ).toBeVisible(); + expect( + canvas.queryByRole("button", { name: "Copy message" }), + ).not.toBeInTheDocument(); + }, +}; + +export const LifecycleHookNoticeOnUserMessage: Story = { + args: { + ...defaultArgs, + urlTransform: (url) => + url.replace("http://localhost:3000", "https://proxy.example.com"), + parsedMessages: buildMessages([ + { + ...baseMessage, + id: 1, + role: "user", + content: [ + { type: "text", text: "original prompt" }, + { + type: "hook-notice", + text: "Deployment context was added: [policy](http://localhost:3000/policy)", + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const notice = canvas.getByRole("note"); + expect(notice).toBeVisible(); + expect(within(notice).getByText("Lifecycle hook")).toBeVisible(); + const prompt = canvas.getByText("original prompt"); + expect(prompt).toBeVisible(); + expect( + prompt.compareDocumentPosition(notice) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + const link = within(notice).getByRole("link", { name: "policy" }); + expect(link).toHaveAttribute("href", "https://proxy.example.com/policy"); + }, +}; + +export const LifecycleHookNoticeAfterEditedMessage: Story = { + args: { + ...defaultArgs, + editingMessageId: 1, + parsedMessages: buildMessages([ + { + ...baseMessage, + id: 1, + role: "user", + content: [{ type: "text", text: "prompt being edited" }], + }, + { + ...baseMessage, + id: 2, + role: "user", + content: [ + { type: "text", text: "later prompt" }, + { + type: "hook-notice", + text: "Deployment context was added: [policy](http://localhost:3000/policy)", + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("prompt being edited")).toBeVisible(); + const link = canvas.getByRole("link", { name: "policy" }); + link.focus(); + expect(link).not.toHaveFocus(); + }, +}; + export const DurableListTemplatesToolLifecycle: Story = { args: { ...defaultArgs, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index db86c1087b4..6a84edcc895 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -1,8 +1,14 @@ -import { ChevronLeftIcon, ChevronRightIcon, PencilIcon } from "lucide-react"; +import { + ChevronLeftIcon, + ChevronRightIcon, + InfoIcon, + PencilIcon, +} from "lucide-react"; import { type FC, Fragment, memo, + type ReactNode, useLayoutEffect, useRef, useState, @@ -14,6 +20,7 @@ import { preferenceSettings } from "#/api/queries/users"; import type * as TypesGen from "#/api/typesGenerated"; import type { ThinkingDisplayMode } from "#/api/typesGenerated"; +import { AlertTitle } from "#/components/Alert/Alert"; import { Button } from "#/components/Button/Button"; import { CopyButton } from "#/components/CopyButton/CopyButton"; import { @@ -510,6 +517,31 @@ export const BlockList: FC<{ ); }; +// Avoid announcing historical hook notices as live alerts. +const TimelineNotice: FC<{ children?: ReactNode }> = ({ children }) => ( +
+
+ +
{children}
+
+
+); + +const LifecycleHookNotice: FC<{ + children: string; + urlTransform?: UrlTransform; +}> = ({ children, urlTransform }) => ( + +
+ Lifecycle hook + {children} +
+
+); + const ChatMessageItem = memo<{ message: TypesGen.ChatMessage; parsed: ParsedMessageContent; @@ -589,6 +621,22 @@ const ChatMessageItem = memo<{ if (displayState.shouldHide) { return null; } + if (message.role === "system") { + return ( +
+ + {parsed.markdown} + +
+ ); + } const conversationItemProps: { role: "user" | "assistant" } = { role: isUser ? "user" : "assistant", @@ -600,6 +648,7 @@ const ChatMessageItem = memo<{ isAfterEditingMessage && "opacity-40 pointer-events-none", "group/msg relative transition-opacity duration-200", )} + inert={isAfterEditingMessage ? true : undefined} > {isUser ? ( @@ -645,6 +694,14 @@ const ChatMessageItem = memo<{ )} + {parsed.hookNotices.map((notice, index) => ( + + {notice} + + ))} {!hideActions && (displayState.hasCopyableContent || (isUser && onEditUserMessage)) && ( @@ -777,6 +834,7 @@ const StickyUserMessage = memo<{ nextUserMessageId?: number; onJumpToUserMessage?: (messageId: number) => void; registerSentinel?: (messageId: number, el: HTMLDivElement | null) => void; + urlTransform?: UrlTransform; }>( ({ message, @@ -788,6 +846,7 @@ const StickyUserMessage = memo<{ nextUserMessageId, onJumpToUserMessage, registerSentinel, + urlTransform, }) => { const [isStuck, setIsStuck] = useState(false); const [isReady, setIsReady] = useState(false); @@ -1016,6 +1075,11 @@ const StickyUserMessage = memo<{ ? { opacity: "calc(1 - var(--overlay-ready, 0))" } : undefined } + // While the overlay copy is shown, drop the flow copy + // from the accessibility tree so the message and its + // hook notices aren't exposed twice. + aria-hidden={isStuck && !isTooTall ? true : undefined} + inert={isStuck && !isTooTall ? true : undefined} > @@ -1071,6 +1136,7 @@ const StickyUserMessage = memo<{ prevUserMessageId={prevUserMessageId} nextUserMessageId={nextUserMessageId} onJumpToUserMessage={onJumpToUserMessage} + urlTransform={urlTransform} fadeFromBottom /> @@ -1089,6 +1155,10 @@ function computeLastInChainFlags( let nextVisibleIsUser = true; for (let i = displayMessages.length - 1; i >= 0; i--) { const entry = displayMessages[i]; + if (entry.message.role === "system") { + nextVisibleIsUser = true; + continue; + } if (entry.message.role !== "user") { flags[i] = nextVisibleIsUser; } @@ -1265,6 +1335,7 @@ export const ConversationTimeline = memo( nextUserMessageId={userNeighborsById.get(message.id)?.nextId} onJumpToUserMessage={jumpToUserMessage} registerSentinel={registerSentinel} + urlTransform={urlTransform} /> ); } diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts index 51550c5042c..951eb81c6a9 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts @@ -48,6 +48,10 @@ export const getErrorTitle = ( return "Provider disabled"; case "content_filter": return "Response blocked"; + case "hook_dispatch_failed": + return "Lifecycle hook failed"; + case "hook_denied": + return "Blocked by policy"; default: return mode === "retry" ? "Retrying request" : "Request failed"; } diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts index 2087397b8eb..7e610f5c2de 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts @@ -32,6 +32,8 @@ const makeQueuedMessage = ( content: [{ type: "text", text }], }) as TypesGen.ChatQueuedMessage; +const testChatID = "chat-1"; + // --------------------------------------------------------------------------- // replaceMessages // --------------------------------------------------------------------------- @@ -460,8 +462,7 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => { store.suppressQueuedMessageID(b.id); expect(store.getSnapshot().suppressedQueuedMessageIDs.has(b.id)).toBe(true); - // Transient reordered queue from the running-case backend - // must not surface the suppressed message. + // Running-case promotion only reorders the queue; the backend still reports the row. store.applyAuthoritativeQueuedMessages([b, a, c]); expect( store.getSnapshot().queuedMessages.map((message) => message.id), @@ -491,6 +492,272 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => { ).toEqual([a.id, c.id]); }); + it("still applies newly queued messages while a suppressed message stays queued", () => { + const store = createChatStore(); + const a = makeQueuedMessage(1, "A"); + const b = makeQueuedMessage(2, "B"); + const d = makeQueuedMessage(4, "D"); + + store.setQueuedMessages([b]); + store.suppressQueuedMessageID(a.id); + + store.applyAuthoritativeQueuedMessages([a, b, d]); + expect( + store.getSnapshot().queuedMessages.map((message) => message.id), + ).toEqual([b.id, d.id]); + expect(store.getSnapshot().suppressedQueuedMessageIDs.has(a.id)).toBe(true); + }); + + it("ignores stale snapshots that still list a promoted message", () => { + const store = createChatStore(); + const a = makeQueuedMessage(1, "A"); + const b = makeQueuedMessage(2, "B"); + const c = makeQueuedMessage(3, "C"); + + store.setQueuedMessages([b, c]); + store.markQueuedMessagePromoted(a.id); + + store.applyAuthoritativeQueuedMessages([a, b]); + expect( + store.getSnapshot().queuedMessages.map((message) => message.id), + ).toEqual([b.id, c.id]); + expect(store.getSnapshot().promotedQueuedMessageIDs.has(a.id)).toBe(true); + + store.applyAuthoritativeQueuedMessages([b, c]); + expect( + store.getSnapshot().queuedMessages.map((message) => message.id), + ).toEqual([b.id, c.id]); + expect(store.getSnapshot().promotedQueuedMessageIDs.size).toBe(0); + expect(store.getSnapshot().suppressedQueuedMessageIDs.size).toBe(0); + }); + + it("records queued IDs the server has reported", () => { + const store = createChatStore(); + const a = makeQueuedMessage(1, "A"); + const b = makeQueuedMessage(2, "B"); + const c = makeQueuedMessage(3, "C"); + + expect(store.hasObservedQueuedMessageID(a.id)).toBe(false); + store.applyAuthoritativeQueuedMessages([a, b]); + expect(store.hasObservedQueuedMessageID(a.id)).toBe(true); + expect(store.hasObservedQueuedMessageID(b.id)).toBe(true); + + store.applyAuthoritativeQueuedMessages([b]); + expect(store.hasObservedQueuedMessageID(a.id)).toBe(true); + + store.setQueuedMessages([b, c]); + expect(store.hasObservedQueuedMessageID(c.id)).toBe(false); + + store.clearSuppressedQueuedMessageIDs(); + expect(store.hasObservedQueuedMessageID(a.id)).toBe(false); + }); + + it("counts every server status report, including repeats", () => { + const store = createChatStore(); + + expect(store.getServerChatStatusVersion()).toBe(0); + + store.setChatStatus("running"); + expect(store.getServerChatStatusVersion()).toBe(0); + + store.applyServerChatStatus("error"); + expect(store.getServerChatStatusVersion()).toBe(1); + expect(store.getSnapshot().chatStatus).toBe("error"); + + store.applyServerChatStatus("error"); + expect(store.getServerChatStatusVersion()).toBe(2); + expect(store.getSnapshot().chatStatus).toBe("error"); + }); + + it("restores a promoted head that a fresh snapshot still queues", () => { + const store = createChatStore(); + const a = makeQueuedMessage(1, "A"); + const b = makeQueuedMessage(2, "B"); + + store.setActiveChatID(testChatID); + store.setQueuedMessages([b]); + store.markQueuedMessagePromoted(a.id); + const baseline = store.getQueueConvergenceFence(); + + expect( + store + .applyPromoteRefetchQueuedMessages(testChatID, a.id, [a, b], baseline) + ?.map((message) => message.id), + ).toEqual([a.id, b.id]); + expect( + store.getSnapshot().queuedMessages.map((message) => message.id), + ).toEqual([a.id, b.id]); + expect(store.getSnapshot().promotedQueuedMessageIDs.size).toBe(0); + expect(store.getSnapshot().suppressedQueuedMessageIDs.size).toBe(0); + }); + + it("ignores a promote refetch that a newer snapshot already superseded", () => { + const store = createChatStore(); + const a = makeQueuedMessage(1, "A"); + const b = makeQueuedMessage(2, "B"); + const c = makeQueuedMessage(3, "C"); + + store.setActiveChatID(testChatID); + store.setQueuedMessages([b]); + store.markQueuedMessagePromoted(a.id); + const baseline = store.getQueueConvergenceFence(); + + store.applyAuthoritativeQueuedMessages([b, c]); + + expect( + store.applyPromoteRefetchQueuedMessages( + testChatID, + a.id, + [a, b], + baseline, + ), + ).toBeUndefined(); + expect( + store.getSnapshot().queuedMessages.map((message) => message.id), + ).toEqual([b.id, c.id]); + expect(store.getSnapshot().promotedQueuedMessageIDs.size).toBe(0); + }); + + it("still applies a promote refetch after a stale snapshot was discarded", () => { + const store = createChatStore(); + const a = makeQueuedMessage(1, "A"); + const b = makeQueuedMessage(2, "B"); + const c = makeQueuedMessage(3, "C"); + + store.setActiveChatID(testChatID); + store.setQueuedMessages([b]); + store.markQueuedMessagePromoted(a.id); + const baseline = store.getQueueConvergenceFence(); + + store.applyAuthoritativeQueuedMessages([a, b, c]); + + expect( + store.applyPromoteRefetchQueuedMessages( + testChatID, + a.id, + [b, c], + baseline, + ), + ).toEqual([b, c]); + expect( + store.getSnapshot().queuedMessages.map((message) => message.id), + ).toEqual([b.id, c.id]); + }); + + it("ignores a promote refetch that resolves after switching chats", () => { + const store = createChatStore(); + const a = makeQueuedMessage(1, "A"); + const b = makeQueuedMessage(2, "B"); + + store.setActiveChatID(testChatID); + store.setQueuedMessages([b]); + store.markQueuedMessagePromoted(a.id); + const baseline = store.getQueueConvergenceFence(); + + store.setActiveChatID("chat-other"); + store.setQueuedMessages([]); + + expect( + store.applyPromoteRefetchQueuedMessages( + testChatID, + a.id, + [a, b], + baseline, + ), + ).toBeUndefined(); + expect(store.getSnapshot().queuedMessages).toEqual([]); + }); + + it("ignores a promote refetch spanning a round trip back to the same chat", () => { + const store = createChatStore(); + const a = makeQueuedMessage(1, "A"); + const b = makeQueuedMessage(2, "B"); + + store.setActiveChatID(testChatID); + store.setQueuedMessages([b]); + store.markQueuedMessagePromoted(a.id); + const baseline = store.getQueueConvergenceFence(); + + store.setActiveChatID("chat-other"); + store.setActiveChatID(testChatID); + + expect( + store.applyPromoteRefetchQueuedMessages( + testChatID, + a.id, + [a, b], + baseline, + ), + ).toBeUndefined(); + expect( + store.getSnapshot().queuedMessages.map((message) => message.id), + ).toEqual([b.id]); + }); + + it("ignores a promote refetch naming another chat even at a matching fence", () => { + const store = createChatStore(); + const a = makeQueuedMessage(1, "A"); + const b = makeQueuedMessage(2, "B"); + + store.setActiveChatID(testChatID); + store.setQueuedMessages([b]); + store.markQueuedMessagePromoted(a.id); + + expect( + store.applyPromoteRefetchQueuedMessages( + "chat-other", + a.id, + [a, b], + store.getQueueConvergenceFence(), + ), + ).toBeUndefined(); + expect( + store.getSnapshot().queuedMessages.map((message) => message.id), + ).toEqual([b.id]); + }); + + it("returns the queue it applied, not the raw snapshot", () => { + const store = createChatStore(); + const a = makeQueuedMessage(1, "A"); + const b = makeQueuedMessage(2, "B"); + const c = makeQueuedMessage(3, "C"); + + store.setActiveChatID(testChatID); + store.setQueuedMessages([b]); + store.markQueuedMessagePromoted(a.id); + store.suppressQueuedMessageID(c.id); + const baseline = store.getQueueConvergenceFence(); + + expect( + store + .applyPromoteRefetchQueuedMessages( + testChatID, + a.id, + [a, b, c], + baseline, + ) + ?.map((message) => message.id), + ).toEqual([a.id, b.id]); + expect( + store.getSnapshot().queuedMessages.map((message) => message.id), + ).toEqual([a.id, b.id]); + }); + + it("unsuppressQueuedMessageID clears a promoted marker after a failed promotion", () => { + const store = createChatStore(); + const a = makeQueuedMessage(1, "A"); + const b = makeQueuedMessage(2, "B"); + + store.markQueuedMessagePromoted(a.id); + store.unsuppressQueuedMessageID(a.id); + expect(store.getSnapshot().promotedQueuedMessageIDs.size).toBe(0); + + store.applyAuthoritativeQueuedMessages([a, b]); + expect( + store.getSnapshot().queuedMessages.map((message) => message.id), + ).toEqual([a.id, b.id]); + }); + it("unsuppressQueuedMessageID removes IDs from the suppression set", () => { const store = createChatStore(); store.suppressQueuedMessageID(42); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index 1d70610023f..427d0180c43 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -1537,6 +1537,81 @@ describe("useChatStore", () => { expect(cachedData?.pages[0]?.queued_messages).toEqual([]); }); + it("caches the filtered queue when a queue_update still contains a suppressed message", async () => { + const chatID = "chat-1"; + const existingMessage = buildMessage(chatID, 1, "user", "hello"); + const queuedMessage = buildQueuedMessage(chatID, 10, "queued"); + const mockSocket = createMockSocket(); + mockWatchChatReturn(mockSocket); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: Number.POSITIVE_INFINITY, + refetchOnWindowFocus: false, + networkMode: "offlineFirst", + }, + }, + }); + const initialChatMessagesData: TypesGen.ChatMessagesResponse = { + messages: [existingMessage], + queued_messages: [queuedMessage], + has_more: false, + }; + queryClient.setQueryData(chatMessagesKey(chatID), { + pages: [initialChatMessagesData], + pageParams: [undefined], + }); + + const wrapper = createWrapper(queryClient); + const setChatErrorReason = vi.fn(); + const clearChatErrorReason = vi.fn(); + + const { result } = renderHook( + () => { + const { store } = useChatStore({ + chatID, + chatMessages: [existingMessage], + chatRecord: buildChat(chatID), + chatMessagesData: initialChatMessagesData, + chatQueuedMessages: [queuedMessage], + setChatErrorReason, + clearChatErrorReason, + }); + return { + store, + queuedMessages: useChatSelector(store, selectQueuedMessages), + }; + }, + { wrapper }, + ); + + await waitFor(() => { + expect(watchChat).toHaveBeenCalledWith(chatID, 1); + }); + + act(() => { + result.current.store.suppressQueuedMessageID(queuedMessage.id); + }); + act(() => { + mockSocket.emitData({ + type: "queue_update", + chat_id: chatID, + queued_messages: [queuedMessage], + }); + }); + + await waitFor(() => { + expect(result.current.queuedMessages).toEqual([]); + }); + const cachedData = queryClient.getQueryData<{ + pages: TypesGen.ChatMessagesResponse[]; + pageParams: unknown[]; + }>(chatMessagesKey(chatID)); + expect(cachedData?.pages[0]?.queued_messages).toEqual([]); + }); + it("writes WebSocket message events into the chat query cache", async () => { const chatID = "chat-1"; const existingMessage = buildMessage(chatID, 1, "user", "hello"); @@ -2215,6 +2290,245 @@ describe("useChatStore", () => { }); }); + it("applies a refetched status after acceptServerChatStatus", async () => { + const chatID = "chat-resync"; + const mockSocket = createMockSocket(); + mockWatchChatReturn(mockSocket); + const queryClient = createTestQueryClient(); + const wrapper = createWrapper(queryClient); + + const initialProps: { status: TypesGen.ChatStatus; updatedAt: number } = { + status: "waiting", + updatedAt: 1, + }; + const { result, rerender } = renderHook( + ({ status, updatedAt }: typeof initialProps) => { + const { store, acceptServerChatStatus } = useChatStore({ + chatID, + chatMessages: [], + chatRecord: { ...buildChat(chatID), status }, + chatRecordUpdatedAt: updatedAt, + chatMessagesData: { + messages: [], + queued_messages: [], + has_more: false, + }, + chatQueuedMessages: [], + setChatErrorReason: vi.fn(), + clearChatErrorReason: vi.fn(), + }); + return { + acceptServerChatStatus, + chatStatus: useChatSelector(store, selectChatStatus), + }; + }, + { wrapper, initialProps }, + ); + + await waitFor(() => { + expect(watchChat).toHaveBeenCalledWith(chatID, undefined); + }); + + act(() => { + mockSocket.emitData({ + type: "status", + chat_id: chatID, + status: { status: "running" }, + }); + }); + await waitFor(() => { + expect(result.current.chatStatus).toBe("running"); + }); + rerender({ status: "error", updatedAt: 1 }); + expect(result.current.chatStatus).toBe("running"); + + act(() => { + result.current.acceptServerChatStatus(); + }); + rerender({ status: "waiting", updatedAt: 1 }); + expect(result.current.chatStatus).toBe("running"); + rerender({ status: "error", updatedAt: 2 }); + await waitFor(() => { + expect(result.current.chatStatus).toBe("error"); + }); + }); + + it("hydrates an unchanged status after a successful refetch", async () => { + const chatID = "chat-resync-same"; + const mockSocket = createMockSocket(); + mockWatchChatReturn(mockSocket); + const queryClient = createTestQueryClient(); + const wrapper = createWrapper(queryClient); + const chatRecord = { ...buildChat(chatID), status: "error" as const }; + + const { result, rerender } = renderHook( + ({ updatedAt }: { updatedAt: number }) => { + const { store, acceptServerChatStatus } = useChatStore({ + chatID, + chatMessages: [], + chatRecord, + chatRecordUpdatedAt: updatedAt, + chatMessagesData: { + messages: [], + queued_messages: [], + has_more: false, + }, + chatQueuedMessages: [], + setChatErrorReason: vi.fn(), + clearChatErrorReason: vi.fn(), + }); + return { + acceptServerChatStatus, + chatStatus: useChatSelector(store, selectChatStatus), + }; + }, + { wrapper, initialProps: { updatedAt: 1 } }, + ); + + await waitFor(() => { + expect(watchChat).toHaveBeenCalledWith(chatID, undefined); + }); + act(() => { + mockSocket.emitData({ + type: "status", + chat_id: chatID, + status: { status: "running" }, + }); + }); + await waitFor(() => { + expect(result.current.chatStatus).toBe("running"); + }); + + act(() => { + result.current.acceptServerChatStatus(); + }); + expect(result.current.chatStatus).toBe("running"); + rerender({ updatedAt: 2 }); + await waitFor(() => { + expect(result.current.chatStatus).toBe("error"); + }); + }); + + it("ignores a resync armed by a request from a chat the user left", async () => { + const leftChatID = "chat-resync-left"; + const activeChatID = "chat-resync-active"; + const mockSocket = createMockSocket(); + mockWatchChatReturn(mockSocket); + const queryClient = createTestQueryClient(); + const wrapper = createWrapper(queryClient); + + const { result, rerender } = renderHook( + ({ chatID, updatedAt }: { chatID: string; updatedAt: number }) => { + const { store, acceptServerChatStatus } = useChatStore({ + chatID, + chatMessages: [], + chatRecord: { ...buildChat(chatID), status: "waiting" }, + chatRecordUpdatedAt: updatedAt, + chatMessagesData: { + messages: [], + queued_messages: [], + has_more: false, + }, + chatQueuedMessages: [], + setChatErrorReason: () => {}, + clearChatErrorReason: () => {}, + }); + return { + acceptServerChatStatus, + chatStatus: useChatSelector(store, selectChatStatus), + }; + }, + { + wrapper, + initialProps: { chatID: leftChatID, updatedAt: 1 }, + }, + ); + + await waitFor(() => { + expect(watchChat).toHaveBeenCalledWith(leftChatID, undefined); + }); + // The in-flight request holds the callback from the render it started in. + const staleAcceptServerChatStatus = result.current.acceptServerChatStatus; + + rerender({ chatID: activeChatID, updatedAt: 5 }); + await waitFor(() => { + expect(watchChat).toHaveBeenCalledWith(activeChatID, undefined); + }); + act(() => { + mockSocket.emitData({ + type: "status", + chat_id: activeChatID, + status: { status: "running" }, + }); + }); + await waitFor(() => { + expect(result.current.chatStatus).toBe("running"); + }); + + act(() => { + staleAcceptServerChatStatus(); + }); + await waitFor(() => { + expect(result.current.chatStatus).toBe("running"); + }); + }); + + it("keeps a websocket status delivered while the resync refetch is in flight", async () => { + const chatID = "chat-resync-ws-race"; + const mockSocket = createMockSocket(); + mockWatchChatReturn(mockSocket); + const queryClient = createTestQueryClient(); + const wrapper = createWrapper(queryClient); + const chatRecord = { ...buildChat(chatID), status: "error" as const }; + + const { result, rerender } = renderHook( + ({ updatedAt }: { updatedAt: number }) => { + const { store, acceptServerChatStatus } = useChatStore({ + chatID, + chatMessages: [], + chatRecord, + chatRecordUpdatedAt: updatedAt, + chatMessagesData: { + messages: [], + queued_messages: [], + has_more: false, + }, + chatQueuedMessages: [], + setChatErrorReason: () => {}, + clearChatErrorReason: () => {}, + }); + return { + acceptServerChatStatus, + chatStatus: useChatSelector(store, selectChatStatus), + }; + }, + { wrapper, initialProps: { updatedAt: 1 } }, + ); + + await waitFor(() => { + expect(watchChat).toHaveBeenCalledWith(chatID, undefined); + }); + + act(() => { + result.current.acceptServerChatStatus(); + }); + act(() => { + mockSocket.emitData({ + type: "status", + chat_id: chatID, + status: { status: "running" }, + }); + }); + await waitFor(() => { + expect(result.current.chatStatus).toBe("running"); + }); + + rerender({ updatedAt: 2 }); + await waitFor(() => { + expect(result.current.chatStatus).toBe("running"); + }); + }); + it("sets chatStatus to error and populates streamError on error event", async () => { immediateAnimationFrame(); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts index 886524feed3..3993af14f24 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts @@ -140,6 +140,9 @@ export type ChatStoreState = { // the running-case promote, where the backend reorders the // queued message to the front before auto-promoting it. suppressedQueuedMessageIDs: ReadonlySet; + // IDs confirmed deleted from the queue because the send response + // contained their promoted user rows. + promotedQueuedMessageIDs: ReadonlySet; subagentStatusOverrides: Map; }; @@ -167,7 +170,28 @@ export type ChatStore = { applyAuthoritativeQueuedMessages: ( queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined, ) => void; + // Advances when an accepted snapshot or active-chat change invalidates an + // in-flight convergence request. Discarded snapshots do not advance it. + getQueueConvergenceFence: () => number; + // Applies a promotion refetch only while the chat and fence still match. + // Clears that ID's markers and returns the filtered queue for cache mirroring. + applyPromoteRefetchQueuedMessages: ( + chatID: string, + promotedID: number, + queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined, + baselineFence: number, + ) => readonly TypesGen.ChatQueuedMessage[] | undefined; suppressQueuedMessageID: (id: number) => void; + markQueuedMessagePromoted: (id: number) => void; + // Distinguishes a tail still in flight from one the server listed and later removed. + hasObservedQueuedMessageID: (id: number) => boolean; + setActiveChatID: (chatID: string | null) => void; + getActiveChatID: () => string | null; + // Counts server-reported status events, including repeats of the + // current value, so a caller can tell that the server spoke during a + // request even when the status did not change. + getServerChatStatusVersion: () => number; + applyServerChatStatus: (status: TypesGen.ChatStatus | null) => void; unsuppressQueuedMessageID: (id: number) => void; clearSuppressedQueuedMessageIDs: () => void; setChatStatus: (status: TypesGen.ChatStatus | null) => void; @@ -197,11 +221,18 @@ const createInitialState = (): ChatStoreState => ({ reconnectState: null, queuedMessages: [], suppressedQueuedMessageIDs: new Set(), + promotedQueuedMessageIDs: new Set(), subagentStatusOverrides: new Map(), }); export const createChatStore = (): ChatStore => { let state = createInitialState(); + // Bookkeeping, deliberately outside the rendered state so observing a + // server event cannot trigger a re-render. + let observedQueuedMessageIDs = new Set(); + let queueConvergenceFence = 0; + let serverChatStatusVersion = 0; + let activeChatID: string | null = null; const listeners = new Set<() => void>(); const emit = (): void => { @@ -404,6 +435,20 @@ export const createChatStore = (): ChatStore => { }, applyAuthoritativeQueuedMessages: (queuedMessages) => { const incoming = queuedMessages ?? []; + for (const message of incoming) { + observedQueuedMessageIDs.add(message.id); + } + // A snapshot containing a confirmed promoted ID predates its queue + // deletion. Applying it would also drop newer queued messages, and + // counting it would discard the fresher refetch racing it. + if ( + incoming.some((message) => + state.promotedQueuedMessageIDs.has(message.id), + ) + ) { + return; + } + queueConvergenceFence++; setState((current) => { let nextSuppressed = current.suppressedQueuedMessageIDs; if (current.suppressedQueuedMessageIDs.size > 0) { @@ -431,16 +476,65 @@ export const createChatStore = (): ChatStore => { ); const sameSuppressed = nextSuppressed === current.suppressedQueuedMessageIDs; - if (sameQueue && sameSuppressed) { + const nextPromoted = + current.promotedQueuedMessageIDs.size === 0 + ? current.promotedQueuedMessageIDs + : new Set(); + const samePromoted = nextPromoted === current.promotedQueuedMessageIDs; + if (sameQueue && sameSuppressed && samePromoted) { return current; } return { ...current, queuedMessages: sameQueue ? current.queuedMessages : filtered, suppressedQueuedMessageIDs: nextSuppressed, + promotedQueuedMessageIDs: nextPromoted, }; }); }, + getQueueConvergenceFence: () => queueConvergenceFence, + applyPromoteRefetchQueuedMessages: ( + chatID, + promotedID, + queuedMessages, + baselineFence, + ) => { + // The fence covers ordering, including navigation. This identity check + // additionally keeps a response that names another chat out of the + // shared store even if its caller captured the fence incorrectly. + if (activeChatID !== chatID) { + return undefined; + } + if (queueConvergenceFence !== baselineFence) { + return undefined; + } + const incoming = queuedMessages ?? []; + queueConvergenceFence++; + for (const message of incoming) { + observedQueuedMessageIDs.add(message.id); + } + const suppressed = new Set(state.suppressedQueuedMessageIDs); + suppressed.delete(promotedID); + const promoted = new Set(state.promotedQueuedMessageIDs); + promoted.delete(promotedID); + const applied = + suppressed.size === 0 + ? incoming + : incoming.filter((message) => !suppressed.has(message.id)); + setState((current) => ({ + ...current, + queuedMessages: chatQueuedMessagesEqualByID( + current.queuedMessages, + applied, + ) + ? current.queuedMessages + : applied, + suppressedQueuedMessageIDs: suppressed, + promotedQueuedMessageIDs: promoted, + })); + return applied; + }, + hasObservedQueuedMessageID: (id) => observedQueuedMessageIDs.has(id), suppressQueuedMessageID: (id) => { setState((current) => { if (current.suppressedQueuedMessageIDs.has(id)) { @@ -451,22 +545,58 @@ export const createChatStore = (): ChatStore => { return { ...current, suppressedQueuedMessageIDs: next }; }); }, + markQueuedMessagePromoted: (id) => { + setState((current) => { + if ( + current.suppressedQueuedMessageIDs.has(id) && + current.promotedQueuedMessageIDs.has(id) + ) { + return current; + } + const suppressed = new Set(current.suppressedQueuedMessageIDs); + suppressed.add(id); + const promoted = new Set(current.promotedQueuedMessageIDs); + promoted.add(id); + return { + ...current, + suppressedQueuedMessageIDs: suppressed, + promotedQueuedMessageIDs: promoted, + }; + }); + }, unsuppressQueuedMessageID: (id) => { setState((current) => { - if (!current.suppressedQueuedMessageIDs.has(id)) { + if ( + !current.suppressedQueuedMessageIDs.has(id) && + !current.promotedQueuedMessageIDs.has(id) + ) { return current; } - const next = new Set(current.suppressedQueuedMessageIDs); - next.delete(id); - return { ...current, suppressedQueuedMessageIDs: next }; + const suppressed = new Set(current.suppressedQueuedMessageIDs); + suppressed.delete(id); + const promoted = new Set(current.promotedQueuedMessageIDs); + promoted.delete(id); + return { + ...current, + suppressedQueuedMessageIDs: suppressed, + promotedQueuedMessageIDs: promoted, + }; }); }, clearSuppressedQueuedMessageIDs: () => { + observedQueuedMessageIDs = new Set(); setState((current) => { - if (current.suppressedQueuedMessageIDs.size === 0) { + if ( + current.suppressedQueuedMessageIDs.size === 0 && + current.promotedQueuedMessageIDs.size === 0 + ) { return current; } - return { ...current, suppressedQueuedMessageIDs: new Set() }; + return { + ...current, + suppressedQueuedMessageIDs: new Set(), + promotedQueuedMessageIDs: new Set(), + }; }); }, setChatStatus: (status) => { @@ -478,6 +608,27 @@ export const createChatStore = (): ChatStore => { chatStatus: status, })); }, + setActiveChatID: (chatID) => { + if (activeChatID === chatID) { + return; + } + activeChatID = chatID; + // Leaving a chat strands any convergence request issued for it, so a + // later return to the same chat cannot revive one. + queueConvergenceFence++; + }, + getActiveChatID: () => activeChatID, + getServerChatStatusVersion: () => serverChatStatusVersion, + applyServerChatStatus: (status) => { + serverChatStatusVersion++; + if (state.chatStatus === status) { + return; + } + setState((current) => ({ + ...current, + chatStatus: status, + })); + }, setStreamState: (streamState) => { if (state.streamState === streamState) { return; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index 4bb8c721974..51810a2292e 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -5,7 +5,11 @@ import { useRef, useState, } from "react"; -import { type InfiniteData, useQueryClient } from "react-query"; +import { + type InfiniteData, + type QueryClient, + useQueryClient, +} from "react-query"; import { watchChat } from "#/api/api"; import { chatMessagesKey, @@ -27,6 +31,50 @@ import { } from "./chatStore"; import type { RetryState } from "./types"; +// Prevents REST re-hydration from replaying a stale queue over the store. +const writeQueuedMessagesToCache = ( + queryClient: QueryClient, + chatID: string | undefined, + queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined, +): void => { + if (!chatID) { + return; + } + const nextQueuedMessages = queuedMessages ?? []; + queryClient.setQueryData< + InfiniteData | undefined + >(chatMessagesKey(chatID), (currentData) => { + if (!currentData?.pages?.length) { + return currentData; + } + const firstPage = currentData.pages[0]; + if ( + chatQueuedMessagesEqualByID(firstPage.queued_messages, nextQueuedMessages) + ) { + return currentData; + } + return { + ...currentData, + pages: [ + { ...firstPage, queued_messages: nextQueuedMessages }, + ...currentData.pages.slice(1), + ], + }; + }); +}; + +const readQueuedMessagesFromCache = ( + queryClient: QueryClient, + chatID: string | undefined, +): readonly TypesGen.ChatQueuedMessage[] | undefined => { + if (!chatID) { + return undefined; + } + return queryClient.getQueryData< + InfiniteData | undefined + >(chatMessagesKey(chatID))?.pages[0]?.queued_messages; +}; + const normalizeRetryState = (retry: TypesGen.ChatStreamRetry): RetryState => ({ attempt: Math.max(1, retry.attempt), error: retry.error.trim() || "Retrying request shortly.", @@ -45,6 +93,7 @@ interface UseChatStoreOptions { chatID: string | undefined; chatMessages: readonly TypesGen.ChatMessage[] | undefined; chatRecord: TypesGen.Chat | undefined; + chatRecordUpdatedAt?: number; chatMessagesData: TypesGen.ChatMessagesResponse | undefined; chatQueuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined; setChatErrorReason: (chatID: string, reason: ChatDetailError) => void; @@ -56,13 +105,21 @@ export const useChatStore = ( options: UseChatStoreOptions, ): { store: ChatStore; + acceptServerChatStatus: () => void; clearStreamError: () => void; + setCacheQueuedMessages: ( + queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined, + ) => void; + getCacheQueuedMessages: () => + | readonly TypesGen.ChatQueuedMessage[] + | undefined; upsertCacheMessages: (messages: readonly TypesGen.ChatMessage[]) => void; } => { const { chatID, chatMessages, chatRecord, + chatRecordUpdatedAt = 0, chatMessagesData, chatQueuedMessages, setChatErrorReason, @@ -88,6 +145,9 @@ export const useChatStore = ( // stale value like "waiting", causing shouldApplyMessagePart() // to drop all incoming parts. const wsStatusReceivedRef = useRef(false); + const [pendingStatusResync, setPendingStatusResync] = useState(false); + const pendingStatusResyncUpdatedAtRef = useRef(null); + const pendingStatusResyncVersionRef = useRef(null); const activeChatIDRef = useRef(null); const prevChatIDRef = useRef(chatID); // Snapshot of the chatMessages elements from the last sync effect @@ -259,6 +319,26 @@ export const useChatStore = ( }, [chatID, chatMessages, store]); useEffect(() => { + if (pendingStatusResync) { + const armedAt = pendingStatusResyncUpdatedAtRef.current; + // dataUpdatedAt advances after a fetch even when structural sharing + // preserves chatRecord. + if (armedAt === null || chatRecordUpdatedAt <= armedAt) { + return; + } + // Preserve a websocket status delivered during the refetch instead of applying its response. + const wsAdvanced = + store.getServerChatStatusVersion() !== + pendingStatusResyncVersionRef.current; + if (!wsAdvanced) { + store.setChatStatus(chatRecord?.status ?? null); + wsStatusReceivedRef.current = false; + } + pendingStatusResyncUpdatedAtRef.current = null; + pendingStatusResyncVersionRef.current = null; + setPendingStatusResync(false); + return; + } // Only hydrate from REST when the WebSocket hasn't delivered // a status event yet. Once the WS is the authoritative // source, a stale REST refetch must not overwrite the @@ -266,12 +346,15 @@ export const useChatStore = ( if (!wsStatusReceivedRef.current) { store.setChatStatus(chatRecord?.status ?? null); } - }, [chatRecord?.status, store]); + }, [chatRecord?.status, chatRecordUpdatedAt, store, pendingStatusResync]); useEffect(() => { queuedMessagesHydratedChatIDRef.current = null; wsQueueUpdateReceivedRef.current = false; wsStatusReceivedRef.current = false; + pendingStatusResyncUpdatedAtRef.current = null; + pendingStatusResyncVersionRef.current = null; + setPendingStatusResync(false); store.setQueuedMessages([]); // Suppression entries are scoped to the current chat; clear // them on chat change so a stale promote suppression doesn't @@ -298,6 +381,16 @@ export const useChatStore = ( return; } queuedMessagesHydratedChatIDRef.current = chatID; + // An optimistic promotion cache write must not clear suppression before + // a stale pre-promotion queue_update arrives. + if ( + chatQueuedMessagesEqualByID( + store.getSnapshot().queuedMessages, + chatQueuedMessages ?? [], + ) + ) { + return; + } store.applyAuthoritativeQueuedMessages(chatQueuedMessages); }, [chatMessagesData, chatID, chatQueuedMessages, store]); @@ -324,40 +417,9 @@ export const useChatStore = ( }); }; - const updateChatQueuedMessages = ( - queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined, - ) => { - if (!chatID) { - return; - } - const nextQueuedMessages = queuedMessages ?? []; - queryClient.setQueryData< - InfiniteData | undefined - >(chatMessagesKey(chatID), (currentData) => { - if (!currentData?.pages?.length) { - return currentData; - } - const firstPage = currentData.pages[0]; - if ( - chatQueuedMessagesEqualByID( - firstPage.queued_messages, - nextQueuedMessages, - ) - ) { - return currentData; - } - return { - ...currentData, - pages: [ - { ...firstPage, queued_messages: nextQueuedMessages }, - ...currentData.pages.slice(1), - ], - }; - }); - }; - store.resetTransientState(); activeChatIDRef.current = chatID ?? null; + store.setActiveChatID(chatID ?? null); if (!chatID || !initialDataLoaded || aiGatewayDisabled) { return; @@ -570,7 +632,14 @@ export const useChatStore = ( store.applyAuthoritativeQueuedMessages( streamEvent.queued_messages, ); - updateChatQueuedMessages(streamEvent.queued_messages); + // Cache the store's filtered queue, not the raw + // event, so a promoted message suppressed by the + // store cannot reappear on REST re-hydration. + writeQueuedMessagesToCache( + queryClient, + chatID, + store.getSnapshot().queuedMessages, + ); continue; case "status": { const nextStatus = streamEvent.status?.status; @@ -580,7 +649,7 @@ export const useChatStore = ( wsStatusReceivedRef.current = true; store.clearRetryState(); - store.setChatStatus(nextStatus); + store.applyServerChatStatus(nextStatus); if (nextStatus === "waiting") { discardBufferedParts(); } @@ -599,7 +668,8 @@ export const useChatStore = ( kind: "generic", message: "Chat processing failed.", }; - store.setChatStatus("error"); + wsStatusReceivedRef.current = true; + store.applyServerChatStatus("error"); store.setStreamError(reason); store.clearRetryState(); setChatErrorReasonEvent(chatID, reason); @@ -707,6 +777,7 @@ export const useChatStore = ( clearTimeout(partsFlushTimer); } activeChatIDRef.current = null; + store.setActiveChatID(null); }; }, [ aiGatewayDisabled, @@ -722,6 +793,26 @@ export const useChatStore = ( clearStreamError: () => { store.clearStreamError(); }, + // A failed request can change server-side chat status while the + // socket is down, and the socket having already delivered a status + // otherwise makes the refetched one inert. + acceptServerChatStatus: () => { + // A request that resolves after the user navigates away belongs to + // the previous chat, whose freshness and status are unrelated to + // the one now displayed by this shared store. + if (store.getActiveChatID() !== (chatID ?? null)) { + return; + } + pendingStatusResyncUpdatedAtRef.current = chatRecordUpdatedAt; + pendingStatusResyncVersionRef.current = + store.getServerChatStatusVersion(); + setPendingStatusResync(true); + }, + setCacheQueuedMessages: (queuedMessages) => { + writeQueuedMessagesToCache(queryClient, chatID, queuedMessages); + }, + getCacheQueuedMessages: () => + readQueuedMessagesFromCache(queryClient, chatID), upsertCacheMessages, }; }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx index a2b40c0a80c..015d9c65bfb 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx @@ -39,23 +39,20 @@ export const EditFilesTool: React.FC<{ EDIT_FILES_AUTO_DISPLAY_STATE, ); - let label: string; + let verb = "Edited"; if (isRunning) { - if (files.length === 1) { - label = `Editing ${getPathBasename(files[0].path)}…`; - } else if (files.length > 1) { - label = `Editing ${files.length} files…`; - } else { - label = "Editing files…"; - } - } else if (files.length === 1) { - const filename = getPathBasename(files[0].path); - label = `Edited ${filename}`; + verb = "Editing"; + } else if (isError) { + verb = "Failed to edit"; + } + let subject = "files"; + if (files.length === 1) { + subject = getPathBasename(files[0].path); } else if (files.length > 1) { - label = `Edited ${files.length} files`; - } else { - label = "Edited files"; + subject = `${files.length} files`; } + const label = isRunning ? `${verb} ${subject}…` : `${verb} ${subject}`; + const errorDetail = isError ? errorMessage?.trim() : undefined; return ( + {errorDetail && ( +
+						{errorDetail}
+					
+ )}
{diffs.map((diff, i) => diff ? ( diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx index 2e6add72896..11064f5630c 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx @@ -36,6 +36,7 @@ type ExecuteToolProps = { transcriptBlocks: readonly ExecuteTranscriptBlock[]; status: ToolStatus; isError: boolean; + errorText?: string; durationMs?: number; isBackgrounded?: boolean; killedBySignal?: "kill" | "terminate"; @@ -49,6 +50,7 @@ export const ExecuteTool: React.FC = ({ transcriptBlocks, status, isError, + errorText, durationMs, isBackgrounded = false, killedBySignal, @@ -71,6 +73,8 @@ export const ExecuteTool: React.FC = ({ modelIntent, parsedCommands, durationLabel, + isRunning, + isError, }); const defaultView = resolveAgentDisplayState( shellToolDisplayMode, @@ -83,7 +87,7 @@ export const ExecuteTool: React.FC = ({ className="group/exec grid w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 rounded-md bg-surface-primary font-sans font-normal text-xs leading-5" status={status} isError={isError} - errorMessage="Command failed" + errorMessage={errorText || "Command failed"} hasContent defaultView={defaultView} ariaLabel={(expanded) => @@ -152,6 +156,8 @@ type ShellCommandLineInput = { modelIntent?: string; parsedCommands?: readonly string[][]; durationLabel: string; + isRunning: boolean; + isError: boolean; }; const getShellCommandLine = ({ @@ -159,6 +165,8 @@ const getShellCommandLine = ({ modelIntent, parsedCommands, durationLabel, + isRunning, + isError, }: ShellCommandLineInput): { commandLabel: string; durationSuffix: string } => { const intentLabel = sanitizeExecuteModelIntent(modelIntent, command); const summary = @@ -166,9 +174,12 @@ const getShellCommandLine = ({ ? summarizeParsedCommands(parsedCommands) : ""; const commandDisplay = summary || command; - const commandLabel = intentLabel + let commandLabel = intentLabel ? `${intentLabel} using ${commandDisplay}` : `Ran ${commandDisplay}`; + if (!isRunning && isError) { + commandLabel = `Failed to run ${commandDisplay}`; + } return { commandLabel, diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index 6286c7d89dc..aa73dbbe2bb 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -401,6 +401,32 @@ export const ExecuteError: Story = { }, }; +export const ExecuteDeniedByHook: Story = { + args: { + name: "execute", + status: "error", + isError: true, + args: { command: "cat /etc/secrets" }, + result: { + error: + "This tool usage was blocked by an external policy (the deployment's lifecycle hook); the tool call was not executed. Reason: secret reads are blocked. This is an administrative policy decision, not a tool or workspace failure; retrying the same call will be denied again. Explain the policy block to the user and adjust your approach.", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText(/Failed to run cat \/etc\/secrets/)).toBeVisible(); + expect( + canvas.queryByText(/Ran cat \/etc\/secrets/), + ).not.toBeInTheDocument(); + await expect( + canvas.getByRole("img", { + name: /blocked by an external policy/, + }), + ).toBeVisible(); + expect(canvas.getByText(/Reason: secret reads are blocked/)).toBeVisible(); + }, +}; + export const ExecuteBackgrounded: Story = { args: { name: "execute", @@ -1781,6 +1807,34 @@ export const WriteFileAlwaysExpanded: Story = { }, }; +export const WriteFileDeniedByHook: Story = { + args: { + name: "write_file", + status: "error", + isError: true, + codeDiffDisplayMode: "auto", + args: { + path: "src/utils/helpers.ts", + content: "export const helper = true;\n", + }, + result: { + error: + "This tool usage was blocked by an external policy (the deployment's lifecycle hook); the tool call was not executed. Reason: writes to src are blocked.", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText(/Failed to write helpers\.ts/)).toBeInTheDocument(); + await userEvent.click( + canvas.getByRole("button", { name: /Failed to write helpers\.ts/ }), + ); + await waitFor(() => { + expect(canvas.getByText(/blocked by an external policy/)).toBeVisible(); + }); + expect(canvas.queryByTestId("write-file-diff")).not.toBeInTheDocument(); + }, +}; + // --------------------------------------------------------------------------- // EditFiles stories // --------------------------------------------------------------------------- @@ -1954,7 +2008,10 @@ export const EditFilesError: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByText(/Edited missing\.ts/)).toBeInTheDocument(); + expect(canvas.getByText(/Failed to edit missing\.ts/)).toBeInTheDocument(); + await waitFor(() => { + expect(canvas.getByText("File not found")).toBeVisible(); + }); // On error, no diff body: the synthetic fallback would // misrepresent a rejected edit as applied. expect(canvas.queryAllByTestId("edit-file-diff")).toHaveLength(0); diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index 1ee04043873..494dbc70ad7 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -247,6 +247,7 @@ const ExecuteRenderer: FC = ({ transcriptBlocks={data.transcriptBlocks} status={status} isError={isError} + errorText={data.errorText} durationMs={data.durationMs} isBackgrounded={data.isBackgrounded} killedBySignal={killedBySignal} diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx index 0a9d41480c4..4c43eee4935 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx @@ -39,7 +39,16 @@ export const WriteFileTool: React.FC<{ ); const filename = getPathBasename(path); - const label = isRunning ? `Writing ${filename}…` : `Wrote ${filename}`; + let label = `Wrote ${filename}`; + if (isRunning) { + label = `Writing ${filename}…`; + } else if (isError) { + label = `Failed to write ${filename}`; + } + // The diff is synthesized from tool args, so showing it on error could + // misrepresent the content as written. + const showDiff = hasDiff && !isError; + const errorDetail = isError ? errorMessage?.trim() : undefined; return ( - {hasDiff && ( + {errorDetail && ( +
+						{errorDetail}
+					
+ )} + {showDiff && ( { ).toEqual({ command: "git fetch origin", transcriptBlocks: [{ kind: "output", text: "fetched" }], + errorText: "", durationMs: 47200, isBackgrounded: true, authenticateURL: "https://example.com/auth", diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts index 58089f02c68..8010a54841c 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts @@ -16,6 +16,7 @@ export type ExecuteTranscriptBlock = { type ExecuteRenderData = { command: string; transcriptBlocks: ExecuteTranscriptBlock[]; + errorText: string; durationMs?: number; isBackgrounded: boolean; authenticateURL: string; @@ -63,6 +64,7 @@ export const getExecuteRenderData = ( return { command, transcriptBlocks, + errorText, durationMs, isBackgrounded, authenticateURL, diff --git a/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx b/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx index 6142665f4b1..72ebb3444f9 100644 --- a/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx +++ b/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx @@ -201,3 +201,24 @@ export const MixedQueueWithAttachments: Story = { ], }, }; + +export const HookNotice: Story = { + args: { + messages: [ + buildMessage(1, [ + { type: "text", text: "Deploy to production" }, + { type: "hook-notice", text: "Deployment prompts are audited." }, + ]), + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole("button", { + name: "Lifecycle hook notice: Deployment prompts are audited.", + }); + await userEvent.tab(); + expect(trigger).toHaveFocus(); + const tooltip = await within(document.body).findByRole("tooltip"); + expect(tooltip).toHaveTextContent("Deployment prompts are audited."); + }, +}; diff --git a/site/src/pages/AgentsPage/components/QueuedMessagesList.test.ts b/site/src/pages/AgentsPage/components/QueuedMessagesList.test.ts index 1f6a2db4c3e..b0a11daed39 100644 --- a/site/src/pages/AgentsPage/components/QueuedMessagesList.test.ts +++ b/site/src/pages/AgentsPage/components/QueuedMessagesList.test.ts @@ -16,6 +16,23 @@ describe("getQueuedMessageInfo", () => { displayText: "hello", rawText: "hello", attachmentCount: 0, + hookNotices: [], + fileBlocks: [], + }); + }); + + it("collects hook notices without polluting the preview text", () => { + const result = getQueuedMessageInfo( + buildMessage([ + { type: "text", text: "hello" }, + { type: "hook-notice", text: "policy notice" }, + ]), + ); + expect(result).toEqual({ + displayText: "hello", + rawText: "hello", + attachmentCount: 0, + hookNotices: ["policy notice"], fileBlocks: [], }); }); @@ -28,6 +45,7 @@ describe("getQueuedMessageInfo", () => { displayText: "line1\nline2", rawText: "line1\nline2", attachmentCount: 0, + hookNotices: [], fileBlocks: [], }); }); @@ -40,6 +58,7 @@ describe("getQueuedMessageInfo", () => { displayText: "[Queued message]", rawText: "", attachmentCount: 1, + hookNotices: [], fileBlocks: [{ type: "file", file_id: "a", media_type: "image/png" }], }); }); @@ -55,6 +74,7 @@ describe("getQueuedMessageInfo", () => { displayText: "[Queued message]", rawText: "", attachmentCount: 2, + hookNotices: [], fileBlocks: [ { type: "file", file_id: "a", media_type: "image/png" }, { type: "file", file_id: "b", media_type: "image/png" }, @@ -73,6 +93,7 @@ describe("getQueuedMessageInfo", () => { displayText: "look", rawText: "look", attachmentCount: 1, + hookNotices: [], fileBlocks: [{ type: "file", file_id: "a", media_type: "image/png" }], }); }); @@ -83,6 +104,7 @@ describe("getQueuedMessageInfo", () => { displayText: "[Queued message]", rawText: "", attachmentCount: 0, + hookNotices: [], fileBlocks: [], }); }); @@ -95,6 +117,7 @@ describe("getQueuedMessageInfo", () => { displayText: "[Queued message]", rawText: "", attachmentCount: 0, + hookNotices: [], fileBlocks: [], }); }); @@ -110,6 +133,7 @@ describe("getQueuedMessageInfo", () => { displayText: "[Queued message]", rawText: "", attachmentCount: 1, + hookNotices: [], fileBlocks: [{ type: "file", file_id: "a", media_type: "image/png" }], }); }); @@ -125,6 +149,7 @@ describe("getQueuedMessageInfo", () => { displayText: "a b", rawText: "a b", attachmentCount: 0, + hookNotices: [], fileBlocks: [], }); }); @@ -141,6 +166,7 @@ describe("getQueuedMessageInfo", () => { displayText: "check this", rawText: "check this", attachmentCount: 2, + hookNotices: [], fileBlocks: [ { type: "file", file_id: "img-1", media_type: "image/png" }, { type: "file", file_id: "doc-2", media_type: "application/pdf" }, diff --git a/site/src/pages/AgentsPage/components/QueuedMessagesList.tsx b/site/src/pages/AgentsPage/components/QueuedMessagesList.tsx index d585200f8ec..a8d418336d3 100644 --- a/site/src/pages/AgentsPage/components/QueuedMessagesList.tsx +++ b/site/src/pages/AgentsPage/components/QueuedMessagesList.tsx @@ -2,6 +2,7 @@ import { ArrowUpIcon, CornerDownLeftIcon, ImageIcon, + InfoIcon, PencilIcon, Trash2Icon, } from "lucide-react"; @@ -34,34 +35,32 @@ interface QueuedMessageInfo { rawText: string; attachmentCount: number; fileBlocks: readonly ChatMessagePart[]; + hookNotices: string[]; } export const getQueuedMessageInfo = ( message: ChatQueuedMessage, ): QueuedMessageInfo => { - const { content } = message; - const fileBlocks = content.filter((p) => p.type === "file"); + const fileBlocks: ChatMessagePart[] = []; const textParts: string[] = []; - for (const part of content) { - if (part.type === "text" && part.text.trim()) { + const hookNotices: string[] = []; + for (const part of message.content) { + if (part.type === "file") { + fileBlocks.push(part); + } else if (part.type === "text" && part.text?.trim()) { textParts.push(part.text); + } else if (part.type === "hook-notice" && part.text?.trim()) { + hookNotices.push(part.text); } } const rawText = textParts.join(" ").trim(); - if (rawText) { - return { - displayText: rawText, - rawText, - attachmentCount: fileBlocks.length, - fileBlocks, - }; - } return { - displayText: "[Queued message]", - rawText: "", + displayText: rawText || "[Queued message]", + rawText, attachmentCount: fileBlocks.length, fileBlocks, + hookNotices, }; }; @@ -74,7 +73,7 @@ export const QueuedMessagesList: FC = ({ className, }) => { const items = messages.map((message) => { - const { displayText, rawText, attachmentCount, fileBlocks } = + const { displayText, rawText, attachmentCount, fileBlocks, hookNotices } = getQueuedMessageInfo(message); return { id: message.id, @@ -82,6 +81,7 @@ export const QueuedMessagesList: FC = ({ rawText, attachmentCount, fileBlocks, + hookNotices, }; }); @@ -214,6 +214,22 @@ export const QueuedMessagesList: FC = ({ )} + {item.hookNotices.length > 0 && ( + + + + + + {item.hookNotices.join(" ")} + + + )} {isFirst && ( { const user = userEvent.setup(); - await waitFor(() => screen.findByLabelText("Start time")); + // findByLabelText already retries. Wrapping it in waitFor raced two 1s + // budgets against each other, so a slow first render timed out. + await screen.findByLabelText("Start time", undefined, { timeout: 10_000 }); const HH = hour.toString().padStart(2, "0"); const mm = minute.toString().padStart(2, "0"); fireEvent.change(screen.getByLabelText("Start time"), { @@ -113,7 +115,7 @@ describe("SchedulePage", () => { const errorMessage = await screen.findByText("oh no!"); expect(errorMessage).toBeDefined(); - }); + }, 15_000); }); describe("when user custom schedule is disabled", () => {