feat(site): surface chat lifecycle hook outcomes in the chats UI (#27430)

Surfaces chat lifecycle hook outcomes in the chats UI. Final PR of the
lifecycle hooks stack (#27401, #27428, #27429), all now merged.

- Show hook notices attached to their user message as timeline notes
(`role="note"` so historical notices stay out of the screen reader's
assertive live region), 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 stream reconnects and queue promotion.
- Reconcile the promoted queue head after sending to an errored chat so
a missed or delayed queue update neither duplicates nor hides messages,
and clear the stale error status so the Thinking indicator appears
before the websocket status event.
- Ignore an authoritative queue snapshot that still contains a
just-promoted message: queued messages are delete-only, so such a
snapshot predates the promotion and would both re-show the promoted
message and drop messages queued since. Fresh snapshots apply in full
and clear the suppression.
- Cache the store's reconciled queue on `queue_update` instead of the
raw event, so a stale update cannot re-show a promoted message after
REST re-hydration.
- Refresh chat details when a send or edit fails, because a failed hook
dispatch can move the chat to the error state.
- Surface tool result error text in the tool rows: the execute failure
tooltip shows the actual error instead of a hardcoded "Command failed",
and a failed `write_file` renders an error label with the result error
text instead of "Wrote <file>" with an args-derived diff of content that
was never written. This makes hook tool denials legible in the timeline,
and benefits every failed execute or write.

- Label a tool call blocked by `pre_tool_use` as failed instead of `Ran
<command>`, matching what the write and edit tools already do. The
wording derives from the tool-result error flag, so a command that ran
and exited non-zero is unaffected.
- Render a hook notice below the message it annotates rather than above
it, which reads correctly for a "your prompt was rewritten" card.
- Give both hook outcomes their own treatment on the create path, where
they previously fell through to the generic error alert and an expected
policy decision appeared with a stack trace, response data, and a
workspaces action. Classification keys on the structured response body
rather than the status code, so ordinary permission errors keep their
existing rendering.

- Unrelated to the hooks work, de-flake `SchedulePage.test.tsx`. Its
`fillForm` helper wrapped an already-retrying `findByLabelText` in
`waitFor`, so the two 1s budgets raced and a slow first render failed
`test-js` with "Timed out in waitFor". This is separable from the rest
of the PR if you would rather it land on its own.

> This PR was written by Mux, an AI coding agent, on Mike's behalf.
This commit is contained in:
Michael Suchacz
2026-07-29 16:45:11 +02:00
committed by GitHub
parent e3a5a697ab
commit 4bf9b9d1e6
27 changed files with 2172 additions and 112 deletions
+52 -1
View File
@@ -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<TypesGen.ChatMessagesResponse> = {
pages: [
{
messages: [staleNotice, makeUserMessage()],
queued_messages: [],
has_more: false,
},
],
pageParams: [undefined],
};
const reconciled = reconcileEditedMessageInCache({
currentData,
optimisticMessageId: 1,
responseMessages: [newNotice, replacement],
deletedMessageIds: [staleNotice.id, 1],
});
const ids = reconciled?.pages[0]?.messages.map((message) => message.id);
// Reversed from responseMessages: the first page is newest first.
expect(ids).toEqual([replacement.id, newNotice.id]);
});
});
+15 -8
View File
@@ -117,28 +117,35 @@ export const projectEditedConversationIntoCache = ({
export const reconcileEditedMessageInCache = ({
currentData,
optimisticMessageId,
responseMessage,
responseMessages,
deletedMessageIds,
}: {
currentData: InfiniteData<TypesGen.ChatMessagesResponse> | undefined;
optimisticMessageId: number;
responseMessage: TypesGen.ChatMessage;
responseMessages: readonly TypesGen.ChatMessage[];
deletedMessageIds?: readonly number[];
}): InfiniteData<TypesGen.ChatMessagesResponse> | 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 {
+14 -1
View File
@@ -23,6 +23,9 @@ export const chatMessagesKey = (chatId: string) =>
export const chatPromptsKey = (chatId: string) =>
["chats", chatId, "prompts"] as const;
const chatQueueConvergenceKey = (chatId: string) =>
["chats", chatId, "queue-convergence"] as const;
export const chatACLKey = (chatId: string) => ["chats", chatId, "acl"] as const;
export type ChatListPRStatusFilter = "draft" | "open" | "merged" | "closed";
@@ -748,6 +751,15 @@ export const chatACL = (chatId: string) => ({
const MESSAGES_PAGE_SIZE = 50;
// The queued messages ride on the uncursored page of the messages endpoint,
// so settling the queue after a promote needs its own request. Refetching
// chatMessagesForInfiniteScroll would reload every page already scrolled.
export const chatQueueConvergence = (chatId: string) => ({
queryKey: chatQueueConvergenceKey(chatId),
queryFn: () => API.experimental.getChatMessages(chatId),
gcTime: 0,
});
export const chatMessagesForInfiniteScroll = (chatId: string) => ({
queryKey: chatMessagesKey(chatId),
initialPageParam: undefined as number | undefined,
@@ -1427,7 +1439,8 @@ export const editChatMessage = (queryClient: QueryClient, chatId: string) => ({
reconcileEditedMessageInCache({
currentData: current,
optimisticMessageId: variables.messageId,
responseMessage: response.message,
responseMessages: response.messages ?? [response.message],
deletedMessageIds: response.deleted_message_ids,
}),
);
},
@@ -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 (
<>
<button
type="button"
className="sr-only"
onClick={() => navigate(`/agents/${SWITCHED_CHAT_ID}`)}
>
Switch chat
</button>
<AgentChatPageLayout />
</>
);
};
const mockWorkspace: TypesGen.Workspace = {
...MockWorkspace,
id: "workspace-1",
@@ -2849,3 +2866,237 @@ export const SlashCompactYieldsToPersonalSkill: Story = {
expect(compactSpy).not.toHaveBeenCalled();
},
};
const promotedQueueHeadChat: TypesGen.Chat = {
id: CHAT_ID,
...baseChatFields,
title: "Promoted queue head",
status: "error",
};
const promotedQueueHeadMessages: TypesGen.ChatMessagesResponse = {
messages: compactCommandMessages.messages,
queued_messages: [
{
...MockChatQueuedMessage,
id: 41,
chat_id: CHAT_ID,
content: [{ type: "text", text: "Queued head prompt" }],
},
],
has_more: false,
};
export const QueuedSendPromotesPreviousHead: Story = {
parameters: {
queries: buildQueries(promotedQueueHeadChat, promotedQueueHeadMessages, {
diffUrl: undefined,
}),
},
beforeEach: () => {
spyOn(API.experimental, "getUserSkills").mockResolvedValue([]);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const promotedHead: TypesGen.ChatMessage = {
...MockChatMessage,
id: 42,
chat_id: CHAT_ID,
role: "user",
created_at: "2024-01-01T00:01:00Z",
content: [{ type: "text", text: "Queued head prompt" }],
};
const followUp: TypesGen.ChatQueuedMessage = {
...MockChatQueuedMessage,
id: 43,
chat_id: CHAT_ID,
content: [{ type: "text", text: "Follow-up prompt" }],
};
const otherTabPrompt: TypesGen.ChatQueuedMessage = {
...MockChatQueuedMessage,
id: 44,
chat_id: CHAT_ID,
content: [{ type: "text", text: "Other tab prompt" }],
};
spyOn(API.experimental, "getChatMessages").mockResolvedValue({
...promotedQueueHeadMessages,
queued_messages: [followUp, otherTabPrompt],
});
const sendSpy = spyOn(
API.experimental,
"createChatMessage",
).mockResolvedValue({
queued: true,
messages: [promotedHead],
queued_message: followUp,
});
expect(await canvas.findByText("Queued head prompt")).toBeVisible();
const editor = await canvas.findByTestId("chat-message-input");
await userEvent.click(editor);
await userEvent.type(editor, "Follow-up prompt");
await userEvent.keyboard("{Enter}");
await waitFor(() => {
expect(sendSpy).toHaveBeenCalledTimes(1);
});
const timeline = within(await canvas.findByTestId("conversation-timeline"));
await waitFor(() => {
expect(timeline.getByText("Queued head prompt")).toBeVisible();
expect(canvas.getAllByText("Queued head prompt")).toHaveLength(1);
expect(canvas.getAllByText("Follow-up prompt")).toHaveLength(1);
expect(timeline.queryByText("Follow-up prompt")).not.toBeInTheDocument();
expect(canvas.getByText("Other tab prompt")).toBeVisible();
expect(timeline.queryByText("Other tab prompt")).not.toBeInTheDocument();
});
expect(await canvas.findByTestId("live-activity-slot")).toBeVisible();
},
};
const switchedChat: TypesGen.Chat = {
id: SWITCHED_CHAT_ID,
...baseChatFields,
title: "Switched chat",
status: "waiting",
};
const switchedChatMessage: TypesGen.ChatMessage = {
...MockChatMessage,
id: 50,
chat_id: SWITCHED_CHAT_ID,
role: "assistant",
content: [{ type: "text", text: "Current chat message" }],
};
export const SendResponseAfterChatSwitch: Story = {
render: () => <AgentChatSwitchHarness />,
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<void>((resolve) => {
releaseSend = resolve;
});
const sendSpy = spyOn(
API.experimental,
"createChatMessage",
).mockImplementation(async () => {
await sendGate;
return {
queued: false,
message: {
...MockChatMessage,
id: 51,
chat_id: CHAT_ID,
role: "user",
content: [
{ type: "text", text: "Stale response from previous chat" },
],
},
};
});
const editor = await canvas.findByTestId("chat-message-input");
await userEvent.click(editor);
await userEvent.type(editor, "Send before switching");
await userEvent.keyboard("{Enter}");
await waitFor(() => {
expect(sendSpy).toHaveBeenCalledTimes(1);
});
await userEvent.click(canvas.getByRole("button", { name: "Switch chat" }));
const timeline = within(await canvas.findByTestId("conversation-timeline"));
expect(await timeline.findByText("Current chat message")).toBeVisible();
releaseSend?.();
await waitFor(() => {
expect(
timeline.queryByText("Stale response from previous chat"),
).not.toBeInTheDocument();
expect(
canvas.queryByTestId("live-activity-slot"),
).not.toBeInTheDocument();
});
},
};
export const SendRejectedByHookDispatchFailure: Story = {
parameters: {
queries: buildQueries(
{
id: CHAT_ID,
...baseChatFields,
title: "Hook failure",
status: "waiting",
},
{ messages: [], queued_messages: [], has_more: false },
{ diffUrl: undefined },
),
},
beforeEach: () => {
spyOn(API.experimental, "getUserSkills").mockResolvedValue([]);
spyOn(API.experimental, "createChatMessage").mockRejectedValue({
isAxiosError: true,
response: {
status: 502,
data: {
message: "Lifecycle hook dispatch failed.",
detail: "Dispatch 0f2c1f3e timed out after 1.5s.",
kind: "hook_dispatch_failed",
},
},
});
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const editor = await canvas.findByTestId("chat-message-input");
await userEvent.click(editor);
await userEvent.type(editor, "Trigger the hook failure");
await userEvent.keyboard("{Enter}");
expect(await canvas.findByText("Lifecycle hook failed")).toBeVisible();
expect(
await canvas.findByText("Dispatch 0f2c1f3e timed out after 1.5s."),
).toBeVisible();
expect(canvas.queryByText("Request failed")).not.toBeInTheDocument();
},
};
+280 -2
View File
@@ -1,16 +1,22 @@
import { act, renderHook } from "@testing-library/react";
import { createRef } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatQueuedMessage } from "#/api/typesGenerated";
import { MockChatQueuedMessage } from "#/testHelpers/chatEntities";
import type { ChatMessage, ChatQueuedMessage } from "#/api/typesGenerated";
import {
MockChatMessage,
MockChatQueuedMessage,
} from "#/testHelpers/chatEntities";
import { createDeferred } from "#/testHelpers/deferred";
import { MockUserOwner, MockWorkspace } from "#/testHelpers/entities";
import {
buildInactiveChatQueueReconciliation,
draftInputStorageKeyPrefix,
getPersistedDraftInputValue,
getWorkspaceOptionsWithLinkedWorkspace,
reconcilePromotedQueueHead,
restoreOptimisticRequestSnapshot,
runPromoteQueuedMessage,
settlePromotedQueueHead,
submitEditAndScroll,
useConversationEditingState,
waitForPendingChatSettingsSyncs,
@@ -284,6 +290,278 @@ describe("runPromoteQueuedMessage", () => {
});
});
describe("reconcilePromotedQueueHead", () => {
const buildQueuedMessage = (id: number, text: string): ChatQueuedMessage => ({
...MockChatQueuedMessage,
id,
content: [{ type: "text", text }],
});
const userMessage: ChatMessage = { ...MockChatMessage, id: 10, role: "user" };
const toolMessage: ChatMessage = { ...MockChatMessage, id: 9, role: "tool" };
it("suppresses the captured head and appends the queued tail", () => {
const store = createChatStore();
const a = buildQueuedMessage(1, "A");
const b = buildQueuedMessage(2, "B");
const tail = buildQueuedMessage(3, "C");
store.setQueuedMessages([a, b]);
const reconciled = reconcilePromotedQueueHead(
store,
[toolMessage, userMessage],
a.id,
tail,
);
const snapshot = store.getSnapshot();
expect(snapshot.queuedMessages.map((m) => m.id)).toEqual([b.id, tail.id]);
expect(snapshot.suppressedQueuedMessageIDs.has(a.id)).toBe(true);
expect(reconciled?.map((m) => m.id)).toEqual([b.id, tail.id]);
});
it("does not suppress the rotated head when a queue_update already applied", () => {
const store = createChatStore();
const a = buildQueuedMessage(1, "A");
const b = buildQueuedMessage(2, "B");
const c = buildQueuedMessage(3, "C");
store.setQueuedMessages([b, c]);
reconcilePromotedQueueHead(store, [userMessage], a.id, c);
const snapshot = store.getSnapshot();
expect(snapshot.queuedMessages.map((m) => m.id)).toEqual([b.id, c.id]);
expect(snapshot.suppressedQueuedMessageIDs.has(a.id)).toBe(true);
expect(snapshot.suppressedQueuedMessageIDs.has(b.id)).toBe(false);
expect(snapshot.suppressedQueuedMessageIDs.has(c.id)).toBe(false);
store.applyAuthoritativeQueuedMessages([a, b, c]);
expect(store.getSnapshot().queuedMessages.map((m) => m.id)).toEqual([
b.id,
c.id,
]);
store.applyAuthoritativeQueuedMessages([b, c]);
expect(store.getSnapshot().suppressedQueuedMessageIDs.size).toBe(0);
});
it("keeps the response tail when a stale snapshot arrived mid-request", () => {
const store = createChatStore();
const a = buildQueuedMessage(1, "A");
const b = buildQueuedMessage(2, "B");
const c = buildQueuedMessage(3, "C");
// A pre-send snapshot lands while the POST is in flight; it cannot
// mention the tail the send just created.
store.setQueuedMessages([a]);
store.applyAuthoritativeQueuedMessages([a, b]);
const next = reconcilePromotedQueueHead(store, [userMessage], a.id, c);
expect(next?.map((m) => m.id)).toEqual([b.id, c.id]);
});
it("drops the response tail once the server reported and removed it", () => {
const store = createChatStore();
const a = buildQueuedMessage(1, "A");
const c = buildQueuedMessage(3, "C");
store.applyAuthoritativeQueuedMessages([a, c]);
store.applyAuthoritativeQueuedMessages([a]);
const next = reconcilePromotedQueueHead(store, [userMessage], a.id, c);
expect(next).toEqual([]);
});
it("omits the response tail when a newer queue update was observed", () => {
const store = createChatStore();
const a = buildQueuedMessage(1, "A");
store.setQueuedMessages([a]);
const next = reconcilePromotedQueueHead(
store,
[userMessage],
a.id,
undefined,
);
expect(next).toEqual([]);
expect(store.getSnapshot().queuedMessages).toEqual([]);
});
it("does nothing when no user row was inserted", () => {
const store = createChatStore();
const a = buildQueuedMessage(1, "A");
store.setQueuedMessages([a]);
const reconciled = reconcilePromotedQueueHead(
store,
[toolMessage],
a.id,
buildQueuedMessage(2, "B"),
);
const snapshot = store.getSnapshot();
expect(snapshot.queuedMessages.map((m) => m.id)).toEqual([a.id]);
expect(snapshot.suppressedQueuedMessageIDs.size).toBe(0);
expect(reconciled).toBeUndefined();
});
it("does nothing when no head was captured before the send", () => {
const store = createChatStore();
const reconciled = reconcilePromotedQueueHead(
store,
[userMessage],
undefined,
buildQueuedMessage(1, "A"),
);
const snapshot = store.getSnapshot();
expect(snapshot.queuedMessages).toEqual([]);
expect(snapshot.suppressedQueuedMessageIDs.size).toBe(0);
expect(reconciled).toBeUndefined();
});
});
describe("buildInactiveChatQueueReconciliation", () => {
const buildQueuedMessage = (id: number, text: string): ChatQueuedMessage => ({
...MockChatQueuedMessage,
id,
content: [{ type: "text", text }],
});
const userMessage: ChatMessage = {
...MockChatMessage,
id: 42,
role: "user",
};
it("keeps a message queued while the send was in flight", () => {
const a = buildQueuedMessage(1, "A");
const b = buildQueuedMessage(2, "B");
const c = buildQueuedMessage(3, "C");
const next = buildInactiveChatQueueReconciliation(
[a, b, c],
[a, b],
[userMessage],
a.id,
undefined,
);
expect(next?.map((m) => m.id)).toEqual([b.id, c.id]);
});
it("falls back to the pre-send queue when nothing is cached", () => {
const a = buildQueuedMessage(1, "A");
const b = buildQueuedMessage(2, "B");
const next = buildInactiveChatQueueReconciliation(
undefined,
[a, b],
[userMessage],
a.id,
undefined,
);
expect(next?.map((m) => m.id)).toEqual([b.id]);
});
});
describe("settlePromotedQueueHead", () => {
const buildQueuedMessage = (id: number, text: string): ChatQueuedMessage => ({
...MockChatQueuedMessage,
id,
content: [{ type: "text", text }],
});
const chatID = "chat-abc-123";
it("restores a head the server still has queued", async () => {
const store = createChatStore();
const a = buildQueuedMessage(1, "A");
const b = buildQueuedMessage(2, "B");
store.setActiveChatID(chatID);
store.setQueuedMessages([b]);
store.markQueuedMessagePromoted(a.id);
const settled = await settlePromotedQueueHead(
store,
chatID,
a.id,
async () => ({ messages: [], has_more: false, queued_messages: [a, b] }),
);
expect(settled?.map((m) => m.id)).toEqual([a.id, b.id]);
expect(store.getSnapshot().queuedMessages.map((m) => m.id)).toEqual([
a.id,
b.id,
]);
});
it("leaves the queue alone when the fetch fails", async () => {
const store = createChatStore();
const a = buildQueuedMessage(1, "A");
const b = buildQueuedMessage(2, "B");
store.setActiveChatID(chatID);
store.setQueuedMessages([b]);
store.markQueuedMessagePromoted(a.id);
const settled = await settlePromotedQueueHead(store, chatID, a.id, () =>
Promise.reject(new Error("offline")),
);
expect(settled).toBeUndefined();
expect(store.getSnapshot().queuedMessages.map((m) => m.id)).toEqual([b.id]);
expect(store.getSnapshot().promotedQueuedMessageIDs.has(a.id)).toBe(true);
});
it("returns the filtered queue the store applied", async () => {
const store = createChatStore();
const a = buildQueuedMessage(1, "A");
const b = buildQueuedMessage(2, "B");
const c = buildQueuedMessage(3, "C");
store.setActiveChatID(chatID);
store.setQueuedMessages([b]);
store.markQueuedMessagePromoted(a.id);
// An overlapping explicit promotion suppresses C, which the server has
// not deleted yet, so the caller must not cache it back.
store.suppressQueuedMessageID(c.id);
const settled = await settlePromotedQueueHead(
store,
chatID,
a.id,
async () => ({
messages: [],
has_more: false,
queued_messages: [a, b, c],
}),
);
expect(settled?.map((m) => m.id)).toEqual([a.id, b.id]);
});
it("discards a response that resolves after navigating to another chat", async () => {
const store = createChatStore();
const a = buildQueuedMessage(1, "A");
const b = buildQueuedMessage(2, "B");
store.setActiveChatID(chatID);
store.setQueuedMessages([b]);
store.markQueuedMessagePromoted(a.id);
const settled = await settlePromotedQueueHead(
store,
chatID,
a.id,
async () => {
store.setActiveChatID("chat-other");
store.setQueuedMessages([]);
return { messages: [], has_more: false, queued_messages: [a, b] };
},
);
expect(settled).toBeUndefined();
expect(store.getSnapshot().queuedMessages).toEqual([]);
});
});
describe("useConversationEditingState", () => {
const chatID = "chat-abc-123";
const expectedKey = `${draftInputStorageKeyPrefix}${chatID}`;
+190 -10
View File
@@ -7,6 +7,7 @@ import {
useState,
} from "react";
import type { QueryClient } from "react-query";
import {
useInfiniteQuery,
useMutation,
@@ -31,6 +32,7 @@ import {
chatModelConfigs,
chatModels,
chatProviderConfigs,
chatQueueConvergence,
compactChat,
createChatMessage,
deleteChatQueuedMessage,
@@ -115,6 +117,8 @@ import {
import {
type ChatDetailError,
formatUsageLimitMessage,
isChatHookDeniedResponse,
isChatHookDispatchFailedResponse,
isChatUsageLimitExceededResponse,
} from "./utils/usageLimitMessage";
@@ -232,6 +236,108 @@ export const runPromoteQueuedMessage = async (params: {
}
};
const buildPromotedQueueReconciliation = (
queuedMessages: readonly TypesGen.ChatQueuedMessage[],
insertedMessages: readonly TypesGen.ChatMessage[],
promotedHeadID: number | undefined,
queuedTail: TypesGen.ChatQueuedMessage | undefined,
hasObservedQueuedMessageID: (id: number) => boolean,
): readonly TypesGen.ChatQueuedMessage[] | undefined => {
if (promotedHeadID === undefined) {
return undefined;
}
if (!insertedMessages.some((message) => message.role === "user")) {
return undefined;
}
const remaining = queuedMessages.filter(
(message) => message.id !== promotedHeadID,
);
const tailPending =
queuedTail !== undefined &&
!remaining.some((message) => message.id === queuedTail.id) &&
!hasObservedQueuedMessageID(queuedTail.id);
return tailPending ? [...remaining, queuedTail] : remaining;
};
// Prefer an inactive chat's cached queue so messages queued during the send
// are not dropped; fall back when no cache exists.
export const buildInactiveChatQueueReconciliation = (
cachedQueuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined,
queuedMessagesBeforeSend: readonly TypesGen.ChatQueuedMessage[],
insertedMessages: readonly TypesGen.ChatMessage[],
promotedHeadID: number | undefined,
queuedTail: TypesGen.ChatQueuedMessage | undefined,
): readonly TypesGen.ChatQueuedMessage[] | undefined =>
buildPromotedQueueReconciliation(
cachedQueuedMessages ?? queuedMessagesBeforeSend,
insertedMessages,
promotedHeadID,
queuedTail,
() => false,
);
// Queue updates may rotate the head before the response arrives.
export const reconcilePromotedQueueHead = (
store: Pick<
ChatStore,
| "batch"
| "getSnapshot"
| "setQueuedMessages"
| "markQueuedMessagePromoted"
| "hasObservedQueuedMessageID"
>,
insertedMessages: readonly TypesGen.ChatMessage[],
promotedHeadID: number | undefined,
queuedTail: TypesGen.ChatQueuedMessage | undefined,
): readonly TypesGen.ChatQueuedMessage[] | undefined => {
const next = buildPromotedQueueReconciliation(
store.getSnapshot().queuedMessages,
insertedMessages,
promotedHeadID,
queuedTail,
store.hasObservedQueuedMessageID,
);
if (!next || promotedHeadID === undefined) {
return next;
}
store.batch(() => {
// The promoted user row proves the server deleted its queue row.
store.markQueuedMessagePromoted(promotedHeadID);
store.setQueuedMessages(next);
});
return next;
};
const fetchChatMessages = (queryClient: QueryClient) => (chatID: string) =>
queryClient.fetchQuery(chatQueueConvergence(chatID));
// A promoted head is suppressed locally, but another tab can queue or
// promote concurrently, so only the server knows the resulting queue.
export const settlePromotedQueueHead = async (
store: Pick<
ChatStore,
"getQueueConvergenceFence" | "applyPromoteRefetchQueuedMessages"
>,
chatID: string,
promotedHeadID: number,
fetchMessages: (chatID: string) => Promise<TypesGen.ChatMessagesResponse>,
): Promise<readonly TypesGen.ChatQueuedMessage[] | undefined> => {
const baselineFence = store.getQueueConvergenceFence();
let response: TypesGen.ChatMessagesResponse;
try {
response = await fetchMessages(chatID);
} catch {
// Convergence is best effort; a later authoritative update can correct it.
return undefined;
}
return store.applyPromoteRefetchQueuedMessages(
chatID,
promotedHeadID,
response.queued_messages ?? [],
baselineFence,
);
};
export async function submitEditAndScroll({
editMessage,
editArgs,
@@ -1102,10 +1208,18 @@ const AgentChatPage: FC = () => {
};
const aiGatewayDisabled = !useAIGatewayEnabled();
const { store, clearStreamError, upsertCacheMessages } = useChatStore({
const {
store,
acceptServerChatStatus,
clearStreamError,
setCacheQueuedMessages,
getCacheQueuedMessages,
upsertCacheMessages,
} = useChatStore({
chatID: agentId,
chatMessages: chatMessagesList,
chatRecord,
chatRecordUpdatedAt: chatQuery.dataUpdatedAt,
chatMessagesData,
chatQueuedMessages,
setChatErrorReason,
@@ -1253,8 +1367,13 @@ const AgentChatPage: FC = () => {
setChatErrorReason(agentId, reason);
} else if (isApiError(error)) {
const detail = error.response?.data?.detail?.trim() || undefined;
const kind = isChatHookDeniedResponse(error.response?.data)
? "hook_denied"
: isChatHookDispatchFailedResponse(error.response?.data)
? "hook_dispatch_failed"
: "generic";
const reason: ChatDetailError = {
kind: "generic",
kind,
message: getErrorMessage(error, "An unexpected error occurred."),
...(detail ? { detail } : {}),
};
@@ -1596,6 +1715,12 @@ const AgentChatPage: FC = () => {
onError: (error) => {
restoreOptimisticRequestSnapshot(store, previousSnapshot);
handleUsageLimitError(error);
// Hook dispatch failures can park an idle chat in error before returning the request error.
acceptServerChatStatus();
void queryClient.invalidateQueries({
queryKey: chatKey(agentId),
exact: true,
});
},
});
if (editSelectedModelConfigID) {
@@ -1627,6 +1752,11 @@ const AgentChatPage: FC = () => {
clearStreamError();
scrollToBottomRef.current?.();
// An errored-chat send may promote the queue head that existed when the request began.
const queuedMessagesBeforeSend = store.getSnapshot().queuedMessages;
const queueHeadIDBeforeSend = queuedMessagesBeforeSend[0]?.id;
const statusVersionBeforeSend = store.getServerChatStatusVersion();
// Don't clear stream state before the POST completes.
// For queued sends the WebSocket status events handle
// clearing; for non-queued sends we clear explicitly
@@ -1636,13 +1766,17 @@ const AgentChatPage: FC = () => {
response = await sendMessage(request);
} catch (error) {
handleUsageLimitError(error);
// Hook dispatch failures can park an idle chat in error before returning the request error.
acceptServerChatStatus();
void queryClient.invalidateQueries({
queryKey: chatKey(agentId),
exact: true,
});
throw error;
}
// When the server accepts the message immediately (not
// queued), clear the stream and insert the user's message
// so it appears in the timeline without waiting for the
// WebSocket stream.
if (!response.queued) {
const isActiveChat = store.getActiveChatID() === agentId;
// Waiting for the WebSocket on non-queued sends leaves stale stream state visible.
if (!response.queued && isActiveChat) {
store.clearStreamState();
// Optimistically set status to "running" so the
// Thinking indicator appears immediately.
@@ -1653,9 +1787,55 @@ const AgentChatPage: FC = () => {
// to error/pending instead, the WebSocket event
// overrides this optimistic value.
store.setChatStatus("running");
if (response.message) {
store.upsertDurableMessage(response.message);
upsertCacheMessages([response.message]);
}
// Upsert the full batch because a queued send can insert a promoted head below
// the highest cached ID, which a reconnect would skip.
const insertedMessages =
response.messages ?? (response.message ? [response.message] : []);
if (insertedMessages.length > 0) {
upsertCacheMessages(insertedMessages);
if (isActiveChat) {
store.upsertDurableMessages(insertedMessages);
}
if (response.queued) {
const reconciledQueue = isActiveChat
? reconcilePromotedQueueHead(
store,
insertedMessages,
queueHeadIDBeforeSend,
response.queued_message,
)
: buildInactiveChatQueueReconciliation(
getCacheQueuedMessages(),
queuedMessagesBeforeSend,
insertedMessages,
queueHeadIDBeforeSend,
response.queued_message,
);
if (reconciledQueue) {
setCacheQueuedMessages(reconciledQueue);
// A promoted head starts a turn, but any server status received during the
// request is newer and must win.
if (
isActiveChat &&
store.getServerChatStatusVersion() === statusVersionBeforeSend
) {
store.clearStreamState();
store.setChatStatus("running");
}
if (isActiveChat && queueHeadIDBeforeSend !== undefined) {
void settlePromotedQueueHead(
store,
agentId,
queueHeadIDBeforeSend,
fetchChatMessages(queryClient),
).then((settled) => {
if (settled) {
setCacheQueuedMessages(settled);
}
});
}
}
}
}
if (selectedModelConfigID) {
@@ -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,
@@ -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<AgentCreateFormProps> = ({
{formatUsageLimitMessage(createError.response.data)}
</AlertDescription>
</Alert>
) : isApiError(createError) &&
createError.response.status === 502 &&
isChatHookDispatchFailedResponse(createError.response.data) ? (
<Alert severity="error">
<AlertTitle>
{getErrorTitle("hook_dispatch_failed", "error")}
</AlertTitle>
<AlertDescription>
<span>{createError.response.data.message}</span>
{createError.response.data.detail && (
<span className="mt-1 block text-content-secondary">
{createError.response.data.detail}
</span>
)}
</AlertDescription>
</Alert>
) : isApiError(createError) &&
createError.response.status === 403 &&
isChatHookDeniedResponse(createError.response.data) ? (
<Alert severity="info">
<AlertDescription>
{createError.response.data.message}
</AlertDescription>
</Alert>
) : (
<ErrorAlert error={createError} />
)
@@ -405,6 +405,108 @@ const meta: Meta<typeof ConversationTimeline> = {
export default meta;
type Story = StoryObj<typeof ConversationTimeline>;
export const LifecycleHookNotice: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "system",
content: [
{
type: "text",
text: "Your organization requires an approval before deployment.",
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const notice = canvas.getByRole("note");
expect(notice).toBeVisible();
expect(within(notice).getByText("Lifecycle hook")).toBeVisible();
expect(
within(notice).getByText(
"Your organization requires an approval before deployment.",
),
).toBeVisible();
expect(
canvas.queryByRole("button", { name: "Copy message" }),
).not.toBeInTheDocument();
},
};
export const LifecycleHookNoticeOnUserMessage: Story = {
args: {
...defaultArgs,
urlTransform: (url) =>
url.replace("http://localhost:3000", "https://proxy.example.com"),
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [
{ type: "text", text: "original prompt" },
{
type: "hook-notice",
text: "Deployment context was added: [policy](http://localhost:3000/policy)",
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const notice = canvas.getByRole("note");
expect(notice).toBeVisible();
expect(within(notice).getByText("Lifecycle hook")).toBeVisible();
const prompt = canvas.getByText("original prompt");
expect(prompt).toBeVisible();
expect(
prompt.compareDocumentPosition(notice) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
const link = within(notice).getByRole("link", { name: "policy" });
expect(link).toHaveAttribute("href", "https://proxy.example.com/policy");
},
};
export const LifecycleHookNoticeAfterEditedMessage: Story = {
args: {
...defaultArgs,
editingMessageId: 1,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [{ type: "text", text: "prompt being edited" }],
},
{
...baseMessage,
id: 2,
role: "user",
content: [
{ type: "text", text: "later prompt" },
{
type: "hook-notice",
text: "Deployment context was added: [policy](http://localhost:3000/policy)",
},
],
},
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("prompt being edited")).toBeVisible();
const link = canvas.getByRole("link", { name: "policy" });
link.focus();
expect(link).not.toHaveFocus();
},
};
export const DurableListTemplatesToolLifecycle: Story = {
args: {
...defaultArgs,
@@ -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 }) => (
<div
role="note"
className="relative my-1 w-full rounded-lg border border-solid border-border-default bg-surface-secondary p-4 text-left"
>
<div className="flex min-w-0 flex-1 flex-row items-start gap-3 text-sm">
<InfoIcon className="size-icon-sm mt-[3px] text-highlight-sky" />
<div className="min-w-0 flex-1">{children}</div>
</div>
</div>
);
const LifecycleHookNotice: FC<{
children: string;
urlTransform?: UrlTransform;
}> = ({ children, urlTransform }) => (
<TimelineNotice>
<div className="flex flex-col gap-1">
<AlertTitle>Lifecycle hook</AlertTitle>
<Response urlTransform={urlTransform}>{children}</Response>
</div>
</TimelineNotice>
);
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 (
<div
className={cn(
isAfterEditingMessage && "opacity-40 pointer-events-none",
"transition-opacity duration-200",
)}
// Keep links in dimmed notices out of accessibility navigation.
inert={isAfterEditingMessage ? true : undefined}
>
<LifecycleHookNotice urlTransform={urlTransform}>
{parsed.markdown}
</LifecycleHookNotice>
</div>
);
}
const conversationItemProps: { role: "user" | "assistant" } = {
role: isUser ? "user" : "assistant",
@@ -600,6 +648,7 @@ const ChatMessageItem = memo<{
isAfterEditingMessage && "opacity-40 pointer-events-none",
"group/msg relative transition-opacity duration-200",
)}
inert={isAfterEditingMessage ? true : undefined}
>
<ConversationItem {...conversationItemProps}>
{isUser ? (
@@ -645,6 +694,14 @@ const ChatMessageItem = memo<{
</Message>
)}
</ConversationItem>
{parsed.hookNotices.map((notice, index) => (
<LifecycleHookNotice
key={`${message.id}-hook-notice-${index}`}
urlTransform={urlTransform}
>
{notice}
</LifecycleHookNotice>
))}
{!hideActions &&
(displayState.hasCopyableContent ||
(isUser && onEditUserMessage)) && (
@@ -777,6 +834,7 @@ const StickyUserMessage = memo<{
nextUserMessageId?: number;
onJumpToUserMessage?: (messageId: number) => void;
registerSentinel?: (messageId: number, el: HTMLDivElement | null) => void;
urlTransform?: UrlTransform;
}>(
({
message,
@@ -788,6 +846,7 @@ const StickyUserMessage = memo<{
nextUserMessageId,
onJumpToUserMessage,
registerSentinel,
urlTransform,
}) => {
const [isStuck, setIsStuck] = useState(false);
const [isReady, setIsReady] = useState(false);
@@ -1016,6 +1075,11 @@ const StickyUserMessage = memo<{
? { opacity: "calc(1 - var(--overlay-ready, 0))" }
: undefined
}
// While the overlay copy is shown, drop the flow copy
// from the accessibility tree so the message and its
// hook notices aren't exposed twice.
aria-hidden={isStuck && !isTooTall ? true : undefined}
inert={isStuck && !isTooTall ? true : undefined}
>
<ChatMessageItem
message={message}
@@ -1026,6 +1090,7 @@ const StickyUserMessage = memo<{
prevUserMessageId={prevUserMessageId}
nextUserMessageId={nextUserMessageId}
onJumpToUserMessage={onJumpToUserMessage}
urlTransform={urlTransform}
/>
</div>
@@ -1071,6 +1136,7 @@ const StickyUserMessage = memo<{
prevUserMessageId={prevUserMessageId}
nextUserMessageId={nextUserMessageId}
onJumpToUserMessage={onJumpToUserMessage}
urlTransform={urlTransform}
fadeFromBottom
/>
</div>
@@ -1089,6 +1155,10 @@ function computeLastInChainFlags(
let nextVisibleIsUser = true;
for (let i = displayMessages.length - 1; i >= 0; i--) {
const entry = displayMessages[i];
if (entry.message.role === "system") {
nextVisibleIsUser = true;
continue;
}
if (entry.message.role !== "user") {
flags[i] = nextVisibleIsUser;
}
@@ -1265,6 +1335,7 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
nextUserMessageId={userNeighborsById.get(message.id)?.nextId}
onJumpToUserMessage={jumpToUserMessage}
registerSentinel={registerSentinel}
urlTransform={urlTransform}
/>
);
}
@@ -48,6 +48,10 @@ export const getErrorTitle = (
return "Provider disabled";
case "content_filter":
return "Response blocked";
case "hook_dispatch_failed":
return "Lifecycle hook failed";
case "hook_denied":
return "Blocked by policy";
default:
return mode === "retry" ? "Retrying request" : "Request failed";
}
@@ -32,6 +32,8 @@ const makeQueuedMessage = (
content: [{ type: "text", text }],
}) as TypesGen.ChatQueuedMessage;
const testChatID = "chat-1";
// ---------------------------------------------------------------------------
// replaceMessages
// ---------------------------------------------------------------------------
@@ -460,8 +462,7 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => {
store.suppressQueuedMessageID(b.id);
expect(store.getSnapshot().suppressedQueuedMessageIDs.has(b.id)).toBe(true);
// Transient reordered queue from the running-case backend
// must not surface the suppressed message.
// Running-case promotion only reorders the queue; the backend still reports the row.
store.applyAuthoritativeQueuedMessages([b, a, c]);
expect(
store.getSnapshot().queuedMessages.map((message) => message.id),
@@ -491,6 +492,272 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => {
).toEqual([a.id, c.id]);
});
it("still applies newly queued messages while a suppressed message stays queued", () => {
const store = createChatStore();
const a = makeQueuedMessage(1, "A");
const b = makeQueuedMessage(2, "B");
const d = makeQueuedMessage(4, "D");
store.setQueuedMessages([b]);
store.suppressQueuedMessageID(a.id);
store.applyAuthoritativeQueuedMessages([a, b, d]);
expect(
store.getSnapshot().queuedMessages.map((message) => message.id),
).toEqual([b.id, d.id]);
expect(store.getSnapshot().suppressedQueuedMessageIDs.has(a.id)).toBe(true);
});
it("ignores stale snapshots that still list a promoted message", () => {
const store = createChatStore();
const a = makeQueuedMessage(1, "A");
const b = makeQueuedMessage(2, "B");
const c = makeQueuedMessage(3, "C");
store.setQueuedMessages([b, c]);
store.markQueuedMessagePromoted(a.id);
store.applyAuthoritativeQueuedMessages([a, b]);
expect(
store.getSnapshot().queuedMessages.map((message) => message.id),
).toEqual([b.id, c.id]);
expect(store.getSnapshot().promotedQueuedMessageIDs.has(a.id)).toBe(true);
store.applyAuthoritativeQueuedMessages([b, c]);
expect(
store.getSnapshot().queuedMessages.map((message) => message.id),
).toEqual([b.id, c.id]);
expect(store.getSnapshot().promotedQueuedMessageIDs.size).toBe(0);
expect(store.getSnapshot().suppressedQueuedMessageIDs.size).toBe(0);
});
it("records queued IDs the server has reported", () => {
const store = createChatStore();
const a = makeQueuedMessage(1, "A");
const b = makeQueuedMessage(2, "B");
const c = makeQueuedMessage(3, "C");
expect(store.hasObservedQueuedMessageID(a.id)).toBe(false);
store.applyAuthoritativeQueuedMessages([a, b]);
expect(store.hasObservedQueuedMessageID(a.id)).toBe(true);
expect(store.hasObservedQueuedMessageID(b.id)).toBe(true);
store.applyAuthoritativeQueuedMessages([b]);
expect(store.hasObservedQueuedMessageID(a.id)).toBe(true);
store.setQueuedMessages([b, c]);
expect(store.hasObservedQueuedMessageID(c.id)).toBe(false);
store.clearSuppressedQueuedMessageIDs();
expect(store.hasObservedQueuedMessageID(a.id)).toBe(false);
});
it("counts every server status report, including repeats", () => {
const store = createChatStore();
expect(store.getServerChatStatusVersion()).toBe(0);
store.setChatStatus("running");
expect(store.getServerChatStatusVersion()).toBe(0);
store.applyServerChatStatus("error");
expect(store.getServerChatStatusVersion()).toBe(1);
expect(store.getSnapshot().chatStatus).toBe("error");
store.applyServerChatStatus("error");
expect(store.getServerChatStatusVersion()).toBe(2);
expect(store.getSnapshot().chatStatus).toBe("error");
});
it("restores a promoted head that a fresh snapshot still queues", () => {
const store = createChatStore();
const a = makeQueuedMessage(1, "A");
const b = makeQueuedMessage(2, "B");
store.setActiveChatID(testChatID);
store.setQueuedMessages([b]);
store.markQueuedMessagePromoted(a.id);
const baseline = store.getQueueConvergenceFence();
expect(
store
.applyPromoteRefetchQueuedMessages(testChatID, a.id, [a, b], baseline)
?.map((message) => message.id),
).toEqual([a.id, b.id]);
expect(
store.getSnapshot().queuedMessages.map((message) => message.id),
).toEqual([a.id, b.id]);
expect(store.getSnapshot().promotedQueuedMessageIDs.size).toBe(0);
expect(store.getSnapshot().suppressedQueuedMessageIDs.size).toBe(0);
});
it("ignores a promote refetch that a newer snapshot already superseded", () => {
const store = createChatStore();
const a = makeQueuedMessage(1, "A");
const b = makeQueuedMessage(2, "B");
const c = makeQueuedMessage(3, "C");
store.setActiveChatID(testChatID);
store.setQueuedMessages([b]);
store.markQueuedMessagePromoted(a.id);
const baseline = store.getQueueConvergenceFence();
store.applyAuthoritativeQueuedMessages([b, c]);
expect(
store.applyPromoteRefetchQueuedMessages(
testChatID,
a.id,
[a, b],
baseline,
),
).toBeUndefined();
expect(
store.getSnapshot().queuedMessages.map((message) => message.id),
).toEqual([b.id, c.id]);
expect(store.getSnapshot().promotedQueuedMessageIDs.size).toBe(0);
});
it("still applies a promote refetch after a stale snapshot was discarded", () => {
const store = createChatStore();
const a = makeQueuedMessage(1, "A");
const b = makeQueuedMessage(2, "B");
const c = makeQueuedMessage(3, "C");
store.setActiveChatID(testChatID);
store.setQueuedMessages([b]);
store.markQueuedMessagePromoted(a.id);
const baseline = store.getQueueConvergenceFence();
store.applyAuthoritativeQueuedMessages([a, b, c]);
expect(
store.applyPromoteRefetchQueuedMessages(
testChatID,
a.id,
[b, c],
baseline,
),
).toEqual([b, c]);
expect(
store.getSnapshot().queuedMessages.map((message) => message.id),
).toEqual([b.id, c.id]);
});
it("ignores a promote refetch that resolves after switching chats", () => {
const store = createChatStore();
const a = makeQueuedMessage(1, "A");
const b = makeQueuedMessage(2, "B");
store.setActiveChatID(testChatID);
store.setQueuedMessages([b]);
store.markQueuedMessagePromoted(a.id);
const baseline = store.getQueueConvergenceFence();
store.setActiveChatID("chat-other");
store.setQueuedMessages([]);
expect(
store.applyPromoteRefetchQueuedMessages(
testChatID,
a.id,
[a, b],
baseline,
),
).toBeUndefined();
expect(store.getSnapshot().queuedMessages).toEqual([]);
});
it("ignores a promote refetch spanning a round trip back to the same chat", () => {
const store = createChatStore();
const a = makeQueuedMessage(1, "A");
const b = makeQueuedMessage(2, "B");
store.setActiveChatID(testChatID);
store.setQueuedMessages([b]);
store.markQueuedMessagePromoted(a.id);
const baseline = store.getQueueConvergenceFence();
store.setActiveChatID("chat-other");
store.setActiveChatID(testChatID);
expect(
store.applyPromoteRefetchQueuedMessages(
testChatID,
a.id,
[a, b],
baseline,
),
).toBeUndefined();
expect(
store.getSnapshot().queuedMessages.map((message) => message.id),
).toEqual([b.id]);
});
it("ignores a promote refetch naming another chat even at a matching fence", () => {
const store = createChatStore();
const a = makeQueuedMessage(1, "A");
const b = makeQueuedMessage(2, "B");
store.setActiveChatID(testChatID);
store.setQueuedMessages([b]);
store.markQueuedMessagePromoted(a.id);
expect(
store.applyPromoteRefetchQueuedMessages(
"chat-other",
a.id,
[a, b],
store.getQueueConvergenceFence(),
),
).toBeUndefined();
expect(
store.getSnapshot().queuedMessages.map((message) => message.id),
).toEqual([b.id]);
});
it("returns the queue it applied, not the raw snapshot", () => {
const store = createChatStore();
const a = makeQueuedMessage(1, "A");
const b = makeQueuedMessage(2, "B");
const c = makeQueuedMessage(3, "C");
store.setActiveChatID(testChatID);
store.setQueuedMessages([b]);
store.markQueuedMessagePromoted(a.id);
store.suppressQueuedMessageID(c.id);
const baseline = store.getQueueConvergenceFence();
expect(
store
.applyPromoteRefetchQueuedMessages(
testChatID,
a.id,
[a, b, c],
baseline,
)
?.map((message) => message.id),
).toEqual([a.id, b.id]);
expect(
store.getSnapshot().queuedMessages.map((message) => message.id),
).toEqual([a.id, b.id]);
});
it("unsuppressQueuedMessageID clears a promoted marker after a failed promotion", () => {
const store = createChatStore();
const a = makeQueuedMessage(1, "A");
const b = makeQueuedMessage(2, "B");
store.markQueuedMessagePromoted(a.id);
store.unsuppressQueuedMessageID(a.id);
expect(store.getSnapshot().promotedQueuedMessageIDs.size).toBe(0);
store.applyAuthoritativeQueuedMessages([a, b]);
expect(
store.getSnapshot().queuedMessages.map((message) => message.id),
).toEqual([a.id, b.id]);
});
it("unsuppressQueuedMessageID removes IDs from the suppression set", () => {
const store = createChatStore();
store.suppressQueuedMessageID(42);
@@ -1537,6 +1537,81 @@ describe("useChatStore", () => {
expect(cachedData?.pages[0]?.queued_messages).toEqual([]);
});
it("caches the filtered queue when a queue_update still contains a suppressed message", async () => {
const chatID = "chat-1";
const existingMessage = buildMessage(chatID, 1, "user", "hello");
const queuedMessage = buildQueuedMessage(chatID, 10, "queued");
const mockSocket = createMockSocket();
mockWatchChatReturn(mockSocket);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: Number.POSITIVE_INFINITY,
refetchOnWindowFocus: false,
networkMode: "offlineFirst",
},
},
});
const initialChatMessagesData: TypesGen.ChatMessagesResponse = {
messages: [existingMessage],
queued_messages: [queuedMessage],
has_more: false,
};
queryClient.setQueryData(chatMessagesKey(chatID), {
pages: [initialChatMessagesData],
pageParams: [undefined],
});
const wrapper = createWrapper(queryClient);
const setChatErrorReason = vi.fn();
const clearChatErrorReason = vi.fn();
const { result } = renderHook(
() => {
const { store } = useChatStore({
chatID,
chatMessages: [existingMessage],
chatRecord: buildChat(chatID),
chatMessagesData: initialChatMessagesData,
chatQueuedMessages: [queuedMessage],
setChatErrorReason,
clearChatErrorReason,
});
return {
store,
queuedMessages: useChatSelector(store, selectQueuedMessages),
};
},
{ wrapper },
);
await waitFor(() => {
expect(watchChat).toHaveBeenCalledWith(chatID, 1);
});
act(() => {
result.current.store.suppressQueuedMessageID(queuedMessage.id);
});
act(() => {
mockSocket.emitData({
type: "queue_update",
chat_id: chatID,
queued_messages: [queuedMessage],
});
});
await waitFor(() => {
expect(result.current.queuedMessages).toEqual([]);
});
const cachedData = queryClient.getQueryData<{
pages: TypesGen.ChatMessagesResponse[];
pageParams: unknown[];
}>(chatMessagesKey(chatID));
expect(cachedData?.pages[0]?.queued_messages).toEqual([]);
});
it("writes WebSocket message events into the chat query cache", async () => {
const chatID = "chat-1";
const existingMessage = buildMessage(chatID, 1, "user", "hello");
@@ -2215,6 +2290,245 @@ describe("useChatStore", () => {
});
});
it("applies a refetched status after acceptServerChatStatus", async () => {
const chatID = "chat-resync";
const mockSocket = createMockSocket();
mockWatchChatReturn(mockSocket);
const queryClient = createTestQueryClient();
const wrapper = createWrapper(queryClient);
const initialProps: { status: TypesGen.ChatStatus; updatedAt: number } = {
status: "waiting",
updatedAt: 1,
};
const { result, rerender } = renderHook(
({ status, updatedAt }: typeof initialProps) => {
const { store, acceptServerChatStatus } = useChatStore({
chatID,
chatMessages: [],
chatRecord: { ...buildChat(chatID), status },
chatRecordUpdatedAt: updatedAt,
chatMessagesData: {
messages: [],
queued_messages: [],
has_more: false,
},
chatQueuedMessages: [],
setChatErrorReason: vi.fn(),
clearChatErrorReason: vi.fn(),
});
return {
acceptServerChatStatus,
chatStatus: useChatSelector(store, selectChatStatus),
};
},
{ wrapper, initialProps },
);
await waitFor(() => {
expect(watchChat).toHaveBeenCalledWith(chatID, undefined);
});
act(() => {
mockSocket.emitData({
type: "status",
chat_id: chatID,
status: { status: "running" },
});
});
await waitFor(() => {
expect(result.current.chatStatus).toBe("running");
});
rerender({ status: "error", updatedAt: 1 });
expect(result.current.chatStatus).toBe("running");
act(() => {
result.current.acceptServerChatStatus();
});
rerender({ status: "waiting", updatedAt: 1 });
expect(result.current.chatStatus).toBe("running");
rerender({ status: "error", updatedAt: 2 });
await waitFor(() => {
expect(result.current.chatStatus).toBe("error");
});
});
it("hydrates an unchanged status after a successful refetch", async () => {
const chatID = "chat-resync-same";
const mockSocket = createMockSocket();
mockWatchChatReturn(mockSocket);
const queryClient = createTestQueryClient();
const wrapper = createWrapper(queryClient);
const chatRecord = { ...buildChat(chatID), status: "error" as const };
const { result, rerender } = renderHook(
({ updatedAt }: { updatedAt: number }) => {
const { store, acceptServerChatStatus } = useChatStore({
chatID,
chatMessages: [],
chatRecord,
chatRecordUpdatedAt: updatedAt,
chatMessagesData: {
messages: [],
queued_messages: [],
has_more: false,
},
chatQueuedMessages: [],
setChatErrorReason: vi.fn(),
clearChatErrorReason: vi.fn(),
});
return {
acceptServerChatStatus,
chatStatus: useChatSelector(store, selectChatStatus),
};
},
{ wrapper, initialProps: { updatedAt: 1 } },
);
await waitFor(() => {
expect(watchChat).toHaveBeenCalledWith(chatID, undefined);
});
act(() => {
mockSocket.emitData({
type: "status",
chat_id: chatID,
status: { status: "running" },
});
});
await waitFor(() => {
expect(result.current.chatStatus).toBe("running");
});
act(() => {
result.current.acceptServerChatStatus();
});
expect(result.current.chatStatus).toBe("running");
rerender({ updatedAt: 2 });
await waitFor(() => {
expect(result.current.chatStatus).toBe("error");
});
});
it("ignores a resync armed by a request from a chat the user left", async () => {
const leftChatID = "chat-resync-left";
const activeChatID = "chat-resync-active";
const mockSocket = createMockSocket();
mockWatchChatReturn(mockSocket);
const queryClient = createTestQueryClient();
const wrapper = createWrapper(queryClient);
const { result, rerender } = renderHook(
({ chatID, updatedAt }: { chatID: string; updatedAt: number }) => {
const { store, acceptServerChatStatus } = useChatStore({
chatID,
chatMessages: [],
chatRecord: { ...buildChat(chatID), status: "waiting" },
chatRecordUpdatedAt: updatedAt,
chatMessagesData: {
messages: [],
queued_messages: [],
has_more: false,
},
chatQueuedMessages: [],
setChatErrorReason: () => {},
clearChatErrorReason: () => {},
});
return {
acceptServerChatStatus,
chatStatus: useChatSelector(store, selectChatStatus),
};
},
{
wrapper,
initialProps: { chatID: leftChatID, updatedAt: 1 },
},
);
await waitFor(() => {
expect(watchChat).toHaveBeenCalledWith(leftChatID, undefined);
});
// The in-flight request holds the callback from the render it started in.
const staleAcceptServerChatStatus = result.current.acceptServerChatStatus;
rerender({ chatID: activeChatID, updatedAt: 5 });
await waitFor(() => {
expect(watchChat).toHaveBeenCalledWith(activeChatID, undefined);
});
act(() => {
mockSocket.emitData({
type: "status",
chat_id: activeChatID,
status: { status: "running" },
});
});
await waitFor(() => {
expect(result.current.chatStatus).toBe("running");
});
act(() => {
staleAcceptServerChatStatus();
});
await waitFor(() => {
expect(result.current.chatStatus).toBe("running");
});
});
it("keeps a websocket status delivered while the resync refetch is in flight", async () => {
const chatID = "chat-resync-ws-race";
const mockSocket = createMockSocket();
mockWatchChatReturn(mockSocket);
const queryClient = createTestQueryClient();
const wrapper = createWrapper(queryClient);
const chatRecord = { ...buildChat(chatID), status: "error" as const };
const { result, rerender } = renderHook(
({ updatedAt }: { updatedAt: number }) => {
const { store, acceptServerChatStatus } = useChatStore({
chatID,
chatMessages: [],
chatRecord,
chatRecordUpdatedAt: updatedAt,
chatMessagesData: {
messages: [],
queued_messages: [],
has_more: false,
},
chatQueuedMessages: [],
setChatErrorReason: () => {},
clearChatErrorReason: () => {},
});
return {
acceptServerChatStatus,
chatStatus: useChatSelector(store, selectChatStatus),
};
},
{ wrapper, initialProps: { updatedAt: 1 } },
);
await waitFor(() => {
expect(watchChat).toHaveBeenCalledWith(chatID, undefined);
});
act(() => {
result.current.acceptServerChatStatus();
});
act(() => {
mockSocket.emitData({
type: "status",
chat_id: chatID,
status: { status: "running" },
});
});
await waitFor(() => {
expect(result.current.chatStatus).toBe("running");
});
rerender({ updatedAt: 2 });
await waitFor(() => {
expect(result.current.chatStatus).toBe("running");
});
});
it("sets chatStatus to error and populates streamError on error event", async () => {
immediateAnimationFrame();
@@ -140,6 +140,9 @@ export type ChatStoreState = {
// the running-case promote, where the backend reorders the
// queued message to the front before auto-promoting it.
suppressedQueuedMessageIDs: ReadonlySet<number>;
// IDs confirmed deleted from the queue because the send response
// contained their promoted user rows.
promotedQueuedMessageIDs: ReadonlySet<number>;
subagentStatusOverrides: Map<string, TypesGen.ChatStatus>;
};
@@ -167,7 +170,28 @@ export type ChatStore = {
applyAuthoritativeQueuedMessages: (
queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined,
) => void;
// Advances when an accepted snapshot or active-chat change invalidates an
// in-flight convergence request. Discarded snapshots do not advance it.
getQueueConvergenceFence: () => number;
// Applies a promotion refetch only while the chat and fence still match.
// Clears that ID's markers and returns the filtered queue for cache mirroring.
applyPromoteRefetchQueuedMessages: (
chatID: string,
promotedID: number,
queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined,
baselineFence: number,
) => readonly TypesGen.ChatQueuedMessage[] | undefined;
suppressQueuedMessageID: (id: number) => void;
markQueuedMessagePromoted: (id: number) => void;
// Distinguishes a tail still in flight from one the server listed and later removed.
hasObservedQueuedMessageID: (id: number) => boolean;
setActiveChatID: (chatID: string | null) => void;
getActiveChatID: () => string | null;
// Counts server-reported status events, including repeats of the
// current value, so a caller can tell that the server spoke during a
// request even when the status did not change.
getServerChatStatusVersion: () => number;
applyServerChatStatus: (status: TypesGen.ChatStatus | null) => void;
unsuppressQueuedMessageID: (id: number) => void;
clearSuppressedQueuedMessageIDs: () => void;
setChatStatus: (status: TypesGen.ChatStatus | null) => void;
@@ -197,11 +221,18 @@ const createInitialState = (): ChatStoreState => ({
reconnectState: null,
queuedMessages: [],
suppressedQueuedMessageIDs: new Set(),
promotedQueuedMessageIDs: new Set(),
subagentStatusOverrides: new Map(),
});
export const createChatStore = (): ChatStore => {
let state = createInitialState();
// Bookkeeping, deliberately outside the rendered state so observing a
// server event cannot trigger a re-render.
let observedQueuedMessageIDs = new Set<number>();
let queueConvergenceFence = 0;
let serverChatStatusVersion = 0;
let activeChatID: string | null = null;
const listeners = new Set<() => void>();
const emit = (): void => {
@@ -404,6 +435,20 @@ export const createChatStore = (): ChatStore => {
},
applyAuthoritativeQueuedMessages: (queuedMessages) => {
const incoming = queuedMessages ?? [];
for (const message of incoming) {
observedQueuedMessageIDs.add(message.id);
}
// A snapshot containing a confirmed promoted ID predates its queue
// deletion. Applying it would also drop newer queued messages, and
// counting it would discard the fresher refetch racing it.
if (
incoming.some((message) =>
state.promotedQueuedMessageIDs.has(message.id),
)
) {
return;
}
queueConvergenceFence++;
setState((current) => {
let nextSuppressed = current.suppressedQueuedMessageIDs;
if (current.suppressedQueuedMessageIDs.size > 0) {
@@ -431,16 +476,65 @@ export const createChatStore = (): ChatStore => {
);
const sameSuppressed =
nextSuppressed === current.suppressedQueuedMessageIDs;
if (sameQueue && sameSuppressed) {
const nextPromoted =
current.promotedQueuedMessageIDs.size === 0
? current.promotedQueuedMessageIDs
: new Set<number>();
const samePromoted = nextPromoted === current.promotedQueuedMessageIDs;
if (sameQueue && sameSuppressed && samePromoted) {
return current;
}
return {
...current,
queuedMessages: sameQueue ? current.queuedMessages : filtered,
suppressedQueuedMessageIDs: nextSuppressed,
promotedQueuedMessageIDs: nextPromoted,
};
});
},
getQueueConvergenceFence: () => queueConvergenceFence,
applyPromoteRefetchQueuedMessages: (
chatID,
promotedID,
queuedMessages,
baselineFence,
) => {
// The fence covers ordering, including navigation. This identity check
// additionally keeps a response that names another chat out of the
// shared store even if its caller captured the fence incorrectly.
if (activeChatID !== chatID) {
return undefined;
}
if (queueConvergenceFence !== baselineFence) {
return undefined;
}
const incoming = queuedMessages ?? [];
queueConvergenceFence++;
for (const message of incoming) {
observedQueuedMessageIDs.add(message.id);
}
const suppressed = new Set(state.suppressedQueuedMessageIDs);
suppressed.delete(promotedID);
const promoted = new Set(state.promotedQueuedMessageIDs);
promoted.delete(promotedID);
const applied =
suppressed.size === 0
? incoming
: incoming.filter((message) => !suppressed.has(message.id));
setState((current) => ({
...current,
queuedMessages: chatQueuedMessagesEqualByID(
current.queuedMessages,
applied,
)
? current.queuedMessages
: applied,
suppressedQueuedMessageIDs: suppressed,
promotedQueuedMessageIDs: promoted,
}));
return applied;
},
hasObservedQueuedMessageID: (id) => observedQueuedMessageIDs.has(id),
suppressQueuedMessageID: (id) => {
setState((current) => {
if (current.suppressedQueuedMessageIDs.has(id)) {
@@ -451,22 +545,58 @@ export const createChatStore = (): ChatStore => {
return { ...current, suppressedQueuedMessageIDs: next };
});
},
unsuppressQueuedMessageID: (id) => {
markQueuedMessagePromoted: (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.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) &&
!current.promotedQueuedMessageIDs.has(id)
) {
return current;
}
const suppressed = new Set(current.suppressedQueuedMessageIDs);
suppressed.delete(id);
const promoted = new Set(current.promotedQueuedMessageIDs);
promoted.delete(id);
return {
...current,
suppressedQueuedMessageIDs: suppressed,
promotedQueuedMessageIDs: promoted,
};
});
},
clearSuppressedQueuedMessageIDs: () => {
observedQueuedMessageIDs = new Set();
setState((current) => {
if (current.suppressedQueuedMessageIDs.size === 0) {
if (
current.suppressedQueuedMessageIDs.size === 0 &&
current.promotedQueuedMessageIDs.size === 0
) {
return current;
}
return { ...current, suppressedQueuedMessageIDs: new Set() };
return {
...current,
suppressedQueuedMessageIDs: new Set(),
promotedQueuedMessageIDs: new Set(),
};
});
},
setChatStatus: (status) => {
@@ -478,6 +608,27 @@ export const createChatStore = (): ChatStore => {
chatStatus: status,
}));
},
setActiveChatID: (chatID) => {
if (activeChatID === chatID) {
return;
}
activeChatID = chatID;
// Leaving a chat strands any convergence request issued for it, so a
// later return to the same chat cannot revive one.
queueConvergenceFence++;
},
getActiveChatID: () => activeChatID,
getServerChatStatusVersion: () => serverChatStatusVersion,
applyServerChatStatus: (status) => {
serverChatStatusVersion++;
if (state.chatStatus === status) {
return;
}
setState((current) => ({
...current,
chatStatus: status,
}));
},
setStreamState: (streamState) => {
if (state.streamState === streamState) {
return;
@@ -5,7 +5,11 @@ import {
useRef,
useState,
} from "react";
import { type InfiniteData, useQueryClient } from "react-query";
import {
type InfiniteData,
type QueryClient,
useQueryClient,
} from "react-query";
import { watchChat } from "#/api/api";
import {
chatMessagesKey,
@@ -27,6 +31,50 @@ import {
} from "./chatStore";
import type { RetryState } from "./types";
// Prevents REST re-hydration from replaying a stale queue over the store.
const writeQueuedMessagesToCache = (
queryClient: QueryClient,
chatID: string | undefined,
queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined,
): void => {
if (!chatID) {
return;
}
const nextQueuedMessages = queuedMessages ?? [];
queryClient.setQueryData<
InfiniteData<TypesGen.ChatMessagesResponse> | undefined
>(chatMessagesKey(chatID), (currentData) => {
if (!currentData?.pages?.length) {
return currentData;
}
const firstPage = currentData.pages[0];
if (
chatQueuedMessagesEqualByID(firstPage.queued_messages, nextQueuedMessages)
) {
return currentData;
}
return {
...currentData,
pages: [
{ ...firstPage, queued_messages: nextQueuedMessages },
...currentData.pages.slice(1),
],
};
});
};
const readQueuedMessagesFromCache = (
queryClient: QueryClient,
chatID: string | undefined,
): readonly TypesGen.ChatQueuedMessage[] | undefined => {
if (!chatID) {
return undefined;
}
return queryClient.getQueryData<
InfiniteData<TypesGen.ChatMessagesResponse> | undefined
>(chatMessagesKey(chatID))?.pages[0]?.queued_messages;
};
const normalizeRetryState = (retry: TypesGen.ChatStreamRetry): RetryState => ({
attempt: Math.max(1, retry.attempt),
error: retry.error.trim() || "Retrying request shortly.",
@@ -45,6 +93,7 @@ interface UseChatStoreOptions {
chatID: string | undefined;
chatMessages: readonly TypesGen.ChatMessage[] | undefined;
chatRecord: TypesGen.Chat | undefined;
chatRecordUpdatedAt?: number;
chatMessagesData: TypesGen.ChatMessagesResponse | undefined;
chatQueuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined;
setChatErrorReason: (chatID: string, reason: ChatDetailError) => void;
@@ -56,13 +105,21 @@ export const useChatStore = (
options: UseChatStoreOptions,
): {
store: ChatStore;
acceptServerChatStatus: () => void;
clearStreamError: () => void;
setCacheQueuedMessages: (
queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined,
) => void;
getCacheQueuedMessages: () =>
| readonly TypesGen.ChatQueuedMessage[]
| undefined;
upsertCacheMessages: (messages: readonly TypesGen.ChatMessage[]) => void;
} => {
const {
chatID,
chatMessages,
chatRecord,
chatRecordUpdatedAt = 0,
chatMessagesData,
chatQueuedMessages,
setChatErrorReason,
@@ -88,6 +145,9 @@ export const useChatStore = (
// stale value like "waiting", causing shouldApplyMessagePart()
// to drop all incoming parts.
const wsStatusReceivedRef = useRef(false);
const [pendingStatusResync, setPendingStatusResync] = useState(false);
const pendingStatusResyncUpdatedAtRef = useRef<number | null>(null);
const pendingStatusResyncVersionRef = useRef<number | null>(null);
const activeChatIDRef = useRef<string | null>(null);
const prevChatIDRef = useRef<string | undefined>(chatID);
// Snapshot of the chatMessages elements from the last sync effect
@@ -259,6 +319,26 @@ export const useChatStore = (
}, [chatID, chatMessages, store]);
useEffect(() => {
if (pendingStatusResync) {
const armedAt = pendingStatusResyncUpdatedAtRef.current;
// dataUpdatedAt advances after a fetch even when structural sharing
// preserves chatRecord.
if (armedAt === null || chatRecordUpdatedAt <= armedAt) {
return;
}
// Preserve a websocket status delivered during the refetch instead of applying its response.
const wsAdvanced =
store.getServerChatStatusVersion() !==
pendingStatusResyncVersionRef.current;
if (!wsAdvanced) {
store.setChatStatus(chatRecord?.status ?? null);
wsStatusReceivedRef.current = false;
}
pendingStatusResyncUpdatedAtRef.current = null;
pendingStatusResyncVersionRef.current = null;
setPendingStatusResync(false);
return;
}
// Only hydrate from REST when the WebSocket hasn't delivered
// a status event yet. Once the WS is the authoritative
// source, a stale REST refetch must not overwrite the
@@ -266,12 +346,15 @@ export const useChatStore = (
if (!wsStatusReceivedRef.current) {
store.setChatStatus(chatRecord?.status ?? null);
}
}, [chatRecord?.status, store]);
}, [chatRecord?.status, chatRecordUpdatedAt, store, pendingStatusResync]);
useEffect(() => {
queuedMessagesHydratedChatIDRef.current = null;
wsQueueUpdateReceivedRef.current = false;
wsStatusReceivedRef.current = false;
pendingStatusResyncUpdatedAtRef.current = null;
pendingStatusResyncVersionRef.current = null;
setPendingStatusResync(false);
store.setQueuedMessages([]);
// Suppression entries are scoped to the current chat; clear
// them on chat change so a stale promote suppression doesn't
@@ -298,6 +381,16 @@ export const useChatStore = (
return;
}
queuedMessagesHydratedChatIDRef.current = chatID;
// An optimistic promotion cache write must not clear suppression before
// a stale pre-promotion queue_update arrives.
if (
chatQueuedMessagesEqualByID(
store.getSnapshot().queuedMessages,
chatQueuedMessages ?? [],
)
) {
return;
}
store.applyAuthoritativeQueuedMessages(chatQueuedMessages);
}, [chatMessagesData, chatID, chatQueuedMessages, store]);
@@ -324,40 +417,9 @@ export const useChatStore = (
});
};
const updateChatQueuedMessages = (
queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined,
) => {
if (!chatID) {
return;
}
const nextQueuedMessages = queuedMessages ?? [];
queryClient.setQueryData<
InfiniteData<TypesGen.ChatMessagesResponse> | undefined
>(chatMessagesKey(chatID), (currentData) => {
if (!currentData?.pages?.length) {
return currentData;
}
const firstPage = currentData.pages[0];
if (
chatQueuedMessagesEqualByID(
firstPage.queued_messages,
nextQueuedMessages,
)
) {
return currentData;
}
return {
...currentData,
pages: [
{ ...firstPage, queued_messages: nextQueuedMessages },
...currentData.pages.slice(1),
],
};
});
};
store.resetTransientState();
activeChatIDRef.current = chatID ?? null;
store.setActiveChatID(chatID ?? null);
if (!chatID || !initialDataLoaded || aiGatewayDisabled) {
return;
@@ -570,7 +632,14 @@ export const useChatStore = (
store.applyAuthoritativeQueuedMessages(
streamEvent.queued_messages,
);
updateChatQueuedMessages(streamEvent.queued_messages);
// Cache the store's filtered queue, not the raw
// event, so a promoted message suppressed by the
// store cannot reappear on REST re-hydration.
writeQueuedMessagesToCache(
queryClient,
chatID,
store.getSnapshot().queuedMessages,
);
continue;
case "status": {
const nextStatus = streamEvent.status?.status;
@@ -580,7 +649,7 @@ export const useChatStore = (
wsStatusReceivedRef.current = true;
store.clearRetryState();
store.setChatStatus(nextStatus);
store.applyServerChatStatus(nextStatus);
if (nextStatus === "waiting") {
discardBufferedParts();
}
@@ -599,7 +668,8 @@ export const useChatStore = (
kind: "generic",
message: "Chat processing failed.",
};
store.setChatStatus("error");
wsStatusReceivedRef.current = true;
store.applyServerChatStatus("error");
store.setStreamError(reason);
store.clearRetryState();
setChatErrorReasonEvent(chatID, reason);
@@ -707,6 +777,7 @@ export const useChatStore = (
clearTimeout(partsFlushTimer);
}
activeChatIDRef.current = null;
store.setActiveChatID(null);
};
}, [
aiGatewayDisabled,
@@ -722,6 +793,26 @@ export const useChatStore = (
clearStreamError: () => {
store.clearStreamError();
},
// A failed request can change server-side chat status while the
// socket is down, and the socket having already delivered a status
// otherwise makes the refetched one inert.
acceptServerChatStatus: () => {
// A request that resolves after the user navigates away belongs to
// the previous chat, whose freshness and status are unrelated to
// the one now displayed by this shared store.
if (store.getActiveChatID() !== (chatID ?? null)) {
return;
}
pendingStatusResyncUpdatedAtRef.current = chatRecordUpdatedAt;
pendingStatusResyncVersionRef.current =
store.getServerChatStatusVersion();
setPendingStatusResync(true);
},
setCacheQueuedMessages: (queuedMessages) => {
writeQueuedMessagesToCache(queryClient, chatID, queuedMessages);
},
getCacheQueuedMessages: () =>
readQueuedMessagesFromCache(queryClient, chatID),
upsertCacheMessages,
};
};
@@ -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}`;
} else if (files.length > 1) {
label = `Edited ${files.length} files`;
} else {
label = "Edited files";
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) {
subject = `${files.length} files`;
}
const label = isRunning ? `${verb} ${subject}` : `${verb} ${subject}`;
const errorDetail = isError ? errorMessage?.trim() : undefined;
return (
<ToolCall.Root
@@ -64,11 +61,16 @@ export const EditFilesTool: React.FC<{
status={status}
isError={isError}
errorMessage={errorMessage || "Failed to edit files"}
hasContent={hasDiffs}
hasContent={hasDiffs || Boolean(errorDetail)}
defaultView={displayState}
>
<ToolCall.Header iconName="edit_files" label={label} />
<ToolCall.Content>
{errorDetail && (
<pre className="m-0 mt-1.5 whitespace-pre-wrap break-all border-0 bg-transparent p-0 font-mono text-xs leading-5 text-content-destructive">
{errorDetail}
</pre>
)}
<div className="mt-1.5 space-y-1.5">
{diffs.map((diff, i) =>
diff ? (
@@ -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<ExecuteToolProps> = ({
transcriptBlocks,
status,
isError,
errorText,
durationMs,
isBackgrounded = false,
killedBySignal,
@@ -71,6 +73,8 @@ export const ExecuteTool: React.FC<ExecuteToolProps> = ({
modelIntent,
parsedCommands,
durationLabel,
isRunning,
isError,
});
const defaultView = resolveAgentDisplayState(
shellToolDisplayMode,
@@ -83,7 +87,7 @@ export const ExecuteTool: React.FC<ExecuteToolProps> = ({
className="group/exec grid w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 rounded-md bg-surface-primary font-sans font-normal text-xs leading-5"
status={status}
isError={isError}
errorMessage="Command failed"
errorMessage={errorText || "Command failed"}
hasContent
defaultView={defaultView}
ariaLabel={(expanded) =>
@@ -152,6 +156,8 @@ type ShellCommandLineInput = {
modelIntent?: string;
parsedCommands?: readonly string[][];
durationLabel: string;
isRunning: boolean;
isError: boolean;
};
const getShellCommandLine = ({
@@ -159,6 +165,8 @@ const getShellCommandLine = ({
modelIntent,
parsedCommands,
durationLabel,
isRunning,
isError,
}: ShellCommandLineInput): { commandLabel: string; durationSuffix: string } => {
const intentLabel = sanitizeExecuteModelIntent(modelIntent, command);
const summary =
@@ -166,9 +174,12 @@ const getShellCommandLine = ({
? summarizeParsedCommands(parsedCommands)
: "";
const commandDisplay = summary || command;
const commandLabel = intentLabel
let commandLabel = intentLabel
? `${intentLabel} using ${commandDisplay}`
: `Ran ${commandDisplay}`;
if (!isRunning && isError) {
commandLabel = `Failed to run ${commandDisplay}`;
}
return {
commandLabel,
@@ -401,6 +401,32 @@ export const ExecuteError: Story = {
},
};
export const ExecuteDeniedByHook: Story = {
args: {
name: "execute",
status: "error",
isError: true,
args: { command: "cat /etc/secrets" },
result: {
error:
"This tool usage was blocked by an external policy (the deployment's lifecycle hook); the tool call was not executed. Reason: secret reads are blocked. This is an administrative policy decision, not a tool or workspace failure; retrying the same call will be denied again. Explain the policy block to the user and adjust your approach.",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/Failed to run cat \/etc\/secrets/)).toBeVisible();
expect(
canvas.queryByText(/Ran cat \/etc\/secrets/),
).not.toBeInTheDocument();
await expect(
canvas.getByRole("img", {
name: /blocked by an external policy/,
}),
).toBeVisible();
expect(canvas.getByText(/Reason: secret reads are blocked/)).toBeVisible();
},
};
export const ExecuteBackgrounded: Story = {
args: {
name: "execute",
@@ -1781,6 +1807,34 @@ export const WriteFileAlwaysExpanded: Story = {
},
};
export const WriteFileDeniedByHook: Story = {
args: {
name: "write_file",
status: "error",
isError: true,
codeDiffDisplayMode: "auto",
args: {
path: "src/utils/helpers.ts",
content: "export const helper = true;\n",
},
result: {
error:
"This tool usage was blocked by an external policy (the deployment's lifecycle hook); the tool call was not executed. Reason: writes to src are blocked.",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/Failed to write helpers\.ts/)).toBeInTheDocument();
await userEvent.click(
canvas.getByRole("button", { name: /Failed to write helpers\.ts/ }),
);
await waitFor(() => {
expect(canvas.getByText(/blocked by an external policy/)).toBeVisible();
});
expect(canvas.queryByTestId("write-file-diff")).not.toBeInTheDocument();
},
};
// ---------------------------------------------------------------------------
// EditFiles stories
// ---------------------------------------------------------------------------
@@ -1954,7 +2008,10 @@ export const EditFilesError: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/Edited missing\.ts/)).toBeInTheDocument();
expect(canvas.getByText(/Failed to edit missing\.ts/)).toBeInTheDocument();
await waitFor(() => {
expect(canvas.getByText("File not found")).toBeVisible();
});
// On error, no diff body: the synthetic fallback would
// misrepresent a rejected edit as applied.
expect(canvas.queryAllByTestId("edit-file-diff")).toHaveLength(0);
@@ -247,6 +247,7 @@ const ExecuteRenderer: FC<ToolRendererProps> = ({
transcriptBlocks={data.transcriptBlocks}
status={status}
isError={isError}
errorText={data.errorText}
durationMs={data.durationMs}
isBackgrounded={data.isBackgrounded}
killedBySignal={killedBySignal}
@@ -39,7 +39,16 @@ export const WriteFileTool: React.FC<{
);
const filename = getPathBasename(path);
const label = isRunning ? `Writing ${filename}` : `Wrote ${filename}`;
let label = `Wrote ${filename}`;
if (isRunning) {
label = `Writing ${filename}`;
} else if (isError) {
label = `Failed to write ${filename}`;
}
// The diff is synthesized from tool args, so showing it on error could
// misrepresent the content as written.
const showDiff = hasDiff && !isError;
const errorDetail = isError ? errorMessage?.trim() : undefined;
return (
<ToolCall.Root
@@ -48,12 +57,17 @@ export const WriteFileTool: React.FC<{
status={status}
isError={isError}
errorMessage={errorMessage || "Failed to write file"}
hasContent={hasDiff}
hasContent={showDiff || Boolean(errorDetail)}
defaultView={displayState}
>
<ToolCall.Header iconName="write_file" label={label} />
<ToolCall.Content>
{hasDiff && (
{errorDetail && (
<pre className="m-0 mt-1.5 whitespace-pre-wrap break-all border-0 bg-transparent p-0 font-mono text-xs leading-5 text-content-destructive">
{errorDetail}
</pre>
)}
{showDiff && (
<ScrollArea
data-testid="write-file-diff"
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
@@ -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",
@@ -16,6 +16,7 @@ export type ExecuteTranscriptBlock = {
type ExecuteRenderData = {
command: string;
transcriptBlocks: ExecuteTranscriptBlock[];
errorText: string;
durationMs?: number;
isBackgrounded: boolean;
authenticateURL: string;
@@ -63,6 +64,7 @@ export const getExecuteRenderData = (
return {
command,
transcriptBlocks,
errorText,
durationMs,
isBackgrounded,
authenticateURL,
@@ -201,3 +201,24 @@ export const MixedQueueWithAttachments: Story = {
],
},
};
export const HookNotice: Story = {
args: {
messages: [
buildMessage(1, [
{ type: "text", text: "Deploy to production" },
{ type: "hook-notice", text: "Deployment prompts are audited." },
]),
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = canvas.getByRole("button", {
name: "Lifecycle hook notice: Deployment prompts are audited.",
});
await userEvent.tab();
expect(trigger).toHaveFocus();
const tooltip = await within(document.body).findByRole("tooltip");
expect(tooltip).toHaveTextContent("Deployment prompts are audited.");
},
};
@@ -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" },
@@ -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<QueuedMessagesListProps> = ({
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<QueuedMessagesListProps> = ({
rawText,
attachmentCount,
fileBlocks,
hookNotices,
};
});
@@ -214,6 +214,22 @@ export const QueuedMessagesList: FC<QueuedMessagesListProps> = ({
<span aria-hidden="true">{item.attachmentCount}</span>
</span>
)}
{item.hookNotices.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`Lifecycle hook notice: ${item.hookNotices.join(" ")}`}
className="flex shrink-0 cursor-default items-center border-none bg-transparent p-0 text-highlight-sky"
>
<InfoIcon className="size-3" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent side="top">
{item.hookNotices.join(" ")}
</TooltipContent>
</Tooltip>
)}
{isFirst && (
<span
className={cn(
@@ -81,6 +81,28 @@ export function isChatUsageLimitExceededResponse(
);
}
export function isChatHookDispatchFailedResponse(
value: unknown,
): value is TypesGen.ChatHookDispatchFailedResponse {
return (
typeof value === "object" &&
value !== null &&
"kind" in value &&
value.kind === "hook_dispatch_failed"
);
}
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
@@ -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", () => {