From dabecc2022cddafcef012ab8b83d788ba9a42f61 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:37:20 +0000 Subject: [PATCH 01/35] feat(site): surface chat lifecycle hook outcomes in the chats UI Render lifecycle hook outcomes in the chat experience: - Show hook notices attached to their user message as timeline notes, and show an info tooltip for notices on queued messages. - Cache the full inserted message batch from send and edit responses so hook-inserted messages survive reconnects and queue promotion. - Reconcile the promoted queue head after a send to an errored chat so a missed queue update neither duplicates nor hides messages. - Refresh chat details when a send fails, because a failed hook dispatch can move the chat to the error state. --- site/src/api/queries/chatMessageEdits.test.ts | 53 ++++++++- site/src/api/queries/chatMessageEdits.ts | 28 +++-- site/src/api/queries/chats.test.ts | 62 +++++------ site/src/api/queries/chats.ts | 3 +- .../pages/AgentsPage/AgentChatPage.test.ts | 102 +++++++++++++++++- site/src/pages/AgentsPage/AgentChatPage.tsx | 79 ++++++++++++-- .../ConversationTimeline.stories.tsx | 63 +++++++++++ .../ChatConversation/ConversationTimeline.tsx | 62 ++++++++++- .../ChatConversation/useChatStore.ts | 75 +++++++------ .../components/QueuedMessagesList.test.ts | 26 +++++ .../components/QueuedMessagesList.tsx | 46 +++++--- .../AgentsPage/utils/usageLimitMessage.ts | 13 +++ 12 files changed, 512 insertions(+), 100 deletions(-) diff --git a/site/src/api/queries/chatMessageEdits.test.ts b/site/src/api/queries/chatMessageEdits.test.ts index 0cf726ff85c18..8314f157e3b55 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); + // The first page is ordered 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 2fbefa12741f1..fe0512beada4d 100644 --- a/site/src/api/queries/chatMessageEdits.ts +++ b/site/src/api/queries/chatMessageEdits.ts @@ -117,28 +117,40 @@ export const projectEditedConversationIntoCache = ({ export const reconcileEditedMessageInCache = ({ currentData, optimisticMessageId, - responseMessage, + responseMessages, + deletedMessageIds, }: { currentData: InfiniteData | undefined; optimisticMessageId: number; - responseMessage: TypesGen.ChatMessage; + // Every message the edit inserted, in insertion order. All of them + // must land in the cache, or a stream reconnect keyed on the + // highest cached ID would skip rows around the replacement. + responseMessages: readonly TypesGen.ChatMessage[]; + // Messages the edit soft-deleted. Dropped here so the cache does + // not keep them if the history reset event is missed. + 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.test.ts b/site/src/api/queries/chats.test.ts index 89156318d5a6c..b8112ee393025 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -1061,37 +1061,6 @@ describe("mutation invalidation scope", () => { ).toBe(true); }); - it("editChatMessage onError invalidates messages", async () => { - const queryClient = createTestQueryClient(); - const chatId = "chat-1"; - const messages = [3, 2, 1].map((id) => makeMsg(chatId, id)); - - queryClient.setQueryData(chatMessagesKey(chatId), { - pages: [{ messages, queued_messages: [], has_more: false }], - pageParams: [undefined], - }); - - const mutation = editChatMessage(queryClient, chatId); - mutation.onError( - new Error("fail"), - { messageId: 2, req: editReq }, - { - previousData: { - pages: [{ messages, queued_messages: [], has_more: false }], - pageParams: [undefined], - }, - }, - ); - - await new Promise((r) => setTimeout(r, 0)); - - const messagesState = queryClient.getQueryState(chatMessagesKey(chatId)); - expect( - messagesState?.isInvalidated, - "chatMessagesKey should be invalidated on error", - ).toBe(true); - }); - // Shared type for the infinite messages cache shape used by // editChatMessage tests below. type InfMessages = { @@ -1138,6 +1107,37 @@ describe("mutation invalidation scope", () => { requestContent: editReq.content, }); + it("editChatMessage onError invalidates messages", async () => { + const queryClient = createTestQueryClient(); + const chatId = "chat-1"; + const messages = [3, 2, 1].map((id) => makeMsg(chatId, id)); + + queryClient.setQueryData(chatMessagesKey(chatId), { + pages: [{ messages, queued_messages: [], has_more: false }], + pageParams: [undefined], + }); + + const mutation = editChatMessage(queryClient, chatId); + mutation.onError( + new Error("fail"), + { messageId: 2, req: editReq }, + { + previousData: { + pages: [{ messages, queued_messages: [], has_more: false }], + pageParams: [undefined], + }, + }, + ); + + await new Promise((r) => setTimeout(r, 0)); + + const messagesState = queryClient.getQueryState(chatMessagesKey(chatId)); + expect( + messagesState?.isInvalidated, + "chatMessagesKey should be invalidated on error", + ).toBe(true); + }); + it("editChatMessage writes the optimistic replacement into cache", async () => { const queryClient = createTestQueryClient(); const chatId = "chat-1"; diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index a23e7efd7d3b6..88962f9edfd31 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -1427,7 +1427,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.test.ts b/site/src/pages/AgentsPage/AgentChatPage.test.ts index 945a5724e6e4f..e87c37daabdf8 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.test.ts +++ b/site/src/pages/AgentsPage/AgentChatPage.test.ts @@ -1,14 +1,18 @@ 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 { draftInputStorageKeyPrefix, getPersistedDraftInputValue, getWorkspaceOptionsWithLinkedWorkspace, + reconcilePromotedQueueHead, restoreOptimisticRequestSnapshot, runPromoteQueuedMessage, submitEditAndScroll, @@ -284,6 +288,100 @@ 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(); + // Pre-send queue was [a, b]; a was promoted and c was queued, + // and the authoritative post-promotion snapshot [b, c] landed + // before the send response. Re-appending c must not duplicate it. + 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); + + // A late pre-promotion snapshot must not resurrect the + // promoted row, while the post-promotion snapshot clears the + // suppression entry. + 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("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("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 c96001d1ce2d6..70767b4a93cee 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -115,6 +115,7 @@ import { import { type ChatDetailError, formatUsageLimitMessage, + isChatHookDispatchFailedResponse, isChatUsageLimitExceededResponse, } from "./utils/usageLimitMessage"; @@ -232,6 +233,37 @@ export const runPromoteQueuedMessage = async (params: { } }; +// promotedHeadID must be the queue head captured before the send because +// queue updates can rotate the current head before the response arrives. +export const reconcilePromotedQueueHead = ( + store: Pick< + ChatStore, + "batch" | "getSnapshot" | "setQueuedMessages" | "suppressQueuedMessageID" + >, + insertedMessages: readonly TypesGen.ChatMessage[], + promotedHeadID: number | undefined, + queuedTail: TypesGen.ChatQueuedMessage | undefined, +): readonly TypesGen.ChatQueuedMessage[] | undefined => { + if (promotedHeadID === undefined) { + return undefined; + } + if (!insertedMessages.some((message) => message.role === "user")) { + return undefined; + } + const remaining = store + .getSnapshot() + .queuedMessages.filter((message) => message.id !== promotedHeadID); + const next = + queuedTail && !remaining.some((message) => message.id === queuedTail.id) + ? [...remaining, queuedTail] + : remaining; + store.batch(() => { + store.suppressQueuedMessageID(promotedHeadID); + store.setQueuedMessages(next); + }); + return next; +}; + export async function submitEditAndScroll({ editMessage, editArgs, @@ -1102,7 +1134,12 @@ const AgentChatPage: FC = () => { }; const aiGatewayDisabled = !useAIGatewayEnabled(); - const { store, clearStreamError, upsertCacheMessages } = useChatStore({ + const { + store, + clearStreamError, + setCacheQueuedMessages, + upsertCacheMessages, + } = useChatStore({ chatID: agentId, chatMessages: chatMessagesList, chatRecord, @@ -1254,7 +1291,9 @@ const AgentChatPage: FC = () => { } else if (isApiError(error)) { const detail = error.response?.data?.detail?.trim() || undefined; const reason: ChatDetailError = { - kind: "generic", + kind: isChatHookDispatchFailedResponse(error.response?.data) + ? "hook_dispatch_failed" + : "generic", message: getErrorMessage(error, "An unexpected error occurred."), ...(detail ? { detail } : {}), }; @@ -1627,6 +1666,9 @@ const AgentChatPage: FC = () => { clearStreamError(); scrollToBottomRef.current?.(); + // Capture the queue head before sending because an errored chat may promote it. + const queueHeadIDBeforeSend = store.getSnapshot().queuedMessages[0]?.id; + // 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,12 +1678,16 @@ const AgentChatPage: FC = () => { response = await sendMessage(request); } catch (error) { handleUsageLimitError(error); + // Refresh chat details in case the failed request changed server state. + 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. + // queued), clear the stream so the timeline updates without + // waiting for the WebSocket stream. if (!response.queued) { store.clearStreamState(); // Optimistically set status to "running" so the @@ -1653,9 +1699,26 @@ 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]); + } + // Prefer the full inserted batch: queued sends can insert + // messages beyond the user row, such as a promoted queue head + // on an errored chat, and a stream reconnect keyed on the + // highest cached ID would skip them, so upsert unconditionally. + const insertedMessages = + response.messages ?? (response.message ? [response.message] : []); + if (insertedMessages.length > 0) { + store.upsertDurableMessages(insertedMessages); + upsertCacheMessages(insertedMessages); + if (response.queued) { + const reconciledQueue = reconcilePromotedQueueHead( + store, + insertedMessages, + queueHeadIDBeforeSend, + response.queued_message, + ); + if (reconciledQueue) { + setCacheQueuedMessages(reconciledQueue); + } } } if (selectedModelConfigID) { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index e58d45bdd1c54..a64a2245fa511 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -405,6 +405,69 @@ 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, + parsedMessages: buildMessages([ + { + ...baseMessage, + id: 1, + role: "user", + content: [ + { type: "text", text: "original prompt" }, + { + type: "hook-notice", + text: "Deployment context was added to this prompt.", + }, + ], + }, + ]), + }, + 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("Deployment context was added to this prompt."), + ).toBeVisible(); + expect(canvas.getByText("original prompt")).toBeVisible(); + }, +}; + 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 db86c1087b4b9..8422eb26267f2 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", @@ -601,6 +649,14 @@ const ChatMessageItem = memo<{ "group/msg relative transition-opacity duration-200", )} > + {parsed.hookNotices.map((notice, index) => ( + + {notice} + + ))} {isUser ? ( = 0; i--) { const entry = displayMessages[i]; + if (entry.message.role === "system") { + nextVisibleIsUser = true; + continue; + } if (entry.message.role !== "user") { flags[i] = nextVisibleIsUser; } diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index 4bb8c72197460..4fef63a3a9286 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -57,6 +57,9 @@ export const useChatStore = ( ): { store: ChatStore; clearStreamError: () => void; + setCacheQueuedMessages: ( + queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined, + ) => void; upsertCacheMessages: (messages: readonly TypesGen.ChatMessage[]) => void; } => { const { @@ -126,6 +129,42 @@ export const useChatStore = ( // its snapshot, defeating pagination. const initialDataLoaded = chatMessages !== undefined; + // Writes an authoritative queued-message snapshot into the + // messages query cache so REST re-hydration cannot replay a stale + // queue over the store. + const setCacheQueuedMessages = useCallback( + (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), + ], + }; + }); + }, + [chatID, queryClient], + ); + // Write WebSocket-delivered durable messages into the React // Query infinite cache so that navigating away and back // serves up-to-date data instead of the stale REST snapshot. @@ -324,38 +363,6 @@ 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; @@ -570,7 +577,7 @@ export const useChatStore = ( store.applyAuthoritativeQueuedMessages( streamEvent.queued_messages, ); - updateChatQueuedMessages(streamEvent.queued_messages); + setCacheQueuedMessages(streamEvent.queued_messages); continue; case "status": { const nextStatus = streamEvent.status?.status; @@ -714,6 +721,7 @@ export const useChatStore = ( initialDataLoaded, queryClient, replaceCacheMessages, + setCacheQueuedMessages, store, upsertCacheMessages, ]); @@ -722,6 +730,7 @@ export const useChatStore = ( clearStreamError: () => { store.clearStreamError(); }, + setCacheQueuedMessages, upsertCacheMessages, }; }; diff --git a/site/src/pages/AgentsPage/components/QueuedMessagesList.test.ts b/site/src/pages/AgentsPage/components/QueuedMessagesList.test.ts index 1f6a2db4c3e99..b0a11daed390e 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 d585200f8ec85..0ea38c43b9d97 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 && ( ; + return obj.kind === "hook_dispatch_failed"; +} + /** * Build a user-friendly usage-limit message from structured 409 * response data. Falls back to a generic message if structured From 2f645c9dbbfa84ebc62de92597458486348af66e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:06:54 +0000 Subject: [PATCH 02/35] fix(site): make queued hook notices keyboard-accessible and keep promote suppression - Use a focusable tooltip trigger whose accessible name carries the notice text so keyboard and screen reader users can read queued hook outcomes, and cover the interaction in Storybook. - Skip REST re-hydration snapshots identical to the visible queue so the promoted-queue reconciliation's own cache write cannot lift the promote suppression while a stale pre-promotion queue_update can still arrive. --- .../ChatConversation/useChatStore.ts | 13 ++++++++++ .../components/QueuedMessagesList.stories.tsx | 24 +++++++++++++++++++ .../components/QueuedMessagesList.tsx | 12 +++++----- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index 4fef63a3a9286..f87bb04a0bf84 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -337,6 +337,19 @@ export const useChatStore = ( return; } queuedMessagesHydratedChatIDRef.current = chatID; + // Skip snapshots identical to the visible queue. The promoted-queue + // reconciliation writes its own optimistic snapshot into the cache, + // and treating that write as authoritative would lift the promote + // suppression while a stale pre-promotion queue_update can still + // arrive and re-show the promoted message. + if ( + chatQueuedMessagesEqualByID( + store.getSnapshot().queuedMessages, + chatQueuedMessages ?? [], + ) + ) { + return; + } store.applyAuthoritativeQueuedMessages(chatQueuedMessages); }, [chatMessagesData, chatID, chatQueuedMessages, store]); diff --git a/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx b/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx index 6142665f4b1fc..231c1b2dae292 100644 --- a/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx +++ b/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx @@ -201,3 +201,27 @@ export const MixedQueueWithAttachments: Story = { ], }, }; + +// A queued message carrying a lifecycle hook notice shows an info +// indicator whose accessible name includes the notice text and whose +// tooltip opens on keyboard focus. +export const HookNotice: Story = { + args: { + messages: [ + buildMessage(1, [ + { type: "text", text: "Deploy to production" }, + { type: "hook-notice", text: "Deployment prompts are audited." }, + ] as ChatQueuedMessage["content"]), + ], + }, + 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.tsx b/site/src/pages/AgentsPage/components/QueuedMessagesList.tsx index 0ea38c43b9d97..a8d418336d3d4 100644 --- a/site/src/pages/AgentsPage/components/QueuedMessagesList.tsx +++ b/site/src/pages/AgentsPage/components/QueuedMessagesList.tsx @@ -217,13 +217,13 @@ export const QueuedMessagesList: FC = ({ {item.hookNotices.length > 0 && ( - - - + {item.hookNotices.join(" ")} From d0e0ad8a2164fa255f69f5a944a6a41d10e75d63 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:36:51 +0000 Subject: [PATCH 03/35] fix(site): hide the inactive sticky message copy from assistive tech StickyUserMessage renders the message twice while stuck; the flow copy was only opacity-hidden, so screen readers encountered the message and its hook notices twice. --- .../components/ChatConversation/ConversationTimeline.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index 8422eb26267f2..eb6f47598b378 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -1072,6 +1072,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} > Date: Thu, 23 Jul 2026 17:24:20 +0000 Subject: [PATCH 04/35] fix(site): refresh chat state after promoted sends and failed edits Queued sends that promote a head now clear the stream and set the store to running so the Thinking indicator can appear before the websocket status event. Failed edits invalidate the chat query because hook dispatch failures can park the chat in error server-side. Also trims redundant comments and restores the moved onError test to its original position. --- site/src/api/queries/chatMessageEdits.ts | 5 -- site/src/api/queries/chats.test.ts | 62 ++++++++++----------- site/src/pages/AgentsPage/AgentChatPage.tsx | 12 ++++ 3 files changed, 43 insertions(+), 36 deletions(-) diff --git a/site/src/api/queries/chatMessageEdits.ts b/site/src/api/queries/chatMessageEdits.ts index fe0512beada4d..84235a007c214 100644 --- a/site/src/api/queries/chatMessageEdits.ts +++ b/site/src/api/queries/chatMessageEdits.ts @@ -122,12 +122,7 @@ export const reconcileEditedMessageInCache = ({ }: { currentData: InfiniteData | undefined; optimisticMessageId: number; - // Every message the edit inserted, in insertion order. All of them - // must land in the cache, or a stream reconnect keyed on the - // highest cached ID would skip rows around the replacement. responseMessages: readonly TypesGen.ChatMessage[]; - // Messages the edit soft-deleted. Dropped here so the cache does - // not keep them if the history reset event is missed. deletedMessageIds?: readonly number[]; }): InfiniteData | undefined => { if (!currentData?.pages?.length || responseMessages.length === 0) { diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index b8112ee393025..89156318d5a6c 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -1061,6 +1061,37 @@ describe("mutation invalidation scope", () => { ).toBe(true); }); + it("editChatMessage onError invalidates messages", async () => { + const queryClient = createTestQueryClient(); + const chatId = "chat-1"; + const messages = [3, 2, 1].map((id) => makeMsg(chatId, id)); + + queryClient.setQueryData(chatMessagesKey(chatId), { + pages: [{ messages, queued_messages: [], has_more: false }], + pageParams: [undefined], + }); + + const mutation = editChatMessage(queryClient, chatId); + mutation.onError( + new Error("fail"), + { messageId: 2, req: editReq }, + { + previousData: { + pages: [{ messages, queued_messages: [], has_more: false }], + pageParams: [undefined], + }, + }, + ); + + await new Promise((r) => setTimeout(r, 0)); + + const messagesState = queryClient.getQueryState(chatMessagesKey(chatId)); + expect( + messagesState?.isInvalidated, + "chatMessagesKey should be invalidated on error", + ).toBe(true); + }); + // Shared type for the infinite messages cache shape used by // editChatMessage tests below. type InfMessages = { @@ -1107,37 +1138,6 @@ describe("mutation invalidation scope", () => { requestContent: editReq.content, }); - it("editChatMessage onError invalidates messages", async () => { - const queryClient = createTestQueryClient(); - const chatId = "chat-1"; - const messages = [3, 2, 1].map((id) => makeMsg(chatId, id)); - - queryClient.setQueryData(chatMessagesKey(chatId), { - pages: [{ messages, queued_messages: [], has_more: false }], - pageParams: [undefined], - }); - - const mutation = editChatMessage(queryClient, chatId); - mutation.onError( - new Error("fail"), - { messageId: 2, req: editReq }, - { - previousData: { - pages: [{ messages, queued_messages: [], has_more: false }], - pageParams: [undefined], - }, - }, - ); - - await new Promise((r) => setTimeout(r, 0)); - - const messagesState = queryClient.getQueryState(chatMessagesKey(chatId)); - expect( - messagesState?.isInvalidated, - "chatMessagesKey should be invalidated on error", - ).toBe(true); - }); - it("editChatMessage writes the optimistic replacement into cache", async () => { const queryClient = createTestQueryClient(); const chatId = "chat-1"; diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 70767b4a93cee..8382b21903901 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1635,6 +1635,13 @@ const AgentChatPage: FC = () => { onError: (error) => { restoreOptimisticRequestSnapshot(store, previousSnapshot); handleUsageLimitError(error); + // A failed edit can park the chat in error server-side + // (hook dispatch failures); refresh so the status is not + // stale if the websocket event is missed. + void queryClient.invalidateQueries({ + queryKey: chatKey(agentId), + exact: true, + }); }, }); if (editSelectedModelConfigID) { @@ -1718,6 +1725,11 @@ const AgentChatPage: FC = () => { ); if (reconciledQueue) { setCacheQueuedMessages(reconciledQueue); + // A promoted head means a turn just started; clear the + // stale error status so the Thinking indicator can show + // before the status websocket event arrives. + store.clearStreamState(); + store.setChatStatus("running"); } } } From 009182d1c8389a07803c1ea3e1e153b05b74ed1a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:07:23 +0000 Subject: [PATCH 05/35] fix(site): drop avoidable cast in the HookNotice story --- .../pages/AgentsPage/components/QueuedMessagesList.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx b/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx index 231c1b2dae292..caca89806b012 100644 --- a/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx +++ b/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx @@ -211,7 +211,7 @@ export const HookNotice: Story = { buildMessage(1, [ { type: "text", text: "Deploy to production" }, { type: "hook-notice", text: "Deployment prompts are audited." }, - ] as ChatQueuedMessage["content"]), + ]), ], }, play: async ({ canvasElement }) => { From b8eabe1257ea3a01dc6cf9b9cbff2af234409ac1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:02:06 +0000 Subject: [PATCH 06/35] fix(site): keep suppressed queued messages out of the query cache A queue_update that still contains a promoted message was cached raw, so REST re-hydration could re-show the suppressed head. Cache the store's filtered snapshot instead. --- .../ChatConversation/chatStore.test.tsx | 77 +++++++++++++++++++ .../ChatConversation/useChatStore.ts | 5 +- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index 1d70610023f98..c440643713e0a 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -1537,6 +1537,83 @@ 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); + }); + + // Promote the queued message, then deliver a stale queue_update + // that still contains it. + 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"); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index f87bb04a0bf84..2d3cb6fcebf70 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -590,7 +590,10 @@ export const useChatStore = ( store.applyAuthoritativeQueuedMessages( streamEvent.queued_messages, ); - setCacheQueuedMessages(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. + setCacheQueuedMessages(store.getSnapshot().queuedMessages); continue; case "status": { const nextStatus = streamEvent.status?.status; From aa4036030b79058b2af9808f082085ef1675261f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:48:24 +0000 Subject: [PATCH 07/35] fix(site): drop manual useCallback in the compiler-managed chat store hook Hoist the queued-message cache write to a module-level helper so the hook needs no manual memoization and the effect no longer depends on a locally created callback. --- .../ChatConversation/useChatStore.ts | 87 ++++++++++--------- 1 file changed, 47 insertions(+), 40 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index 2d3cb6fcebf70..2d4c893b07379 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,40 @@ import { } from "./chatStore"; import type { RetryState } from "./types"; +// Writes an authoritative queued-message snapshot into the messages +// query cache so REST re-hydration cannot replay 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 normalizeRetryState = (retry: TypesGen.ChatStreamRetry): RetryState => ({ attempt: Math.max(1, retry.attempt), error: retry.error.trim() || "Retrying request shortly.", @@ -129,42 +167,6 @@ export const useChatStore = ( // its snapshot, defeating pagination. const initialDataLoaded = chatMessages !== undefined; - // Writes an authoritative queued-message snapshot into the - // messages query cache so REST re-hydration cannot replay a stale - // queue over the store. - const setCacheQueuedMessages = useCallback( - (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), - ], - }; - }); - }, - [chatID, queryClient], - ); - // Write WebSocket-delivered durable messages into the React // Query infinite cache so that navigating away and back // serves up-to-date data instead of the stale REST snapshot. @@ -593,7 +595,11 @@ export const useChatStore = ( // Cache the store's filtered queue, not the raw // event, so a promoted message suppressed by the // store cannot reappear on REST re-hydration. - setCacheQueuedMessages(store.getSnapshot().queuedMessages); + writeQueuedMessagesToCache( + queryClient, + chatID, + store.getSnapshot().queuedMessages, + ); continue; case "status": { const nextStatus = streamEvent.status?.status; @@ -737,7 +743,6 @@ export const useChatStore = ( initialDataLoaded, queryClient, replaceCacheMessages, - setCacheQueuedMessages, store, upsertCacheMessages, ]); @@ -746,7 +751,9 @@ export const useChatStore = ( clearStreamError: () => { store.clearStreamError(); }, - setCacheQueuedMessages, + setCacheQueuedMessages: (queuedMessages) => { + writeQueuedMessagesToCache(queryClient, chatID, queuedMessages); + }, upsertCacheMessages, }; }; From 9128684f606d0bccff61253b5a27a96aaf383b2c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:06:53 +0000 Subject: [PATCH 08/35] fix(site): thread urlTransform through sticky user message hook notices --- .../ChatConversation/ConversationTimeline.stories.tsx | 11 +++++++---- .../ChatConversation/ConversationTimeline.tsx | 5 +++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index a64a2245fa511..5bb5cb281894a 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -441,6 +441,8 @@ export const LifecycleHookNotice: Story = { export const LifecycleHookNoticeOnUserMessage: Story = { args: { ...defaultArgs, + urlTransform: (url) => + url.replace("http://localhost:3000", "https://proxy.example.com"), parsedMessages: buildMessages([ { ...baseMessage, @@ -450,7 +452,7 @@ export const LifecycleHookNoticeOnUserMessage: Story = { { type: "text", text: "original prompt" }, { type: "hook-notice", - text: "Deployment context was added to this prompt.", + text: "Deployment context was added: [policy](http://localhost:3000/policy)", }, ], }, @@ -461,10 +463,11 @@ export const LifecycleHookNoticeOnUserMessage: Story = { const notice = canvas.getByRole("note"); expect(notice).toBeVisible(); expect(within(notice).getByText("Lifecycle hook")).toBeVisible(); - expect( - within(notice).getByText("Deployment context was added to this prompt."), - ).toBeVisible(); expect(canvas.getByText("original prompt")).toBeVisible(); + // The user-message notice must receive the timeline's + // urlTransform even through the sticky message wrapper. + const link = within(notice).getByRole("link", { name: "policy" }); + expect(link).toHaveAttribute("href", "https://proxy.example.com/policy"); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index eb6f47598b378..d2e4806d00f0e 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -833,6 +833,7 @@ const StickyUserMessage = memo<{ nextUserMessageId?: number; onJumpToUserMessage?: (messageId: number) => void; registerSentinel?: (messageId: number, el: HTMLDivElement | null) => void; + urlTransform?: UrlTransform; }>( ({ message, @@ -844,6 +845,7 @@ const StickyUserMessage = memo<{ nextUserMessageId, onJumpToUserMessage, registerSentinel, + urlTransform, }) => { const [isStuck, setIsStuck] = useState(false); const [isReady, setIsReady] = useState(false); @@ -1087,6 +1089,7 @@ const StickyUserMessage = memo<{ prevUserMessageId={prevUserMessageId} nextUserMessageId={nextUserMessageId} onJumpToUserMessage={onJumpToUserMessage} + urlTransform={urlTransform} /> @@ -1132,6 +1135,7 @@ const StickyUserMessage = memo<{ prevUserMessageId={prevUserMessageId} nextUserMessageId={nextUserMessageId} onJumpToUserMessage={onJumpToUserMessage} + urlTransform={urlTransform} fadeFromBottom /> @@ -1330,6 +1334,7 @@ export const ConversationTimeline = memo( nextUserMessageId={userNeighborsById.get(message.id)?.nextId} onJumpToUserMessage={jumpToUserMessage} registerSentinel={registerSentinel} + urlTransform={urlTransform} /> ); } From cdf442e7db66d8114dafa9011a0577571e68d581 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:43:58 +0000 Subject: [PATCH 09/35] style(site/src/pages/AgentsPage): remove redundant test comments --- site/src/pages/AgentsPage/AgentChatPage.test.ts | 8 ++------ .../ChatConversation/ConversationTimeline.stories.tsx | 2 -- .../components/ChatConversation/chatStore.test.tsx | 2 -- .../AgentsPage/components/QueuedMessagesList.stories.tsx | 3 --- 4 files changed, 2 insertions(+), 13 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.test.ts b/site/src/pages/AgentsPage/AgentChatPage.test.ts index e87c37daabdf8..eb6bc03c77742 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.test.ts +++ b/site/src/pages/AgentsPage/AgentChatPage.test.ts @@ -319,9 +319,7 @@ describe("reconcilePromotedQueueHead", () => { it("does not suppress the rotated head when a queue_update already applied", () => { const store = createChatStore(); - // Pre-send queue was [a, b]; a was promoted and c was queued, - // and the authoritative post-promotion snapshot [b, c] landed - // before the send response. Re-appending c must not duplicate it. + // The authoritative [b, c] snapshot arrives before the send response. const a = buildQueuedMessage(1, "A"); const b = buildQueuedMessage(2, "B"); const c = buildQueuedMessage(3, "C"); @@ -335,9 +333,7 @@ describe("reconcilePromotedQueueHead", () => { expect(snapshot.suppressedQueuedMessageIDs.has(b.id)).toBe(false); expect(snapshot.suppressedQueuedMessageIDs.has(c.id)).toBe(false); - // A late pre-promotion snapshot must not resurrect the - // promoted row, while the post-promotion snapshot clears the - // suppression entry. + // A late pre-promotion snapshot must not resurrect the promoted row. store.applyAuthoritativeQueuedMessages([a, b, c]); expect(store.getSnapshot().queuedMessages.map((m) => m.id)).toEqual([ b.id, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 5bb5cb281894a..58aa717daa2fd 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -464,8 +464,6 @@ export const LifecycleHookNoticeOnUserMessage: Story = { expect(notice).toBeVisible(); expect(within(notice).getByText("Lifecycle hook")).toBeVisible(); expect(canvas.getByText("original prompt")).toBeVisible(); - // The user-message notice must receive the timeline's - // urlTransform even through the sticky message wrapper. const link = within(notice).getByRole("link", { name: "policy" }); expect(link).toHaveAttribute("href", "https://proxy.example.com/policy"); }, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index c440643713e0a..439bd92bb19df 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -1591,8 +1591,6 @@ describe("useChatStore", () => { expect(watchChat).toHaveBeenCalledWith(chatID, 1); }); - // Promote the queued message, then deliver a stale queue_update - // that still contains it. act(() => { result.current.store.suppressQueuedMessageID(queuedMessage.id); }); diff --git a/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx b/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx index caca89806b012..72ebb3444f9d9 100644 --- a/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx +++ b/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx @@ -202,9 +202,6 @@ export const MixedQueueWithAttachments: Story = { }, }; -// A queued message carrying a lifecycle hook notice shows an info -// indicator whose accessible name includes the notice text and whose -// tooltip opens on keyboard focus. export const HookNotice: Story = { args: { messages: [ From aa1a6567675aa5ca16b2838cee493dbd70fffc43 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:13:39 +0000 Subject: [PATCH 10/35] test(site/src/pages/AgentsPage): cover promoted queued sends with an interaction story --- .../AgentsPage/AgentChatPage.stories.tsx | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 8187e3ad5fcd5..57e2a3b8903a9 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -2849,3 +2849,81 @@ 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, +}; + +/** A queued send on an errored chat can promote the previous queue head: + * the inserted batch lands in the transcript, the new send becomes the + * queued tail, and the stale error flips to a running Thinking state. */ +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 sendSpy = spyOn( + API.experimental, + "createChatMessage", + ).mockResolvedValue({ + queued: true, + messages: [promotedHead], + queued_message: { + ...MockChatQueuedMessage, + id: 43, + chat_id: CHAT_ID, + content: [{ type: "text", text: "Follow-up prompt" }], + }, + }); + + 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); + }); + + // The promoted head moves from the queue into the transcript and + // the new send replaces it as the only queued row. + await waitFor(() => { + expect(canvas.getAllByText("Queued head prompt")).toHaveLength(1); + expect(canvas.getAllByText("Follow-up prompt")).toHaveLength(1); + }); + // The promotion started a turn: the Thinking indicator replaces + // the stale error state. + expect(await canvas.findByTestId("live-activity-slot")).toBeVisible(); + }, +}; From 2b47978ec46643bf47a649420e32d8f4787ec5e8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:06:52 +0000 Subject: [PATCH 11/35] fix(site): surface tool error reasons for denied and failed tool calls Thread the extracted execute result error into the failure tooltip instead of the hardcoded 'Command failed', and make write_file errors render honestly: an error label instead of 'Wrote ', the result error text in the expanded view, and no args-derived synthetic diff for content that was never written. Covers lifecycle hook denials, which previously looked like successful writes or opaque command failures. --- .../ChatElements/tools/ExecuteTool.tsx | 4 +- .../ChatElements/tools/Tool.stories.tsx | 52 +++++++++++++++++++ .../components/ChatElements/tools/Tool.tsx | 1 + .../ChatElements/tools/WriteFileTool.tsx | 20 +++++-- .../ChatElements/tools/toolVisibility.ts | 2 + 5 files changed, 75 insertions(+), 4 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx index 2e6add7289681..33e9e12eb5479 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, @@ -83,7 +85,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) => 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 6286c7d89dca6..7574680c50397 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,28 @@ export const ExecuteError: Story = { }, }; +export const ExecuteDeniedByHook: Story = { + args: { + name: "execute", + status: "error", + isError: true, + args: { command: "cat /etc/secrets" }, + result: { + error: + "Tool call denied by the deployment's lifecycle hook policy. 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 denial to the user and adjust your approach.", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("img", { + name: /denied by the deployment's lifecycle hook policy/, + }), + ).toBeVisible(); + expect(canvas.getByText(/Reason: secret reads are blocked/)).toBeVisible(); + }, +}; + export const ExecuteBackgrounded: Story = { args: { name: "execute", @@ -1781,6 +1803,36 @@ 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: + "Tool call denied by the deployment's lifecycle hook policy. 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(/denied by the deployment's lifecycle hook policy/), + ).toBeVisible(); + }); + expect(canvas.queryByTestId("write-file-diff")).not.toBeInTheDocument(); + }, +}; + // --------------------------------------------------------------------------- // EditFiles stories // --------------------------------------------------------------------------- diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index 1ee04043873b9..494dbc70ad77e 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 0a9d41480c477..8b9f652d7093d 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 the tool args, so on error it would + // show content that was never written. + const showDiff = hasDiff && !isError; + const errorDetail = isError ? errorMessage?.trim() : undefined; return ( - {hasDiff && ( + {errorDetail && ( +
+						{errorDetail}
+					
+ )} + {showDiff && ( Date: Fri, 24 Jul 2026 13:23:30 +0000 Subject: [PATCH 12/35] fix(site): surface edit_files failures and cover the execute errorText field Label failed edit_files calls 'Failed to edit' with the result error text in the expanded view, matching the write_file error rendering, and include the new errorText field in the execute render data unit test. --- .../ChatElements/tools/EditFilesTool.tsx | 32 ++++++++++--------- .../ChatElements/tools/Tool.stories.tsx | 5 ++- .../ChatElements/tools/toolVisibility.test.ts | 1 + 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx index a2b40c0a80c33..015d9c65bfb5a 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/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index 7574680c50397..74b85e65a16a9 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -2006,7 +2006,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/toolVisibility.test.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts index 077a626468cda..cda550f68b2c2 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts @@ -22,6 +22,7 @@ describe("toolVisibility", () => { ).toEqual({ command: "git fetch origin", transcriptBlocks: [{ kind: "output", text: "fetched" }], + errorText: "", durationMs: 47200, isBackgrounded: true, authenticateURL: "https://example.com/auth", From f4fc28ddf708d5fc46701f2123367c57ddbeb944 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:54:36 +0000 Subject: [PATCH 13/35] fix(site): align hook denial story fixtures with the external policy wording --- .../components/ChatElements/tools/Tool.stories.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) 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 74b85e65a16a9..03830052bc0c6 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -409,14 +409,14 @@ export const ExecuteDeniedByHook: Story = { args: { command: "cat /etc/secrets" }, result: { error: - "Tool call denied by the deployment's lifecycle hook policy. 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 denial to the user and adjust your approach.", + "This tool usage was blocked by an external policy (the deployment's lifecycle hook). 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); await expect( canvas.getByRole("img", { - name: /denied by the deployment's lifecycle hook policy/, + name: /blocked by an external policy/, }), ).toBeVisible(); expect(canvas.getByText(/Reason: secret reads are blocked/)).toBeVisible(); @@ -1815,7 +1815,7 @@ export const WriteFileDeniedByHook: Story = { }, result: { error: - "Tool call denied by the deployment's lifecycle hook policy. Reason: writes to src are blocked.", + "This tool usage was blocked by an external policy (the deployment's lifecycle hook). Reason: writes to src are blocked.", }, }, play: async ({ canvasElement }) => { @@ -1825,9 +1825,7 @@ export const WriteFileDeniedByHook: Story = { canvas.getByRole("button", { name: /Failed to write helpers\.ts/ }), ); await waitFor(() => { - expect( - canvas.getByText(/denied by the deployment's lifecycle hook policy/), - ).toBeVisible(); + expect(canvas.getByText(/blocked by an external policy/)).toBeVisible(); }); expect(canvas.queryByTestId("write-file-diff")).not.toBeInTheDocument(); }, From 31062df6bd96e4fa7cd130fd43769e4a6c2587f7 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:26:07 +0000 Subject: [PATCH 14/35] fix(site): align hook denial fixtures with the not-executed wording --- .../AgentsPage/components/ChatElements/tools/Tool.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 03830052bc0c6..ead73d70cad01 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -409,7 +409,7 @@ export const ExecuteDeniedByHook: Story = { args: { command: "cat /etc/secrets" }, result: { error: - "This tool usage was blocked by an external policy (the deployment's lifecycle hook). 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.", + "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 }) => { @@ -1815,7 +1815,7 @@ export const WriteFileDeniedByHook: Story = { }, result: { error: - "This tool usage was blocked by an external policy (the deployment's lifecycle hook). Reason: writes to src are blocked.", + "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 }) => { From 36451f3f10cfe29d717aa7b2ee1358e728d1744a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:51:12 +0000 Subject: [PATCH 15/35] fix(site): make dimmed timeline messages inert during edits --- .../ConversationTimeline.stories.tsx | 34 +++++++++++++++++++ .../ChatConversation/ConversationTimeline.tsx | 1 + 2 files changed, 35 insertions(+) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 58aa717daa2fd..529dc062b8e62 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -469,6 +469,40 @@ export const LifecycleHookNoticeOnUserMessage: Story = { }, }; +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 d2e4806d00f0e..dc1a9170f5fa9 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -648,6 +648,7 @@ const ChatMessageItem = memo<{ isAfterEditingMessage && "opacity-40 pointer-events-none", "group/msg relative transition-opacity duration-200", )} + inert={isAfterEditingMessage ? true : undefined} > {parsed.hookNotices.map((notice, index) => ( Date: Sat, 25 Jul 2026 08:57:17 +0000 Subject: [PATCH 16/35] fix(site/src/pages/AgentsPage): ignore stale queue snapshots after a promotion --- site/src/pages/AgentsPage/AgentChatPage.tsx | 6 +- .../chatStore.createStore.test.ts | 60 +++++++++++++++- .../components/ChatConversation/chatStore.ts | 72 +++++++++++++++++-- 3 files changed, 127 insertions(+), 11 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 8382b21903901..ea2b8a00a7a03 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -238,7 +238,7 @@ export const runPromoteQueuedMessage = async (params: { export const reconcilePromotedQueueHead = ( store: Pick< ChatStore, - "batch" | "getSnapshot" | "setQueuedMessages" | "suppressQueuedMessageID" + "batch" | "getSnapshot" | "setQueuedMessages" | "markQueuedMessagePromoted" >, insertedMessages: readonly TypesGen.ChatMessage[], promotedHeadID: number | undefined, @@ -258,7 +258,9 @@ export const reconcilePromotedQueueHead = ( ? [...remaining, queuedTail] : remaining; store.batch(() => { - store.suppressQueuedMessageID(promotedHeadID); + // The response carried the promoted user row, so the server has + // already deleted its queue row. + store.markQueuedMessagePromoted(promotedHeadID); store.setQueuedMessages(next); }); return next; 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 2087397b8eb6d..18c87a851e901 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts @@ -460,8 +460,8 @@ 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. + // The running-case promote only reorders the queue, so the backend + // still reports the suppressed message. store.applyAuthoritativeQueuedMessages([b, a, c]); expect( store.getSnapshot().queuedMessages.map((message) => message.id), @@ -491,6 +491,62 @@ 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"); + + // A was promoted into history and C was queued by the same send. + store.setQueuedMessages([b, c]); + store.markQueuedMessagePromoted(a.id); + + // This snapshot predates both the promotion and C. + 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("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.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts index 886524feed3fe..def8827b284b5 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts @@ -140,6 +140,10 @@ export type ChatStoreState = { // the running-case promote, where the backend reorders the // queued message to the front before auto-promoting it. suppressedQueuedMessageIDs: ReadonlySet; + // Suppressed IDs whose queue row the server has provably deleted, + // because the send response carried the promoted user row. A + // snapshot that still lists one predates that promotion. + promotedQueuedMessageIDs: ReadonlySet; subagentStatusOverrides: Map; }; @@ -168,6 +172,8 @@ export type ChatStore = { queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined, ) => void; suppressQueuedMessageID: (id: number) => void; + // Suppresses id and records that its queue row is already gone. + markQueuedMessagePromoted: (id: number) => void; unsuppressQueuedMessageID: (id: number) => void; clearSuppressedQueuedMessageIDs: () => void; setChatStatus: (status: TypesGen.ChatStatus | null) => void; @@ -197,6 +203,7 @@ const createInitialState = (): ChatStoreState => ({ reconnectState: null, queuedMessages: [], suppressedQueuedMessageIDs: new Set(), + promotedQueuedMessageIDs: new Set(), subagentStatusOverrides: new Map(), }); @@ -405,6 +412,16 @@ export const createChatStore = (): ChatStore => { applyAuthoritativeQueuedMessages: (queuedMessages) => { const incoming = queuedMessages ?? []; setState((current) => { + // A snapshot listing an ID whose row the server already + // deleted predates that deletion, so applying it would both + // revert the queue and drop messages queued since. + if ( + incoming.some((message) => + current.promotedQueuedMessageIDs.has(message.id), + ) + ) { + return current; + } let nextSuppressed = current.suppressedQueuedMessageIDs; if (current.suppressedQueuedMessageIDs.size > 0) { const incomingIDs = new Set(incoming.map((message) => message.id)); @@ -431,13 +448,19 @@ 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, }; }); }, @@ -451,22 +474,57 @@ 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: () => { 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) => { From b3add472918f6f4b1c603994bfaee965f762e054 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:58:16 +0000 Subject: [PATCH 17/35] test(site/src/pages/AgentsPage): scope promotion story assertions and trim comments --- site/src/pages/AgentsPage/AgentChatPage.stories.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 57e2a3b8903a9..370617bb6ff27 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -2916,14 +2916,14 @@ export const QueuedSendPromotesPreviousHead: Story = { expect(sendSpy).toHaveBeenCalledTimes(1); }); - // The promoted head moves from the queue into the transcript and - // the new send replaces it as the only queued row. + 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(); }); - // The promotion started a turn: the Thinking indicator replaces - // the stale error state. + // Promotion starts a turn, so the Thinking indicator replaces the error. expect(await canvas.findByTestId("live-activity-slot")).toBeVisible(); }, }; From bfe35e815a02c61eaf84b9f0d7c6a7f0cfb188f8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:01:22 +0000 Subject: [PATCH 18/35] style(site/src/pages/AgentsPage): tighten hook queue and story comments --- site/src/pages/AgentsPage/AgentChatPage.stories.tsx | 1 - site/src/pages/AgentsPage/AgentChatPage.tsx | 7 +++---- .../ChatConversation/chatStore.createStore.test.ts | 5 +---- .../components/ChatConversation/chatStore.ts | 12 +++++------- 4 files changed, 9 insertions(+), 16 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 370617bb6ff27..1755eb349bcc2 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -2923,7 +2923,6 @@ export const QueuedSendPromotesPreviousHead: Story = { expect(canvas.getAllByText("Follow-up prompt")).toHaveLength(1); expect(timeline.queryByText("Follow-up prompt")).not.toBeInTheDocument(); }); - // Promotion starts a turn, so the Thinking indicator replaces the error. expect(await canvas.findByTestId("live-activity-slot")).toBeVisible(); }, }; diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index ea2b8a00a7a03..4ba0ba4f2860b 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -233,8 +233,8 @@ export const runPromoteQueuedMessage = async (params: { } }; -// promotedHeadID must be the queue head captured before the send because -// queue updates can rotate the current head before the response arrives. +// Use the pre-send queue head because queue updates may rotate it before +// the response arrives. export const reconcilePromotedQueueHead = ( store: Pick< ChatStore, @@ -258,8 +258,7 @@ export const reconcilePromotedQueueHead = ( ? [...remaining, queuedTail] : remaining; store.batch(() => { - // The response carried the promoted user row, so the server has - // already deleted its queue row. + // The promoted user row proves the server deleted its queue row. store.markQueuedMessagePromoted(promotedHeadID); store.setQueuedMessages(next); }); 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 18c87a851e901..75c65e323e89a 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts @@ -460,8 +460,7 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => { store.suppressQueuedMessageID(b.id); expect(store.getSnapshot().suppressedQueuedMessageIDs.has(b.id)).toBe(true); - // The running-case promote only reorders the queue, so the backend - // still reports 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), @@ -513,11 +512,9 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => { const b = makeQueuedMessage(2, "B"); const c = makeQueuedMessage(3, "C"); - // A was promoted into history and C was queued by the same send. store.setQueuedMessages([b, c]); store.markQueuedMessagePromoted(a.id); - // This snapshot predates both the promotion and C. store.applyAuthoritativeQueuedMessages([a, b]); expect( store.getSnapshot().queuedMessages.map((message) => message.id), diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts index def8827b284b5..9308057ea449e 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts @@ -140,9 +140,8 @@ export type ChatStoreState = { // the running-case promote, where the backend reorders the // queued message to the front before auto-promoting it. suppressedQueuedMessageIDs: ReadonlySet; - // Suppressed IDs whose queue row the server has provably deleted, - // because the send response carried the promoted user row. A - // snapshot that still lists one predates that promotion. + // IDs confirmed deleted from the queue because the send response + // contained their promoted user rows. promotedQueuedMessageIDs: ReadonlySet; subagentStatusOverrides: Map; }; @@ -172,7 +171,7 @@ export type ChatStore = { queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined, ) => void; suppressQueuedMessageID: (id: number) => void; - // Suppresses id and records that its queue row is already gone. + // Suppresses id and records that its queue row is already deleted. markQueuedMessagePromoted: (id: number) => void; unsuppressQueuedMessageID: (id: number) => void; clearSuppressedQueuedMessageIDs: () => void; @@ -412,9 +411,8 @@ export const createChatStore = (): ChatStore => { applyAuthoritativeQueuedMessages: (queuedMessages) => { const incoming = queuedMessages ?? []; setState((current) => { - // A snapshot listing an ID whose row the server already - // deleted predates that deletion, so applying it would both - // revert the queue and drop messages queued since. + // A snapshot containing a confirmed promoted ID predates its queue + // deletion. Applying it would also drop newer queued messages. if ( incoming.some((message) => current.promotedQueuedMessageIDs.has(message.id), From 4bf3f8948643fdd2c636febb810ba1c716a9ebe2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:18:01 +0000 Subject: [PATCH 19/35] fix(site/src/pages/AgentsPage): keep post-send reconciliation from clobbering newer server state --- .../pages/AgentsPage/AgentChatPage.test.ts | 18 ++++++++++++ site/src/pages/AgentsPage/AgentChatPage.tsx | 22 +++++++++++---- .../chatStore.createStore.test.ts | 28 +++++++++++++++++++ .../components/ChatConversation/chatStore.ts | 12 ++++++++ 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.test.ts b/site/src/pages/AgentsPage/AgentChatPage.test.ts index eb6bc03c77742..4f1b167d21bf8 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.test.ts +++ b/site/src/pages/AgentsPage/AgentChatPage.test.ts @@ -343,6 +343,24 @@ describe("reconcilePromotedQueueHead", () => { expect(store.getSnapshot().suppressedQueuedMessageIDs.size).toBe(0); }); + it("omits the response tail when a newer queue update was observed", () => { + const store = createChatStore(); + const a = buildQueuedMessage(1, "A"); + // The caller withholds the tail once it has seen a newer queue, + // because that snapshot may already have deleted it. + 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"); diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 4ba0ba4f2860b..c0e3275aa819f 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1676,6 +1676,8 @@ const AgentChatPage: FC = () => { // Capture the queue head before sending because an errored chat may promote it. const queueHeadIDBeforeSend = store.getSnapshot().queuedMessages[0]?.id; + const queueVersionBeforeSend = store.getAuthoritativeQueueVersion(); + const statusVersionBeforeSend = store.getChatStatusVersion(); // Don't clear stream state before the POST completes. // For queued sends the WebSocket status events handle @@ -1718,19 +1720,27 @@ const AgentChatPage: FC = () => { store.upsertDurableMessages(insertedMessages); upsertCacheMessages(insertedMessages); if (response.queued) { + // A queue update during the request already accounts for the + // tail, and may have deleted it, so merging it back would + // resurrect a phantom entry. + const sawNewerQueue = + store.getAuthoritativeQueueVersion() !== queueVersionBeforeSend; const reconciledQueue = reconcilePromotedQueueHead( store, insertedMessages, queueHeadIDBeforeSend, - response.queued_message, + sawNewerQueue ? undefined : response.queued_message, ); if (reconciledQueue) { setCacheQueuedMessages(reconciledQueue); - // A promoted head means a turn just started; clear the - // stale error status so the Thinking indicator can show - // before the status websocket event arrives. - store.clearStreamState(); - store.setChatStatus("running"); + // A promoted head means a turn just started, so clear the + // stale error status before the status websocket event + // arrives. A status event during the request is already + // newer than this optimistic value. + if (store.getChatStatusVersion() === statusVersionBeforeSend) { + store.clearStreamState(); + store.setChatStatus("running"); + } } } } 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 75c65e323e89a..d75e16f983984 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts @@ -529,6 +529,34 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => { expect(store.getSnapshot().suppressedQueuedMessageIDs.size).toBe(0); }); + it("tracks authoritative queue and status versions for in-flight requests", () => { + const store = createChatStore(); + const a = makeQueuedMessage(1, "A"); + const b = makeQueuedMessage(2, "B"); + + expect(store.getAuthoritativeQueueVersion()).toBe(0); + store.applyAuthoritativeQueuedMessages([a, b]); + const afterApply = store.getAuthoritativeQueueVersion(); + expect(afterApply).toBeGreaterThan(0); + + // Optimistic writes are not server observations. + store.setQueuedMessages([b]); + expect(store.getAuthoritativeQueueVersion()).toBe(afterApply); + + // An ignored stale snapshot is not an observation either. + store.markQueuedMessagePromoted(a.id); + store.applyAuthoritativeQueuedMessages([a, b]); + expect(store.getAuthoritativeQueueVersion()).toBe(afterApply); + + expect(store.getChatStatusVersion()).toBe(0); + store.setChatStatus("running"); + expect(store.getChatStatusVersion()).toBe(1); + store.setChatStatus("running"); + expect(store.getChatStatusVersion()).toBe(1); + store.setChatStatus("error"); + expect(store.getChatStatusVersion()).toBe(2); + }); + it("unsuppressQueuedMessageID clears a promoted marker after a failed promotion", () => { const store = createChatStore(); const a = makeQueuedMessage(1, "A"); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts index 9308057ea449e..75e6b4692fe51 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts @@ -173,6 +173,10 @@ export type ChatStore = { suppressQueuedMessageID: (id: number) => void; // Suppresses id and records that its queue row is already deleted. markQueuedMessagePromoted: (id: number) => void; + // Counters for detecting that the server reported a newer queue or + // status while a request was in flight. + getAuthoritativeQueueVersion: () => number; + getChatStatusVersion: () => number; unsuppressQueuedMessageID: (id: number) => void; clearSuppressedQueuedMessageIDs: () => void; setChatStatus: (status: TypesGen.ChatStatus | null) => void; @@ -208,6 +212,10 @@ const createInitialState = (): ChatStoreState => ({ export const createChatStore = (): ChatStore => { let state = createInitialState(); + // Bookkeeping, deliberately outside the rendered state so observing a + // server event cannot trigger a re-render. + let authoritativeQueueVersion = 0; + let chatStatusVersion = 0; const listeners = new Set<() => void>(); const emit = (): void => { @@ -420,6 +428,7 @@ export const createChatStore = (): ChatStore => { ) { return current; } + authoritativeQueueVersion++; let nextSuppressed = current.suppressedQueuedMessageIDs; if (current.suppressedQueuedMessageIDs.size > 0) { const incomingIDs = new Set(incoming.map((message) => message.id)); @@ -462,6 +471,8 @@ export const createChatStore = (): ChatStore => { }; }); }, + getAuthoritativeQueueVersion: () => authoritativeQueueVersion, + getChatStatusVersion: () => chatStatusVersion, suppressQueuedMessageID: (id) => { setState((current) => { if (current.suppressedQueuedMessageIDs.has(id)) { @@ -529,6 +540,7 @@ export const createChatStore = (): ChatStore => { if (state.chatStatus === status) { return; } + chatStatusVersion++; setState((current) => ({ ...current, chatStatus: status, From 140b907a682e33aaa548a9c1ab060c1b125e5d65 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:34:33 +0000 Subject: [PATCH 20/35] fix(site/src/pages/AgentsPage): give hook dispatch failures their own error title --- .../AgentsPage/AgentChatPage.stories.tsx | 45 +++++++++++++++++++ .../ChatConversation/chatStatusHelpers.ts | 2 + 2 files changed, 47 insertions(+) diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 1755eb349bcc2..457e091c8d7e3 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -2926,3 +2926,48 @@ export const QueuedSendPromotesPreviousHead: Story = { expect(await canvas.findByTestId("live-activity-slot")).toBeVisible(); }, }; + +/** A send rejected with the structured 502 hook-dispatch-failure body must + * render the lifecycle-hook title and the server's detail text, not the + * generic request-failure fallback. */ +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/components/ChatConversation/chatStatusHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts index 51550c5042c4a..137dc5cb263c6 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts @@ -48,6 +48,8 @@ export const getErrorTitle = ( return "Provider disabled"; case "content_filter": return "Response blocked"; + case "hook_dispatch_failed": + return "Lifecycle hook failed"; default: return mode === "retry" ? "Retrying request" : "Request failed"; } From 30090d94f4d40c4098cc958d7ad1f0bc7fbccca1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:50:08 +0000 Subject: [PATCH 21/35] refactor(site/src/pages/AgentsPage): narrow the hook response guard without a cast --- site/src/pages/AgentsPage/utils/usageLimitMessage.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts index 5ff7ea77220b1..c15d7c22bfe31 100644 --- a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts +++ b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts @@ -87,11 +87,12 @@ export function isChatUsageLimitExceededResponse( export function isChatHookDispatchFailedResponse( value: unknown, ): value is TypesGen.ChatHookDispatchFailedResponse { - if (value == null || typeof value !== "object") { - return false; - } - const obj = value as Record; - return obj.kind === "hook_dispatch_failed"; + return ( + typeof value === "object" && + value !== null && + "kind" in value && + value.kind === "hook_dispatch_failed" + ); } /** From 9f395bd51a4b454ee0621d28eccd374443504915 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:18:29 +0000 Subject: [PATCH 22/35] fix(site/src/pages/AgentsPage): apply the refetched status after a failed request --- site/src/pages/AgentsPage/AgentChatPage.tsx | 3 + .../ChatConversation/chatStore.test.tsx | 59 +++++++++++++++++++ .../ChatConversation/useChatStore.ts | 7 +++ 3 files changed, 69 insertions(+) diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index c0e3275aa819f..a5cf7975d2759 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1137,6 +1137,7 @@ const AgentChatPage: FC = () => { const aiGatewayDisabled = !useAIGatewayEnabled(); const { store, + acceptServerChatStatus, clearStreamError, setCacheQueuedMessages, upsertCacheMessages, @@ -1639,6 +1640,7 @@ const AgentChatPage: FC = () => { // A failed edit can park the chat in error server-side // (hook dispatch failures); refresh so the status is not // stale if the websocket event is missed. + acceptServerChatStatus(); void queryClient.invalidateQueries({ queryKey: chatKey(agentId), exact: true, @@ -1689,6 +1691,7 @@ const AgentChatPage: FC = () => { } catch (error) { handleUsageLimitError(error); // Refresh chat details in case the failed request changed server state. + acceptServerChatStatus(); void queryClient.invalidateQueries({ queryKey: chatKey(agentId), exact: true, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index 439bd92bb19df..bb352903f1655 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -2290,6 +2290,65 @@ 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 { result, rerender } = renderHook( + ({ status }: { status: TypesGen.ChatStatus }) => { + const { store, acceptServerChatStatus } = useChatStore({ + chatID, + chatMessages: [], + chatRecord: { ...buildChat(chatID), status }, + chatMessagesData: { + messages: [], + queued_messages: [], + has_more: false, + }, + chatQueuedMessages: [], + setChatErrorReason: vi.fn(), + clearChatErrorReason: vi.fn(), + }); + return { + acceptServerChatStatus, + chatStatus: useChatSelector(store, selectChatStatus), + }; + }, + { wrapper, initialProps: { status: "waiting" as TypesGen.ChatStatus } }, + ); + + await waitFor(() => { + expect(watchChat).toHaveBeenCalledWith(chatID, undefined); + }); + + // The socket becomes authoritative, so a refetched status is ignored. + act(() => { + mockSocket.emitData({ + type: "status", + chat_id: chatID, + status: { status: "running" }, + }); + }); + await waitFor(() => { + expect(result.current.chatStatus).toBe("running"); + }); + rerender({ status: "error" }); + expect(result.current.chatStatus).toBe("running"); + + // A failed request opts back in, so the next refetch applies. + act(() => { + result.current.acceptServerChatStatus(); + }); + rerender({ status: "waiting" }); + rerender({ status: "error" }); + await waitFor(() => { + expect(result.current.chatStatus).toBe("error"); + }); + }); + it("sets chatStatus to error and populates streamError on error event", async () => { immediateAnimationFrame(); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index 2d4c893b07379..fbf04f43cccbb 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -94,6 +94,7 @@ export const useChatStore = ( options: UseChatStoreOptions, ): { store: ChatStore; + acceptServerChatStatus: () => void; clearStreamError: () => void; setCacheQueuedMessages: ( queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined, @@ -751,6 +752,12 @@ 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: () => { + wsStatusReceivedRef.current = false; + }, setCacheQueuedMessages: (queuedMessages) => { writeQueuedMessagesToCache(queryClient, chatID, queuedMessages); }, From 5b7265e1e42759106ee5ed471f0ca6ad56afd600 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:27:15 +0000 Subject: [PATCH 23/35] fix(site/src/pages/AgentsPage): key the queued tail on server observation --- .../pages/AgentsPage/AgentChatPage.test.ts | 28 +++++++++++++++++ site/src/pages/AgentsPage/AgentChatPage.tsx | 26 ++++++++-------- .../chatStore.createStore.test.ts | 30 ++++++++++++------- .../components/ChatConversation/chatStore.ts | 17 +++++++---- 4 files changed, 72 insertions(+), 29 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.test.ts b/site/src/pages/AgentsPage/AgentChatPage.test.ts index 4f1b167d21bf8..b5eb080f0fdae 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.test.ts +++ b/site/src/pages/AgentsPage/AgentChatPage.test.ts @@ -343,6 +343,34 @@ describe("reconcilePromotedQueueHead", () => { 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"); + // The server acknowledged the tail, then a later snapshot deleted it. + 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"); diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index a5cf7975d2759..717252c120427 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -238,7 +238,11 @@ export const runPromoteQueuedMessage = async (params: { export const reconcilePromotedQueueHead = ( store: Pick< ChatStore, - "batch" | "getSnapshot" | "setQueuedMessages" | "markQueuedMessagePromoted" + | "batch" + | "getSnapshot" + | "setQueuedMessages" + | "markQueuedMessagePromoted" + | "hasObservedQueuedMessageID" >, insertedMessages: readonly TypesGen.ChatMessage[], promotedHeadID: number | undefined, @@ -253,10 +257,14 @@ export const reconcilePromotedQueueHead = ( const remaining = store .getSnapshot() .queuedMessages.filter((message) => message.id !== promotedHeadID); - const next = - queuedTail && !remaining.some((message) => message.id === queuedTail.id) - ? [...remaining, queuedTail] - : remaining; + // Append the tail only while the server has never reported it. Once a + // snapshot has listed it, its later absence means it was deleted, so + // re-adding it would resurrect a phantom row. + const tailPending = + queuedTail !== undefined && + !remaining.some((message) => message.id === queuedTail.id) && + !store.hasObservedQueuedMessageID(queuedTail.id); + const next = tailPending ? [...remaining, queuedTail] : remaining; store.batch(() => { // The promoted user row proves the server deleted its queue row. store.markQueuedMessagePromoted(promotedHeadID); @@ -1678,7 +1686,6 @@ const AgentChatPage: FC = () => { // Capture the queue head before sending because an errored chat may promote it. const queueHeadIDBeforeSend = store.getSnapshot().queuedMessages[0]?.id; - const queueVersionBeforeSend = store.getAuthoritativeQueueVersion(); const statusVersionBeforeSend = store.getChatStatusVersion(); // Don't clear stream state before the POST completes. @@ -1723,16 +1730,11 @@ const AgentChatPage: FC = () => { store.upsertDurableMessages(insertedMessages); upsertCacheMessages(insertedMessages); if (response.queued) { - // A queue update during the request already accounts for the - // tail, and may have deleted it, so merging it back would - // resurrect a phantom entry. - const sawNewerQueue = - store.getAuthoritativeQueueVersion() !== queueVersionBeforeSend; const reconciledQueue = reconcilePromotedQueueHead( store, insertedMessages, queueHeadIDBeforeSend, - sawNewerQueue ? undefined : response.queued_message, + response.queued_message, ); if (reconciledQueue) { setCacheQueuedMessages(reconciledQueue); 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 d75e16f983984..8659d97e5442b 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts @@ -529,24 +529,32 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => { expect(store.getSnapshot().suppressedQueuedMessageIDs.size).toBe(0); }); - it("tracks authoritative queue and status versions for in-flight requests", () => { + 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.getAuthoritativeQueueVersion()).toBe(0); + expect(store.hasObservedQueuedMessageID(a.id)).toBe(false); store.applyAuthoritativeQueuedMessages([a, b]); - const afterApply = store.getAuthoritativeQueueVersion(); - expect(afterApply).toBeGreaterThan(0); + expect(store.hasObservedQueuedMessageID(a.id)).toBe(true); + expect(store.hasObservedQueuedMessageID(b.id)).toBe(true); - // Optimistic writes are not server observations. - store.setQueuedMessages([b]); - expect(store.getAuthoritativeQueueVersion()).toBe(afterApply); + // A later snapshot dropping A does not unlearn that A existed. + store.applyAuthoritativeQueuedMessages([b]); + expect(store.hasObservedQueuedMessageID(a.id)).toBe(true); - // An ignored stale snapshot is not an observation either. - store.markQueuedMessagePromoted(a.id); - store.applyAuthoritativeQueuedMessages([a, b]); - expect(store.getAuthoritativeQueueVersion()).toBe(afterApply); + // Optimistic writes are not server reports. + store.setQueuedMessages([b, c]); + expect(store.hasObservedQueuedMessageID(c.id)).toBe(false); + + // Observations are per-chat. + store.clearSuppressedQueuedMessageIDs(); + expect(store.hasObservedQueuedMessageID(a.id)).toBe(false); + }); + + it("tracks chat status versions for in-flight requests", () => { + const store = createChatStore(); expect(store.getChatStatusVersion()).toBe(0); store.setChatStatus("running"); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts index 75e6b4692fe51..6303f00f51261 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts @@ -173,9 +173,11 @@ export type ChatStore = { suppressQueuedMessageID: (id: number) => void; // Suppresses id and records that its queue row is already deleted. markQueuedMessagePromoted: (id: number) => void; - // Counters for detecting that the server reported a newer queue or - // status while a request was in flight. - getAuthoritativeQueueVersion: () => number; + // Reports whether any authoritative snapshot has listed id. A tail the + // server never mentioned is still in flight; one it mentioned and then + // dropped was deleted. + hasObservedQueuedMessageID: (id: number) => boolean; + // Detects that the server reported a newer status mid-request. getChatStatusVersion: () => number; unsuppressQueuedMessageID: (id: number) => void; clearSuppressedQueuedMessageIDs: () => void; @@ -214,7 +216,7 @@ export const createChatStore = (): ChatStore => { let state = createInitialState(); // Bookkeeping, deliberately outside the rendered state so observing a // server event cannot trigger a re-render. - let authoritativeQueueVersion = 0; + let observedQueuedMessageIDs = new Set(); let chatStatusVersion = 0; const listeners = new Set<() => void>(); @@ -418,6 +420,9 @@ export const createChatStore = (): ChatStore => { }, applyAuthoritativeQueuedMessages: (queuedMessages) => { const incoming = queuedMessages ?? []; + for (const message of incoming) { + observedQueuedMessageIDs.add(message.id); + } setState((current) => { // A snapshot containing a confirmed promoted ID predates its queue // deletion. Applying it would also drop newer queued messages. @@ -428,7 +433,6 @@ export const createChatStore = (): ChatStore => { ) { return current; } - authoritativeQueueVersion++; let nextSuppressed = current.suppressedQueuedMessageIDs; if (current.suppressedQueuedMessageIDs.size > 0) { const incomingIDs = new Set(incoming.map((message) => message.id)); @@ -471,7 +475,7 @@ export const createChatStore = (): ChatStore => { }; }); }, - getAuthoritativeQueueVersion: () => authoritativeQueueVersion, + hasObservedQueuedMessageID: (id) => observedQueuedMessageIDs.has(id), getChatStatusVersion: () => chatStatusVersion, suppressQueuedMessageID: (id) => { setState((current) => { @@ -522,6 +526,7 @@ export const createChatStore = (): ChatStore => { }); }, clearSuppressedQueuedMessageIDs: () => { + observedQueuedMessageIDs = new Set(); setState((current) => { if ( current.suppressedQueuedMessageIDs.size === 0 && From 282d218874c91021b26cf65d8a88f728efc40f05 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:12:57 +0000 Subject: [PATCH 24/35] test(site/src/pages/AgentsPage): type the status fixture without a cast --- .../AgentsPage/components/ChatConversation/chatStore.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index bb352903f1655..63502d742a52b 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -2297,6 +2297,7 @@ describe("useChatStore", () => { const queryClient = createTestQueryClient(); const wrapper = createWrapper(queryClient); + const initialProps: { status: TypesGen.ChatStatus } = { status: "waiting" }; const { result, rerender } = renderHook( ({ status }: { status: TypesGen.ChatStatus }) => { const { store, acceptServerChatStatus } = useChatStore({ @@ -2317,7 +2318,7 @@ describe("useChatStore", () => { chatStatus: useChatSelector(store, selectChatStatus), }; }, - { wrapper, initialProps: { status: "waiting" as TypesGen.ChatStatus } }, + { wrapper, initialProps }, ); await waitFor(() => { From ad89579d7c4422a9518ec6697f7ffc66a02b46d7 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:35:04 +0000 Subject: [PATCH 25/35] fix(site/src/pages/AgentsPage): count repeated server status reports as newer --- site/src/pages/AgentsPage/AgentChatPage.tsx | 4 ++-- .../chatStore.createStore.test.ts | 21 +++++++++++------ .../components/ChatConversation/chatStore.ts | 23 +++++++++++++++---- .../ChatConversation/useChatStore.ts | 4 ++-- 4 files changed, 36 insertions(+), 16 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 717252c120427..2c88f87103e84 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1686,7 +1686,7 @@ const AgentChatPage: FC = () => { // Capture the queue head before sending because an errored chat may promote it. const queueHeadIDBeforeSend = store.getSnapshot().queuedMessages[0]?.id; - const statusVersionBeforeSend = store.getChatStatusVersion(); + const statusVersionBeforeSend = store.getServerChatStatusVersion(); // Don't clear stream state before the POST completes. // For queued sends the WebSocket status events handle @@ -1742,7 +1742,7 @@ const AgentChatPage: FC = () => { // stale error status before the status websocket event // arrives. A status event during the request is already // newer than this optimistic value. - if (store.getChatStatusVersion() === statusVersionBeforeSend) { + if (store.getServerChatStatusVersion() === statusVersionBeforeSend) { store.clearStreamState(); store.setChatStatus("running"); } 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 8659d97e5442b..cc23751506b51 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts @@ -553,16 +553,23 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => { expect(store.hasObservedQueuedMessageID(a.id)).toBe(false); }); - it("tracks chat status versions for in-flight requests", () => { + it("counts every server status report, including repeats", () => { const store = createChatStore(); - expect(store.getChatStatusVersion()).toBe(0); - store.setChatStatus("running"); - expect(store.getChatStatusVersion()).toBe(1); + expect(store.getServerChatStatusVersion()).toBe(0); + + // Optimistic writes are not server reports. store.setChatStatus("running"); - expect(store.getChatStatusVersion()).toBe(1); - store.setChatStatus("error"); - expect(store.getChatStatusVersion()).toBe(2); + expect(store.getServerChatStatusVersion()).toBe(0); + + store.applyServerChatStatus("error"); + expect(store.getServerChatStatusVersion()).toBe(1); + expect(store.getSnapshot().chatStatus).toBe("error"); + + // A repeat of the current value is still the server speaking. + store.applyServerChatStatus("error"); + expect(store.getServerChatStatusVersion()).toBe(2); + expect(store.getSnapshot().chatStatus).toBe("error"); }); it("unsuppressQueuedMessageID clears a promoted marker after a failed promotion", () => { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts index 6303f00f51261..b2bf8ec0adb78 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts @@ -177,8 +177,12 @@ export type ChatStore = { // server never mentioned is still in flight; one it mentioned and then // dropped was deleted. hasObservedQueuedMessageID: (id: number) => boolean; - // Detects that the server reported a newer status mid-request. - getChatStatusVersion: () => number; + // 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; + // Records a server-reported status; always counts as an observation. + applyServerChatStatus: (status: TypesGen.ChatStatus | null) => void; unsuppressQueuedMessageID: (id: number) => void; clearSuppressedQueuedMessageIDs: () => void; setChatStatus: (status: TypesGen.ChatStatus | null) => void; @@ -217,7 +221,7 @@ export const createChatStore = (): ChatStore => { // Bookkeeping, deliberately outside the rendered state so observing a // server event cannot trigger a re-render. let observedQueuedMessageIDs = new Set(); - let chatStatusVersion = 0; + let serverChatStatusVersion = 0; const listeners = new Set<() => void>(); const emit = (): void => { @@ -476,7 +480,6 @@ export const createChatStore = (): ChatStore => { }); }, hasObservedQueuedMessageID: (id) => observedQueuedMessageIDs.has(id), - getChatStatusVersion: () => chatStatusVersion, suppressQueuedMessageID: (id) => { setState((current) => { if (current.suppressedQueuedMessageIDs.has(id)) { @@ -545,7 +548,17 @@ export const createChatStore = (): ChatStore => { if (state.chatStatus === status) { return; } - chatStatusVersion++; + setState((current) => ({ + ...current, + chatStatus: status, + })); + }, + getServerChatStatusVersion: () => serverChatStatusVersion, + applyServerChatStatus: (status) => { + serverChatStatusVersion++; + if (state.chatStatus === status) { + return; + } setState((current) => ({ ...current, chatStatus: status, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index fbf04f43cccbb..511b265b723ea 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -610,7 +610,7 @@ export const useChatStore = ( wsStatusReceivedRef.current = true; store.clearRetryState(); - store.setChatStatus(nextStatus); + store.applyServerChatStatus(nextStatus); if (nextStatus === "waiting") { discardBufferedParts(); } @@ -629,7 +629,7 @@ export const useChatStore = ( kind: "generic", message: "Chat processing failed.", }; - store.setChatStatus("error"); + store.applyServerChatStatus("error"); store.setStreamError(reason); store.clearRetryState(); setChatErrorReasonEvent(chatID, reason); From 5b4b58ec8cec67bdbdecbef80ee44dbe863f7769 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:45:06 +0000 Subject: [PATCH 26/35] test(site/src/api/queries): clarify why the reconciled page order inverts --- site/src/api/queries/chatMessageEdits.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/api/queries/chatMessageEdits.test.ts b/site/src/api/queries/chatMessageEdits.test.ts index 8314f157e3b55..480bfaa82a62b 100644 --- a/site/src/api/queries/chatMessageEdits.test.ts +++ b/site/src/api/queries/chatMessageEdits.test.ts @@ -89,7 +89,7 @@ describe("reconcileEditedMessageInCache", () => { }); const ids = reconciled?.pages[0]?.messages.map((message) => message.id); - // The first page is ordered newest first. + // Reversed from responseMessages: the first page is newest first. expect(ids).toEqual([replacement.id, newNotice.id]); }); }); From 346c4e985a52de5fe7ba8279c103b56a28db0888 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:13:03 +0000 Subject: [PATCH 27/35] fix(site/src/pages/AgentsPage): hydrate an unchanged status after a resync --- .../ChatConversation/chatStore.test.tsx | 54 +++++++++++++++++++ .../ChatConversation/useChatStore.ts | 11 +++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index 63502d742a52b..253785e887786 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -2350,6 +2350,60 @@ describe("useChatStore", () => { }); }); + it("hydrates a refetched status that never changed value", async () => { + const chatID = "chat-resync-same"; + const mockSocket = createMockSocket(); + mockWatchChatReturn(mockSocket); + const queryClient = createTestQueryClient(); + const wrapper = createWrapper(queryClient); + + // The cache already holds "error" while the socket pushes "running", + // so opting back in must apply the cached value without it changing. + const { result } = renderHook( + () => { + const { store, acceptServerChatStatus } = useChatStore({ + chatID, + chatMessages: [], + chatRecord: { ...buildChat(chatID), status: "error" }, + chatMessagesData: { + messages: [], + queued_messages: [], + has_more: false, + }, + chatQueuedMessages: [], + setChatErrorReason: vi.fn(), + clearChatErrorReason: vi.fn(), + }); + return { + acceptServerChatStatus, + chatStatus: useChatSelector(store, selectChatStatus), + }; + }, + { wrapper }, + ); + + 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(); + }); + await waitFor(() => { + expect(result.current.chatStatus).toBe("error"); + }); + }); + it("sets chatStatus to error and populates streamError on error event", async () => { immediateAnimationFrame(); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index 511b265b723ea..1876939774196 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -130,6 +130,7 @@ export const useChatStore = ( // stale value like "waiting", causing shouldApplyMessagePart() // to drop all incoming parts. const wsStatusReceivedRef = useRef(false); + const [pendingStatusResync, setPendingStatusResync] = useState(false); const activeChatIDRef = useRef(null); const prevChatIDRef = useRef(chatID); // Snapshot of the chatMessages elements from the last sync effect @@ -305,10 +306,15 @@ export const useChatStore = ( // a status event yet. Once the WS is the authoritative // source, a stale REST refetch must not overwrite the // fresher WS-delivered value. - if (!wsStatusReceivedRef.current) { + if (!wsStatusReceivedRef.current || pendingStatusResync) { store.setChatStatus(chatRecord?.status ?? null); } - }, [chatRecord?.status, store]); + // A resync must apply the cached status even when its value never + // changed, which happens when the store drifted ahead of it. + if (pendingStatusResync) { + setPendingStatusResync(false); + } + }, [chatRecord?.status, store, pendingStatusResync]); useEffect(() => { queuedMessagesHydratedChatIDRef.current = null; @@ -757,6 +763,7 @@ export const useChatStore = ( // otherwise makes the refetched one inert. acceptServerChatStatus: () => { wsStatusReceivedRef.current = false; + setPendingStatusResync(true); }, setCacheQueuedMessages: (queuedMessages) => { writeQueuedMessagesToCache(queryClient, chatID, queuedMessages); From 050babebaa3c7d4840b29ddc32abb811a2c5a3c8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:24:35 +0000 Subject: [PATCH 28/35] fix(site/src/pages/AgentsPage): guard send responses by chat --- site/src/pages/AgentsPage/AgentChatPage.tsx | 83 +++++++++++++------ .../ChatConversation/chatStore.test.tsx | 35 +++++++- .../components/ChatConversation/chatStore.ts | 7 ++ .../ChatConversation/useChatStore.ts | 3 + 4 files changed, 101 insertions(+), 27 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 2c88f87103e84..be91ed3a8eef0 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -233,6 +233,29 @@ 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; +}; + // Use the pre-send queue head because queue updates may rotate it before // the response arrives. export const reconcilePromotedQueueHead = ( @@ -248,23 +271,16 @@ export const reconcilePromotedQueueHead = ( promotedHeadID: number | undefined, queuedTail: TypesGen.ChatQueuedMessage | undefined, ): readonly TypesGen.ChatQueuedMessage[] | undefined => { - if (promotedHeadID === undefined) { - return undefined; - } - if (!insertedMessages.some((message) => message.role === "user")) { - return undefined; + const next = buildPromotedQueueReconciliation( + store.getSnapshot().queuedMessages, + insertedMessages, + promotedHeadID, + queuedTail, + store.hasObservedQueuedMessageID, + ); + if (!next || promotedHeadID === undefined) { + return next; } - const remaining = store - .getSnapshot() - .queuedMessages.filter((message) => message.id !== promotedHeadID); - // Append the tail only while the server has never reported it. Once a - // snapshot has listed it, its later absence means it was deleted, so - // re-adding it would resurrect a phantom row. - const tailPending = - queuedTail !== undefined && - !remaining.some((message) => message.id === queuedTail.id) && - !store.hasObservedQueuedMessageID(queuedTail.id); - const next = tailPending ? [...remaining, queuedTail] : remaining; store.batch(() => { // The promoted user row proves the server deleted its queue row. store.markQueuedMessagePromoted(promotedHeadID); @@ -1685,7 +1701,8 @@ const AgentChatPage: FC = () => { scrollToBottomRef.current?.(); // Capture the queue head before sending because an errored chat may promote it. - const queueHeadIDBeforeSend = store.getSnapshot().queuedMessages[0]?.id; + const queuedMessagesBeforeSend = store.getSnapshot().queuedMessages; + const queueHeadIDBeforeSend = queuedMessagesBeforeSend[0]?.id; const statusVersionBeforeSend = store.getServerChatStatusVersion(); // Don't clear stream state before the POST completes. @@ -1705,10 +1722,11 @@ const AgentChatPage: FC = () => { }); throw error; } + const isActiveChat = store.getActiveChatID() === agentId; // When the server accepts the message immediately (not // queued), clear the stream so the timeline updates without // waiting for the WebSocket stream. - if (!response.queued) { + if (!response.queued && isActiveChat) { store.clearStreamState(); // Optimistically set status to "running" so the // Thinking indicator appears immediately. @@ -1727,22 +1745,35 @@ const AgentChatPage: FC = () => { const insertedMessages = response.messages ?? (response.message ? [response.message] : []); if (insertedMessages.length > 0) { - store.upsertDurableMessages(insertedMessages); upsertCacheMessages(insertedMessages); + if (isActiveChat) { + store.upsertDurableMessages(insertedMessages); + } if (response.queued) { - const reconciledQueue = reconcilePromotedQueueHead( - store, - insertedMessages, - queueHeadIDBeforeSend, - response.queued_message, - ); + const reconciledQueue = isActiveChat + ? reconcilePromotedQueueHead( + store, + insertedMessages, + queueHeadIDBeforeSend, + response.queued_message, + ) + : buildPromotedQueueReconciliation( + queuedMessagesBeforeSend, + insertedMessages, + queueHeadIDBeforeSend, + response.queued_message, + () => false, + ); if (reconciledQueue) { setCacheQueuedMessages(reconciledQueue); // A promoted head means a turn just started, so clear the // stale error status before the status websocket event // arrives. A status event during the request is already // newer than this optimistic value. - if (store.getServerChatStatusVersion() === statusVersionBeforeSend) { + if ( + isActiveChat && + store.getServerChatStatusVersion() === statusVersionBeforeSend + ) { store.clearStreamState(); store.setChatStatus("running"); } diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index 253785e887786..35ed2547c2c5f 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -34,10 +34,11 @@ import type { FC, PropsWithChildren } from "react"; import { QueryClient, QueryClientProvider } from "react-query"; import { afterEach, describe, expect, it, vi } from "vitest"; import type * as TypesGen from "#/api/typesGenerated"; -import { MockChat } from "#/testHelpers/chatEntities"; +import { MockChat, MockChatMessage } from "#/testHelpers/chatEntities"; import { createTestQueryClient } from "#/testHelpers/renderHelpers"; import type { OneWayMessageEvent } from "#/utils/OneWayWebSocket"; import { + createChatStore, selectChatStatus, selectIsAwaitingFirstStreamChunk, selectMessagesByID, @@ -264,6 +265,38 @@ afterEach(() => { vi.mocked(watchChat).mockReset(); }); +describe("createChatStore", () => { + it("guards send response mutations by active chat", () => { + const store = createChatStore(); + const sendChatID = "chat-old"; + const activeMessage = { + ...MockChatMessage, + id: 1, + chat_id: "chat-new", + content: [{ type: "text" as const, text: "New chat message" }], + }; + const staleResponseMessage = { + ...MockChatMessage, + id: 2, + chat_id: sendChatID, + content: [{ type: "text" as const, text: "Old chat message" }], + }; + + store.setActiveChatID(sendChatID); + store.setActiveChatID("chat-new"); + store.replaceMessages([activeMessage]); + store.setChatStatus("waiting"); + + if (store.getActiveChatID() === sendChatID) { + store.upsertDurableMessages([staleResponseMessage]); + store.setChatStatus("running"); + } + + expect(store.getSnapshot().orderedMessageIDs).toEqual([activeMessage.id]); + expect(store.getSnapshot().chatStatus).toBe("waiting"); + }); +}); + describe("useChatStore", () => { it("does not clear in-progress stream parts for duplicate snapshot messages", async () => { immediateAnimationFrame(); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts index b2bf8ec0adb78..d9b1b6d3a862a 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts @@ -177,6 +177,8 @@ export type ChatStore = { // server never mentioned is still in flight; one it mentioned and then // dropped was deleted. 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. @@ -222,6 +224,7 @@ export const createChatStore = (): ChatStore => { // server event cannot trigger a re-render. let observedQueuedMessageIDs = new Set(); let serverChatStatusVersion = 0; + let activeChatID: string | null = null; const listeners = new Set<() => void>(); const emit = (): void => { @@ -553,6 +556,10 @@ export const createChatStore = (): ChatStore => { chatStatus: status, })); }, + setActiveChatID: (chatID) => { + activeChatID = chatID; + }, + getActiveChatID: () => activeChatID, getServerChatStatusVersion: () => serverChatStatusVersion, applyServerChatStatus: (status) => { serverChatStatusVersion++; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index 1876939774196..1dad16c30e28b 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -317,6 +317,7 @@ export const useChatStore = ( }, [chatRecord?.status, store, pendingStatusResync]); useEffect(() => { + store.setActiveChatID(chatID ?? null); queuedMessagesHydratedChatIDRef.current = null; wsQueueUpdateReceivedRef.current = false; wsStatusReceivedRef.current = false; @@ -387,6 +388,7 @@ export const useChatStore = ( store.resetTransientState(); activeChatIDRef.current = chatID ?? null; + store.setActiveChatID(chatID ?? null); if (!chatID || !initialDataLoaded || aiGatewayDisabled) { return; @@ -743,6 +745,7 @@ export const useChatStore = ( clearTimeout(partsFlushTimer); } activeChatIDRef.current = null; + store.setActiveChatID(null); }; }, [ aiGatewayDisabled, From 5487758ce667092d54515584111cf8cfadd8fe90 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:48:02 +0000 Subject: [PATCH 29/35] fix(site/src/pages/AgentsPage): resync status only after fresh chat data acceptServerChatStatus armed a resync that applied the currently cached chatRecord.status immediately, so a failed send or edit could replace a live websocket "running" with a stale REST "waiting" and make shouldApplyMessagePart drop assistant parts. The resync now waits for the chat query's dataUpdatedAt to advance past the value captured when it was armed. Object identity does not work here because TanStack Query structural sharing preserves the chatRecord reference when a refetch returns value-equal data, which would leave the resync armed forever and never apply an unchanged status. Also covers the cross-chat send guard with an interaction story and drops a store test that restated the guard instead of exercising it. --- .../AgentsPage/AgentChatPage.stories.tsx | 124 +++++++++++++++++- site/src/pages/AgentsPage/AgentChatPage.tsx | 3 +- .../ChatConversation/chatStore.test.tsx | 67 +++------- .../ChatConversation/useChatStore.ts | 31 +++-- 4 files changed, 167 insertions(+), 58 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 457e091c8d7e3..daf6bb7101dd4 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", @@ -2927,6 +2944,111 @@ export const QueuedSendPromotesPreviousHead: Story = { }, }; +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(); + }); + }, +}; + /** A send rejected with the structured 502 hook-dispatch-failure body must * render the lifecycle-hook title and the server's detail text, not the * generic request-failure fallback. */ diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index be91ed3a8eef0..75f18edf65046 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1169,6 +1169,7 @@ const AgentChatPage: FC = () => { chatID: agentId, chatMessages: chatMessagesList, chatRecord, + chatRecordUpdatedAt: chatQuery.dataUpdatedAt, chatMessagesData, chatQueuedMessages, setChatErrorReason, @@ -1741,7 +1742,7 @@ const AgentChatPage: FC = () => { // Prefer the full inserted batch: queued sends can insert // messages beyond the user row, such as a promoted queue head // on an errored chat, and a stream reconnect keyed on the - // highest cached ID would skip them, so upsert unconditionally. + // highest cached ID would skip them, so upsert while this chat is active. const insertedMessages = response.messages ?? (response.message ? [response.message] : []); if (insertedMessages.length > 0) { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index 35ed2547c2c5f..dbf3f5035f8b0 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -34,11 +34,10 @@ import type { FC, PropsWithChildren } from "react"; import { QueryClient, QueryClientProvider } from "react-query"; import { afterEach, describe, expect, it, vi } from "vitest"; import type * as TypesGen from "#/api/typesGenerated"; -import { MockChat, MockChatMessage } from "#/testHelpers/chatEntities"; +import { MockChat } from "#/testHelpers/chatEntities"; import { createTestQueryClient } from "#/testHelpers/renderHelpers"; import type { OneWayMessageEvent } from "#/utils/OneWayWebSocket"; import { - createChatStore, selectChatStatus, selectIsAwaitingFirstStreamChunk, selectMessagesByID, @@ -265,38 +264,6 @@ afterEach(() => { vi.mocked(watchChat).mockReset(); }); -describe("createChatStore", () => { - it("guards send response mutations by active chat", () => { - const store = createChatStore(); - const sendChatID = "chat-old"; - const activeMessage = { - ...MockChatMessage, - id: 1, - chat_id: "chat-new", - content: [{ type: "text" as const, text: "New chat message" }], - }; - const staleResponseMessage = { - ...MockChatMessage, - id: 2, - chat_id: sendChatID, - content: [{ type: "text" as const, text: "Old chat message" }], - }; - - store.setActiveChatID(sendChatID); - store.setActiveChatID("chat-new"); - store.replaceMessages([activeMessage]); - store.setChatStatus("waiting"); - - if (store.getActiveChatID() === sendChatID) { - store.upsertDurableMessages([staleResponseMessage]); - store.setChatStatus("running"); - } - - expect(store.getSnapshot().orderedMessageIDs).toEqual([activeMessage.id]); - expect(store.getSnapshot().chatStatus).toBe("waiting"); - }); -}); - describe("useChatStore", () => { it("does not clear in-progress stream parts for duplicate snapshot messages", async () => { immediateAnimationFrame(); @@ -2330,13 +2297,17 @@ describe("useChatStore", () => { const queryClient = createTestQueryClient(); const wrapper = createWrapper(queryClient); - const initialProps: { status: TypesGen.ChatStatus } = { status: "waiting" }; + const initialProps: { status: TypesGen.ChatStatus; updatedAt: number } = { + status: "waiting", + updatedAt: 1, + }; const { result, rerender } = renderHook( - ({ status }: { status: TypesGen.ChatStatus }) => { + ({ status, updatedAt }: typeof initialProps) => { const { store, acceptServerChatStatus } = useChatStore({ chatID, chatMessages: [], chatRecord: { ...buildChat(chatID), status }, + chatRecordUpdatedAt: updatedAt, chatMessagesData: { messages: [], queued_messages: [], @@ -2369,35 +2340,35 @@ describe("useChatStore", () => { await waitFor(() => { expect(result.current.chatStatus).toBe("running"); }); - rerender({ status: "error" }); + rerender({ status: "error", updatedAt: 1 }); expect(result.current.chatStatus).toBe("running"); - // A failed request opts back in, so the next refetch applies. act(() => { result.current.acceptServerChatStatus(); }); - rerender({ status: "waiting" }); - rerender({ status: "error" }); + 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 a refetched status that never changed value", async () => { + 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 }; - // The cache already holds "error" while the socket pushes "running", - // so opting back in must apply the cached value without it changing. - const { result } = renderHook( - () => { + const { result, rerender } = renderHook( + ({ updatedAt }: { updatedAt: number }) => { const { store, acceptServerChatStatus } = useChatStore({ chatID, chatMessages: [], - chatRecord: { ...buildChat(chatID), status: "error" }, + chatRecord, + chatRecordUpdatedAt: updatedAt, chatMessagesData: { messages: [], queued_messages: [], @@ -2412,7 +2383,7 @@ describe("useChatStore", () => { chatStatus: useChatSelector(store, selectChatStatus), }; }, - { wrapper }, + { wrapper, initialProps: { updatedAt: 1 } }, ); await waitFor(() => { @@ -2432,6 +2403,8 @@ describe("useChatStore", () => { act(() => { result.current.acceptServerChatStatus(); }); + expect(result.current.chatStatus).toBe("running"); + rerender({ updatedAt: 2 }); await waitFor(() => { expect(result.current.chatStatus).toBe("error"); }); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index 1dad16c30e28b..f99465c0843b1 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -83,6 +83,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; @@ -105,6 +106,7 @@ export const useChatStore = ( chatID, chatMessages, chatRecord, + chatRecordUpdatedAt = 0, chatMessagesData, chatQueuedMessages, setChatErrorReason, @@ -131,6 +133,7 @@ export const useChatStore = ( // to drop all incoming parts. const wsStatusReceivedRef = useRef(false); const [pendingStatusResync, setPendingStatusResync] = useState(false); + const pendingStatusResyncUpdatedAtRef = useRef(null); const activeChatIDRef = useRef(null); const prevChatIDRef = useRef(chatID); // Snapshot of the chatMessages elements from the last sync effect @@ -302,25 +305,34 @@ 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; + } + store.setChatStatus(chatRecord?.status ?? null); + pendingStatusResyncUpdatedAtRef.current = null; + wsStatusReceivedRef.current = false; + 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 // fresher WS-delivered value. - if (!wsStatusReceivedRef.current || pendingStatusResync) { + if (!wsStatusReceivedRef.current) { store.setChatStatus(chatRecord?.status ?? null); } - // A resync must apply the cached status even when its value never - // changed, which happens when the store drifted ahead of it. - if (pendingStatusResync) { - setPendingStatusResync(false); - } - }, [chatRecord?.status, store, pendingStatusResync]); + }, [chatRecord?.status, chatRecordUpdatedAt, store, pendingStatusResync]); useEffect(() => { - store.setActiveChatID(chatID ?? null); queuedMessagesHydratedChatIDRef.current = null; wsQueueUpdateReceivedRef.current = false; wsStatusReceivedRef.current = false; + pendingStatusResyncUpdatedAtRef.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 @@ -637,6 +649,7 @@ export const useChatStore = ( kind: "generic", message: "Chat processing failed.", }; + wsStatusReceivedRef.current = true; store.applyServerChatStatus("error"); store.setStreamError(reason); store.clearRetryState(); @@ -765,7 +778,7 @@ export const useChatStore = ( // socket is down, and the socket having already delivered a status // otherwise makes the refetched one inert. acceptServerChatStatus: () => { - wsStatusReceivedRef.current = false; + pendingStatusResyncUpdatedAtRef.current = chatRecordUpdatedAt; setPendingStatusResync(true); }, setCacheQueuedMessages: (queuedMessages) => { From ce9c26de534073d022b06a016aed77f68868b7f8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:37:09 +0000 Subject: [PATCH 30/35] fix(site/src/pages/AgentsPage): keep websocket status through a resync A status or error event arriving after acceptServerChatStatus armed the resync but before the invalidated chat query resolved was overwritten by the older REST status, which could strand the turn and make shouldApplyMessagePart drop the retry's assistant deltas. The resync now captures the server status version when armed and skips the overwrite when the websocket advanced it in the meantime. --- .../ChatConversation/chatStore.test.tsx | 56 +++++++++++++++++++ .../ChatConversation/useChatStore.ts | 16 +++++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index dbf3f5035f8b0..cc4add20086cc 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -2410,6 +2410,62 @@ describe("useChatStore", () => { }); }); + 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/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index f99465c0843b1..dc887c87924f2 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -134,6 +134,7 @@ export const useChatStore = ( 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 @@ -312,9 +313,17 @@ export const useChatStore = ( if (armedAt === null || chatRecordUpdatedAt <= armedAt) { return; } - store.setChatStatus(chatRecord?.status ?? null); + // A websocket status delivered while the refetch was in flight is + // newer than its response, so the resync must not undo it. + const wsAdvanced = + store.getServerChatStatusVersion() !== + pendingStatusResyncVersionRef.current; + if (!wsAdvanced) { + store.setChatStatus(chatRecord?.status ?? null); + wsStatusReceivedRef.current = false; + } pendingStatusResyncUpdatedAtRef.current = null; - wsStatusReceivedRef.current = false; + pendingStatusResyncVersionRef.current = null; setPendingStatusResync(false); return; } @@ -332,6 +341,7 @@ export const useChatStore = ( 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 @@ -779,6 +789,8 @@ export const useChatStore = ( // otherwise makes the refetched one inert. acceptServerChatStatus: () => { pendingStatusResyncUpdatedAtRef.current = chatRecordUpdatedAt; + pendingStatusResyncVersionRef.current = + store.getServerChatStatusVersion(); setPendingStatusResync(true); }, setCacheQueuedMessages: (queuedMessages) => { From 586c55a8a78d5421ac95f6dc6360d7b6d380cd39 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:58:19 +0000 Subject: [PATCH 31/35] fix(site/src/pages/AgentsPage): scope the status resync to its own chat The send and edit failure paths armed a resync from the render that started the request, so a rejection arriving after the user navigated away captured the previous chat's dataUpdatedAt. The newly active chat's higher cached timestamp then satisfied the freshness check at once, overwriting a websocket-delivered status and clearing the websocket-authoritative guard. acceptServerChatStatus now ignores calls whose chat is no longer the active one, which covers both failure paths at their single shared entry point. --- .../ChatConversation/chatStore.test.tsx | 64 +++++++++++++++++++ .../ChatConversation/useChatStore.ts | 6 ++ 2 files changed, 70 insertions(+) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index cc4add20086cc..a4ada03aa33de 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -2410,6 +2410,70 @@ describe("useChatStore", () => { }); }); + 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(); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index dc887c87924f2..4cb71b6bb20ac 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -788,6 +788,12 @@ export const useChatStore = ( // 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(); From 6910bf5068b8b2a4d31d9527537bbc7e63c6b983 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:42:41 +0000 Subject: [PATCH 32/35] fix(site/src/pages/AgentsPage): converge the queue after a promoted send A send that promotes the queue head suppresses that ID locally, but a concurrent queue mutation in another tab can leave the row queued server-side, so the transcript and the queue both showed it until the next authoritative snapshot. The page now issues one uncursored messages request after the promotion through a dedicated react-query key and hands the result to the store, which clears the promotion markers and applies the snapshot in a single transition. The store returns the queue it applied, so the caller caches the filtered result rather than the raw response. Stale applies are fenced on two axes: the convergence fence advances on every accepted snapshot and whenever the active chat changes, and the store drops a response whose originating chat is no longer displayed. A send whose response lands after the user switched chats reconciles from the queue currently cached for that chat, falling back to the pre-send snapshot only when nothing is cached, so a queue update received mid-send is not overwritten. --- site/src/api/queries/chats.ts | 12 ++ .../AgentsPage/AgentChatPage.stories.tsx | 27 ++- .../pages/AgentsPage/AgentChatPage.test.ts | 143 +++++++++++++ site/src/pages/AgentsPage/AgentChatPage.tsx | 67 ++++++- .../chatStore.createStore.test.ts | 188 ++++++++++++++++++ .../components/ChatConversation/chatStore.ts | 84 +++++++- .../ChatConversation/useChatStore.ts | 17 ++ 7 files changed, 521 insertions(+), 17 deletions(-) diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 88962f9edfd31..c382e11e7bdc4 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, diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index daf6bb7101dd4..35890d7e5fb30 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -2909,18 +2909,31 @@ export const QueuedSendPromotesPreviousHead: Story = { 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" }], + }; + // Another tab queued this while the send was in flight, so only the + // post-promotion convergence fetch can reveal it. + 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: { - ...MockChatQueuedMessage, - id: 43, - chat_id: CHAT_ID, - content: [{ type: "text", text: "Follow-up prompt" }], - }, + queued_message: followUp, }); expect(await canvas.findByText("Queued head prompt")).toBeVisible(); @@ -2939,6 +2952,8 @@ export const QueuedSendPromotesPreviousHead: Story = { 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(); }, diff --git a/site/src/pages/AgentsPage/AgentChatPage.test.ts b/site/src/pages/AgentsPage/AgentChatPage.test.ts index b5eb080f0fdae..83d8bb29e9096 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.test.ts +++ b/site/src/pages/AgentsPage/AgentChatPage.test.ts @@ -9,12 +9,14 @@ import { import { createDeferred } from "#/testHelpers/deferred"; import { MockUserOwner, MockWorkspace } from "#/testHelpers/entities"; import { + buildInactiveChatQueueReconciliation, draftInputStorageKeyPrefix, getPersistedDraftInputValue, getWorkspaceOptionsWithLinkedWorkspace, reconcilePromotedQueueHead, restoreOptimisticRequestSnapshot, runPromoteQueuedMessage, + settlePromotedQueueHead, submitEditAndScroll, useConversationEditingState, waitForPendingChatSettingsSyncs, @@ -424,6 +426,147 @@ describe("reconcilePromotedQueueHead", () => { }); }); +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 75f18edf65046..838a1e34506e8 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, @@ -256,6 +258,24 @@ const buildPromotedQueueReconciliation = ( return tailPending ? [...remaining, queuedTail] : remaining; }; +// A chat the user navigated away from has no live store to read, so the +// cached queue is the freshest view of it. Falling back to the pre-send +// snapshot would drop messages queued while the send was in flight. +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, + ); + // Use the pre-send queue head because queue updates may rotate it before // the response arrives. export const reconcilePromotedQueueHead = ( @@ -289,6 +309,36 @@ export const reconcilePromotedQueueHead = ( 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, @@ -1164,6 +1214,7 @@ const AgentChatPage: FC = () => { acceptServerChatStatus, clearStreamError, setCacheQueuedMessages, + getCacheQueuedMessages, upsertCacheMessages, } = useChatStore({ chatID: agentId, @@ -1758,12 +1809,12 @@ const AgentChatPage: FC = () => { queueHeadIDBeforeSend, response.queued_message, ) - : buildPromotedQueueReconciliation( + : buildInactiveChatQueueReconciliation( + getCacheQueuedMessages(), queuedMessagesBeforeSend, insertedMessages, queueHeadIDBeforeSend, response.queued_message, - () => false, ); if (reconciledQueue) { setCacheQueuedMessages(reconciledQueue); @@ -1778,6 +1829,18 @@ const AgentChatPage: FC = () => { store.clearStreamState(); store.setChatStatus("running"); } + if (isActiveChat && queueHeadIDBeforeSend !== undefined) { + void settlePromotedQueueHead( + store, + agentId, + queueHeadIDBeforeSend, + fetchChatMessages(queryClient), + ).then((settled) => { + if (settled) { + setCacheQueuedMessages(settled); + } + }); + } } } } 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 cc23751506b51..32c6267dc3fb2 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 // --------------------------------------------------------------------------- @@ -572,6 +574,192 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => { 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(); + + // Another tab re-queued A, so the server still lists it. + 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(); + + // An accepted queue_update lands while the refetch is in flight, so it + // is newer than the response the refetch is about to deliver. + 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]); + // Accepting the snapshot already settled the promotion. + 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(); + + // This snapshot predates the promotion, so it is discarded and must not + // supersede the refetch, which is the only way C becomes visible. + 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(); + + // No authoritative snapshot lands during the round trip, so only the + // activations themselves can strand the request. + 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); + + // A caller that captured the fence too late would otherwise pass the + // ordering check while carrying another chat's queue. + 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); + // An overlapping explicit promotion suppresses C without deleting it + // server-side, so the refetched snapshot still lists it. + 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"); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts index d9b1b6d3a862a..24fa06b3b8d0a 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts @@ -170,6 +170,21 @@ export type ChatStore = { applyAuthoritativeQueuedMessages: ( queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined, ) => void; + // Advances whenever an in-flight convergence request goes stale: an accepted + // authoritative snapshot, or a change of active chat. Snapshots discarded as + // stale do not advance it, since discarding one leaves the caller's data + // fresher. + getQueueConvergenceFence: () => number; + // Applies a snapshot fetched specifically to settle promotedID, whose + // promotion markers this clears. Returns the queue actually applied, which + // the caller should mirror into its cache, or undefined when chatID is no + // longer active or the fence moved past baselineFence. + applyPromoteRefetchQueuedMessages: ( + chatID: string, + promotedID: number, + queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined, + baselineFence: number, + ) => readonly TypesGen.ChatQueuedMessage[] | undefined; suppressQueuedMessageID: (id: number) => void; // Suppresses id and records that its queue row is already deleted. markQueuedMessagePromoted: (id: number) => void; @@ -223,6 +238,7 @@ export const createChatStore = (): ChatStore => { // 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>(); @@ -430,16 +446,18 @@ export const createChatStore = (): ChatStore => { 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) => { - // A snapshot containing a confirmed promoted ID predates its queue - // deletion. Applying it would also drop newer queued messages. - if ( - incoming.some((message) => - current.promotedQueuedMessageIDs.has(message.id), - ) - ) { - return current; - } let nextSuppressed = current.suppressedQueuedMessageIDs; if (current.suppressedQueuedMessageIDs.size > 0) { const incomingIDs = new Set(incoming.map((message) => message.id)); @@ -482,6 +500,48 @@ export const createChatStore = (): ChatStore => { }; }); }, + 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) => { @@ -557,7 +617,13 @@ export const createChatStore = (): ChatStore => { })); }, 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, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index 4cb71b6bb20ac..be0b07a207e7e 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -65,6 +65,18 @@ const writeQueuedMessagesToCache = ( }); }; +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.", @@ -100,6 +112,9 @@ export const useChatStore = ( setCacheQueuedMessages: ( queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined, ) => void; + getCacheQueuedMessages: () => + | readonly TypesGen.ChatQueuedMessage[] + | undefined; upsertCacheMessages: (messages: readonly TypesGen.ChatMessage[]) => void; } => { const { @@ -802,6 +817,8 @@ export const useChatStore = ( setCacheQueuedMessages: (queuedMessages) => { writeQueuedMessagesToCache(queryClient, chatID, queuedMessages); }, + getCacheQueuedMessages: () => + readQueuedMessagesFromCache(queryClient, chatID), upsertCacheMessages, }; }; From 1a3e83890b4a25b474f07c2c001a54b1caec2ace Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:38:36 +0000 Subject: [PATCH 33/35] fix(site/src/pages/AgentsPage): surface hook denials as policy decisions Three UAT defects in how the chat UI presents lifecycle hook outcomes. A tool call blocked by pre_tool_use rendered as 'Ran ', as if it had executed. Derive the failed wording from the tool-result error flag, matching the write and edit tools. A command that ran and exited non-zero is not a protocol error, so it keeps its existing wording. A hook notice rendered above the message it annotates, which reads backwards for a 'your prompt was rewritten' card. Both hook outcomes on the create path fell through to the generic error alert, so an expected policy decision appeared with a stack trace and a workspaces action. Give each its own branch, keyed on the structured response body rather than the status code so ordinary permission errors keep their treatment. --- site/src/pages/AgentsPage/AgentChatPage.tsx | 10 ++- .../components/AgentCreateForm.stories.tsx | 78 +++++++++++++++++++ .../AgentsPage/components/AgentCreateForm.tsx | 29 ++++++- .../ConversationTimeline.stories.tsx | 6 +- .../ChatConversation/ConversationTimeline.tsx | 16 ++-- .../ChatConversation/chatStatusHelpers.ts | 2 + .../ChatElements/tools/ExecuteTool.tsx | 11 ++- .../ChatElements/tools/Tool.stories.tsx | 4 + .../AgentsPage/utils/usageLimitMessage.ts | 14 ++++ 9 files changed, 156 insertions(+), 14 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 838a1e34506e8..99123f81defb6 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -117,6 +117,7 @@ import { import { type ChatDetailError, formatUsageLimitMessage, + isChatHookDeniedResponse, isChatHookDispatchFailedResponse, isChatUsageLimitExceededResponse, } from "./utils/usageLimitMessage"; @@ -1368,10 +1369,13 @@ const AgentChatPage: FC = () => { setChatErrorReason(agentId, reason); } else if (isApiError(error)) { const detail = error.response?.data?.detail?.trim() || undefined; - const reason: ChatDetailError = { - kind: isChatHookDispatchFailedResponse(error.response?.data) + const kind = isChatHookDeniedResponse(error.response?.data) + ? "hook_denied" + : isChatHookDispatchFailedResponse(error.response?.data) ? "hook_dispatch_failed" - : "generic", + : "generic"; + const reason: ChatDetailError = { + kind, message: getErrorMessage(error, "An unexpected error occurred."), ...(detail ? { detail } : {}), }; diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 5e72778eb5000..56524349131a2 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 358a1d70b3c1a..7ce7a50971f0e 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 529dc062b8e62..2e120d34a57ac 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -463,7 +463,11 @@ export const LifecycleHookNoticeOnUserMessage: Story = { const notice = canvas.getByRole("note"); expect(notice).toBeVisible(); expect(within(notice).getByText("Lifecycle hook")).toBeVisible(); - expect(canvas.getByText("original prompt")).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"); }, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index dc1a9170f5fa9..6a84edcc89590 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -650,14 +650,6 @@ const ChatMessageItem = memo<{ )} inert={isAfterEditingMessage ? true : undefined} > - {parsed.hookNotices.map((notice, index) => ( - - {notice} - - ))} {isUser ? ( )} + {parsed.hookNotices.map((notice, index) => ( + + {notice} + + ))} {!hideActions && (displayState.hasCopyableContent || (isUser && onEditUserMessage)) && ( diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts index 137dc5cb263c6..951eb81c6a9b9 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts @@ -50,6 +50,8 @@ export const getErrorTitle = ( 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/ChatElements/tools/ExecuteTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx index 33e9e12eb5479..11064f5630c86 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx @@ -73,6 +73,8 @@ export const ExecuteTool: React.FC = ({ modelIntent, parsedCommands, durationLabel, + isRunning, + isError, }); const defaultView = resolveAgentDisplayState( shellToolDisplayMode, @@ -154,6 +156,8 @@ type ShellCommandLineInput = { modelIntent?: string; parsedCommands?: readonly string[][]; durationLabel: string; + isRunning: boolean; + isError: boolean; }; const getShellCommandLine = ({ @@ -161,6 +165,8 @@ const getShellCommandLine = ({ modelIntent, parsedCommands, durationLabel, + isRunning, + isError, }: ShellCommandLineInput): { commandLabel: string; durationSuffix: string } => { const intentLabel = sanitizeExecuteModelIntent(modelIntent, command); const summary = @@ -168,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 ead73d70cad01..aa73dbbe2bb48 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -414,6 +414,10 @@ export const ExecuteDeniedByHook: Story = { }, 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/, diff --git a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts index c15d7c22bfe31..4cea8134f761c 100644 --- a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts +++ b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts @@ -95,6 +95,20 @@ export function isChatHookDispatchFailedResponse( ); } +/** + * Runtime guard for the structured 403 hook-denial response. + */ +export function isChatHookDeniedResponse( + value: unknown, +): value is TypesGen.ChatHookDeniedResponse { + return ( + typeof value === "object" && + value !== null && + "kind" in value && + value.kind === "hook_denied" + ); +} + /** * Build a user-friendly usage-limit message from structured 409 * response data. Falls back to a generic message if structured From 43f8b55551f0d29588128d66a3f77617efb5af19 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:20:42 +0000 Subject: [PATCH 34/35] style(site/src): clean up chat hook comments --- .../AgentsPage/AgentChatPage.stories.tsx | 8 ----- .../pages/AgentsPage/AgentChatPage.test.ts | 5 --- site/src/pages/AgentsPage/AgentChatPage.tsx | 32 +++++++------------ .../chatStore.createStore.test.ts | 17 ---------- .../ChatConversation/chatStore.test.tsx | 1 - .../components/ChatConversation/chatStore.ts | 18 +++-------- .../ChatConversation/useChatStore.ts | 14 +++----- .../ChatElements/tools/WriteFileTool.tsx | 4 +-- .../AgentsPage/utils/usageLimitMessage.ts | 6 ---- 9 files changed, 22 insertions(+), 83 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 35890d7e5fb30..7364b71aa4288 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -2887,9 +2887,6 @@ const promotedQueueHeadMessages: TypesGen.ChatMessagesResponse = { has_more: false, }; -/** A queued send on an errored chat can promote the previous queue head: - * the inserted batch lands in the transcript, the new send becomes the - * queued tail, and the stale error flips to a running Thinking state. */ export const QueuedSendPromotesPreviousHead: Story = { parameters: { queries: buildQueries(promotedQueueHeadChat, promotedQueueHeadMessages, { @@ -2915,8 +2912,6 @@ export const QueuedSendPromotesPreviousHead: Story = { chat_id: CHAT_ID, content: [{ type: "text", text: "Follow-up prompt" }], }; - // Another tab queued this while the send was in flight, so only the - // post-promotion convergence fetch can reveal it. const otherTabPrompt: TypesGen.ChatQueuedMessage = { ...MockChatQueuedMessage, id: 44, @@ -3064,9 +3059,6 @@ export const SendResponseAfterChatSwitch: Story = { }, }; -/** A send rejected with the structured 502 hook-dispatch-failure body must - * render the lifecycle-hook title and the server's detail text, not the - * generic request-failure fallback. */ export const SendRejectedByHookDispatchFailure: Story = { parameters: { queries: buildQueries( diff --git a/site/src/pages/AgentsPage/AgentChatPage.test.ts b/site/src/pages/AgentsPage/AgentChatPage.test.ts index 83d8bb29e9096..294678d1b074b 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.test.ts +++ b/site/src/pages/AgentsPage/AgentChatPage.test.ts @@ -321,7 +321,6 @@ describe("reconcilePromotedQueueHead", () => { it("does not suppress the rotated head when a queue_update already applied", () => { const store = createChatStore(); - // The authoritative [b, c] snapshot arrives before the send response. const a = buildQueuedMessage(1, "A"); const b = buildQueuedMessage(2, "B"); const c = buildQueuedMessage(3, "C"); @@ -335,7 +334,6 @@ describe("reconcilePromotedQueueHead", () => { expect(snapshot.suppressedQueuedMessageIDs.has(b.id)).toBe(false); expect(snapshot.suppressedQueuedMessageIDs.has(c.id)).toBe(false); - // A late pre-promotion snapshot must not resurrect the promoted row. store.applyAuthoritativeQueuedMessages([a, b, c]); expect(store.getSnapshot().queuedMessages.map((m) => m.id)).toEqual([ b.id, @@ -364,7 +362,6 @@ describe("reconcilePromotedQueueHead", () => { const store = createChatStore(); const a = buildQueuedMessage(1, "A"); const c = buildQueuedMessage(3, "C"); - // The server acknowledged the tail, then a later snapshot deleted it. store.applyAuthoritativeQueuedMessages([a, c]); store.applyAuthoritativeQueuedMessages([a]); @@ -376,8 +373,6 @@ describe("reconcilePromotedQueueHead", () => { it("omits the response tail when a newer queue update was observed", () => { const store = createChatStore(); const a = buildQueuedMessage(1, "A"); - // The caller withholds the tail once it has seen a newer queue, - // because that snapshot may already have deleted it. store.setQueuedMessages([a]); const next = reconcilePromotedQueueHead( diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 99123f81defb6..c7b526579e1b9 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -259,9 +259,8 @@ const buildPromotedQueueReconciliation = ( return tailPending ? [...remaining, queuedTail] : remaining; }; -// A chat the user navigated away from has no live store to read, so the -// cached queue is the freshest view of it. Falling back to the pre-send -// snapshot would drop messages queued while the send was in flight. +// 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[], @@ -277,8 +276,7 @@ export const buildInactiveChatQueueReconciliation = ( () => false, ); -// Use the pre-send queue head because queue updates may rotate it before -// the response arrives. +// Queue updates may rotate the head before the response arrives. export const reconcilePromotedQueueHead = ( store: Pick< ChatStore, @@ -1717,9 +1715,7 @@ const AgentChatPage: FC = () => { onError: (error) => { restoreOptimisticRequestSnapshot(store, previousSnapshot); handleUsageLimitError(error); - // A failed edit can park the chat in error server-side - // (hook dispatch failures); refresh so the status is not - // stale if the websocket event is missed. + // Hook dispatch failures can park an idle chat in error before returning the request error. acceptServerChatStatus(); void queryClient.invalidateQueries({ queryKey: chatKey(agentId), @@ -1756,7 +1752,7 @@ const AgentChatPage: FC = () => { clearStreamError(); scrollToBottomRef.current?.(); - // Capture the queue head before sending because an errored chat may promote it. + // 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(); @@ -1770,7 +1766,7 @@ const AgentChatPage: FC = () => { response = await sendMessage(request); } catch (error) { handleUsageLimitError(error); - // Refresh chat details in case the failed request changed server state. + // Hook dispatch failures can park an idle chat in error before returning the request error. acceptServerChatStatus(); void queryClient.invalidateQueries({ queryKey: chatKey(agentId), @@ -1779,9 +1775,7 @@ const AgentChatPage: FC = () => { throw error; } const isActiveChat = store.getActiveChatID() === agentId; - // When the server accepts the message immediately (not - // queued), clear the stream so the timeline updates without - // waiting for the WebSocket stream. + // 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 @@ -1794,10 +1788,8 @@ const AgentChatPage: FC = () => { // overrides this optimistic value. store.setChatStatus("running"); } - // Prefer the full inserted batch: queued sends can insert - // messages beyond the user row, such as a promoted queue head - // on an errored chat, and a stream reconnect keyed on the - // highest cached ID would skip them, so upsert while this chat is active. + // 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) { @@ -1822,10 +1814,8 @@ const AgentChatPage: FC = () => { ); if (reconciledQueue) { setCacheQueuedMessages(reconciledQueue); - // A promoted head means a turn just started, so clear the - // stale error status before the status websocket event - // arrives. A status event during the request is already - // newer than this optimistic value. + // A promoted head starts a turn, but any server status received during the + // request is newer and must win. if ( isActiveChat && store.getServerChatStatusVersion() === statusVersionBeforeSend 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 32c6267dc3fb2..7e610f5c2de5c 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts @@ -542,15 +542,12 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => { expect(store.hasObservedQueuedMessageID(a.id)).toBe(true); expect(store.hasObservedQueuedMessageID(b.id)).toBe(true); - // A later snapshot dropping A does not unlearn that A existed. store.applyAuthoritativeQueuedMessages([b]); expect(store.hasObservedQueuedMessageID(a.id)).toBe(true); - // Optimistic writes are not server reports. store.setQueuedMessages([b, c]); expect(store.hasObservedQueuedMessageID(c.id)).toBe(false); - // Observations are per-chat. store.clearSuppressedQueuedMessageIDs(); expect(store.hasObservedQueuedMessageID(a.id)).toBe(false); }); @@ -560,7 +557,6 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => { expect(store.getServerChatStatusVersion()).toBe(0); - // Optimistic writes are not server reports. store.setChatStatus("running"); expect(store.getServerChatStatusVersion()).toBe(0); @@ -568,7 +564,6 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => { expect(store.getServerChatStatusVersion()).toBe(1); expect(store.getSnapshot().chatStatus).toBe("error"); - // A repeat of the current value is still the server speaking. store.applyServerChatStatus("error"); expect(store.getServerChatStatusVersion()).toBe(2); expect(store.getSnapshot().chatStatus).toBe("error"); @@ -584,7 +579,6 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => { store.markQueuedMessagePromoted(a.id); const baseline = store.getQueueConvergenceFence(); - // Another tab re-queued A, so the server still lists it. expect( store .applyPromoteRefetchQueuedMessages(testChatID, a.id, [a, b], baseline) @@ -608,8 +602,6 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => { store.markQueuedMessagePromoted(a.id); const baseline = store.getQueueConvergenceFence(); - // An accepted queue_update lands while the refetch is in flight, so it - // is newer than the response the refetch is about to deliver. store.applyAuthoritativeQueuedMessages([b, c]); expect( @@ -623,7 +615,6 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => { expect( store.getSnapshot().queuedMessages.map((message) => message.id), ).toEqual([b.id, c.id]); - // Accepting the snapshot already settled the promotion. expect(store.getSnapshot().promotedQueuedMessageIDs.size).toBe(0); }); @@ -638,8 +629,6 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => { store.markQueuedMessagePromoted(a.id); const baseline = store.getQueueConvergenceFence(); - // This snapshot predates the promotion, so it is discarded and must not - // supersede the refetch, which is the only way C becomes visible. store.applyAuthoritativeQueuedMessages([a, b, c]); expect( @@ -689,8 +678,6 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => { store.markQueuedMessagePromoted(a.id); const baseline = store.getQueueConvergenceFence(); - // No authoritative snapshot lands during the round trip, so only the - // activations themselves can strand the request. store.setActiveChatID("chat-other"); store.setActiveChatID(testChatID); @@ -716,8 +703,6 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => { store.setQueuedMessages([b]); store.markQueuedMessagePromoted(a.id); - // A caller that captured the fence too late would otherwise pass the - // ordering check while carrying another chat's queue. expect( store.applyPromoteRefetchQueuedMessages( "chat-other", @@ -740,8 +725,6 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => { store.setActiveChatID(testChatID); store.setQueuedMessages([b]); store.markQueuedMessagePromoted(a.id); - // An overlapping explicit promotion suppresses C without deleting it - // server-side, so the refetched snapshot still lists it. store.suppressQueuedMessageID(c.id); const baseline = store.getQueueConvergenceFence(); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index a4ada03aa33de..427d0180c43f8 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -2329,7 +2329,6 @@ describe("useChatStore", () => { expect(watchChat).toHaveBeenCalledWith(chatID, undefined); }); - // The socket becomes authoritative, so a refetched status is ignored. act(() => { mockSocket.emitData({ type: "status", diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts index 24fa06b3b8d0a..3993af14f246d 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts @@ -170,15 +170,11 @@ export type ChatStore = { applyAuthoritativeQueuedMessages: ( queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined, ) => void; - // Advances whenever an in-flight convergence request goes stale: an accepted - // authoritative snapshot, or a change of active chat. Snapshots discarded as - // stale do not advance it, since discarding one leaves the caller's data - // fresher. + // 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 snapshot fetched specifically to settle promotedID, whose - // promotion markers this clears. Returns the queue actually applied, which - // the caller should mirror into its cache, or undefined when chatID is no - // longer active or the fence moved past baselineFence. + // 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, @@ -186,11 +182,8 @@ export type ChatStore = { baselineFence: number, ) => readonly TypesGen.ChatQueuedMessage[] | undefined; suppressQueuedMessageID: (id: number) => void; - // Suppresses id and records that its queue row is already deleted. markQueuedMessagePromoted: (id: number) => void; - // Reports whether any authoritative snapshot has listed id. A tail the - // server never mentioned is still in flight; one it mentioned and then - // dropped was deleted. + // 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; @@ -198,7 +191,6 @@ export type ChatStore = { // current value, so a caller can tell that the server spoke during a // request even when the status did not change. getServerChatStatusVersion: () => number; - // Records a server-reported status; always counts as an observation. applyServerChatStatus: (status: TypesGen.ChatStatus | null) => void; unsuppressQueuedMessageID: (id: number) => void; clearSuppressedQueuedMessageIDs: () => void; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index be0b07a207e7e..51810a2292ed6 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -31,9 +31,7 @@ import { } from "./chatStore"; import type { RetryState } from "./types"; -// Writes an authoritative queued-message snapshot into the messages -// query cache so REST re-hydration cannot replay a stale queue over -// the store. +// Prevents REST re-hydration from replaying a stale queue over the store. const writeQueuedMessagesToCache = ( queryClient: QueryClient, chatID: string | undefined, @@ -328,8 +326,7 @@ export const useChatStore = ( if (armedAt === null || chatRecordUpdatedAt <= armedAt) { return; } - // A websocket status delivered while the refetch was in flight is - // newer than its response, so the resync must not undo it. + // Preserve a websocket status delivered during the refetch instead of applying its response. const wsAdvanced = store.getServerChatStatusVersion() !== pendingStatusResyncVersionRef.current; @@ -384,11 +381,8 @@ export const useChatStore = ( return; } queuedMessagesHydratedChatIDRef.current = chatID; - // Skip snapshots identical to the visible queue. The promoted-queue - // reconciliation writes its own optimistic snapshot into the cache, - // and treating that write as authoritative would lift the promote - // suppression while a stale pre-promotion queue_update can still - // arrive and re-show the promoted message. + // An optimistic promotion cache write must not clear suppression before + // a stale pre-promotion queue_update arrives. if ( chatQueuedMessagesEqualByID( store.getSnapshot().queuedMessages, diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx index 8b9f652d7093d..4c43eee49354a 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx @@ -45,8 +45,8 @@ export const WriteFileTool: React.FC<{ } else if (isError) { label = `Failed to write ${filename}`; } - // The diff is synthesized from the tool args, so on error it would - // show content that was never written. + // 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; diff --git a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts index 4cea8134f761c..0f714ad331684 100644 --- a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts +++ b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts @@ -81,9 +81,6 @@ export function isChatUsageLimitExceededResponse( ); } -/** - * Runtime guard for the structured 502 hook-dispatch-failure response. - */ export function isChatHookDispatchFailedResponse( value: unknown, ): value is TypesGen.ChatHookDispatchFailedResponse { @@ -95,9 +92,6 @@ export function isChatHookDispatchFailedResponse( ); } -/** - * Runtime guard for the structured 403 hook-denial response. - */ export function isChatHookDeniedResponse( value: unknown, ): value is TypesGen.ChatHookDeniedResponse { From decf23077984372f9aeb9d6347d395c47cc29272 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:33:51 +0000 Subject: [PATCH 35/35] fix(site/src/pages/UserSettingsPage): de-flake the quiet hours schedule test fillForm wrapped an already-retrying findByLabelText in waitFor, so the inner and outer 1s budgets raced and a slow first render failed with 'Timed out in waitFor'. Await the query directly with a timeout that fits inside the enclosing test budget. Verified by delaying the quiet-hours response 1500ms: the previous code fails at SchedulePage.test.tsx:20:8 with the exact CI signature, and the new code does not. --- .../UserSettingsPage/SchedulePage/SchedulePage.test.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/site/src/pages/UserSettingsPage/SchedulePage/SchedulePage.test.tsx b/site/src/pages/UserSettingsPage/SchedulePage/SchedulePage.test.tsx index 05965bcfdc3fd..eb1f1e625fd3e 100644 --- a/site/src/pages/UserSettingsPage/SchedulePage/SchedulePage.test.tsx +++ b/site/src/pages/UserSettingsPage/SchedulePage/SchedulePage.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { fireEvent, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { HttpResponse, http } from "msw"; import type { UpdateUserQuietHoursScheduleRequest } from "#/api/typesGenerated"; @@ -17,7 +17,9 @@ const fillForm = async ({ timezone: string; }) => { 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", () => {