mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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:
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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} />
|
||||
)
|
||||
|
||||
+102
@@ -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";
|
||||
}
|
||||
|
||||
+269
-2
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user