diff --git a/site/src/pages/AgentsPage/AgentDetail.stories.tsx b/site/src/pages/AgentsPage/AgentDetail.stories.tsx index d1f2bcc6f7..966c2dbdf2 100644 --- a/site/src/pages/AgentsPage/AgentDetail.stories.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.stories.tsx @@ -1043,3 +1043,141 @@ export const StreamedReasoning: Story = { ).resolves.toBeInTheDocument(); }, }; + +/** + * Validates that text currently being streamed via WebSocket is not lost + * when the user sends a follow-up message and the server responds with a + * queued acknowledgement. The streaming content must remain visible in the + * DOM after the send completes. + */ +export const QueuedSendWithActiveStream: Story = { + beforeEach: () => { + const spy = spyOn(API.experimental, "createChatMessage").mockResolvedValue({ + queued: true, + queued_message: { + id: 99, + chat_id: CHAT_ID, + created_at: "2026-02-18T00:00:02.000Z", + content: [{ type: "text", text: "follow-up" }], + }, + }); + return () => spy.mockRestore(); + }, + parameters: { + queries: buildQueries( + { + id: CHAT_ID, + ...baseChatFields, + title: "Streaming survives queued send", + status: "running", + }, + { messages: [], queued_messages: [], has_more: false }, + { diffUrl: undefined }, + ), + webSocket: { + "/chats/": [ + { + event: "message", + data: wrapSSE({ + type: "message_part", + message_part: { + part: { + type: "text", + text: "I am helping you with the implementation", + }, + }, + }), + }, + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for the streamed text to appear. + await expect( + canvas.findByText("I am helping you with the implementation"), + ).resolves.toBeInTheDocument(); + + // Type a follow-up message and send it. + const textbox = canvas.getByRole("textbox"); + await userEvent.type(textbox, "follow-up"); + await userEvent.keyboard("{Enter}"); + + // Verify the send actually fired (guards against the test + // passing trivially if a future change blocks the send). + await waitFor(() => { + expect(API.experimental.createChatMessage).toHaveBeenCalledTimes(1); + }); + + // After the queued send, the streaming text must still be visible. + expect( + canvas.getByText("I am helping you with the implementation"), + ).toBeInTheDocument(); + }, +}; + +/** + * Validates that a failed POST during an active stream does not wipe + * the streaming output. The catch block re-throws before reaching + * clearStreamState(), so the in-progress text must survive. + */ +export const FailedSendWithActiveStream: Story = { + beforeEach: () => { + const spy = spyOn(API.experimental, "createChatMessage").mockRejectedValue( + new Error("network error"), + ); + return () => spy.mockRestore(); + }, + parameters: { + queries: buildQueries( + { + id: CHAT_ID, + ...baseChatFields, + title: "Failed send preserves stream", + status: "running", + }, + { messages: [], queued_messages: [], has_more: false }, + { diffUrl: undefined }, + ), + webSocket: { + "/chats/": [ + { + event: "message", + data: wrapSSE({ + type: "message_part", + message_part: { + part: { + type: "text", + text: "I am helping you with the implementation", + }, + }, + }), + }, + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for the streamed text to appear. + await expect( + canvas.findByText("I am helping you with the implementation"), + ).resolves.toBeInTheDocument(); + + // Type a message and send it (the POST will reject). + const textbox = canvas.getByRole("textbox"); + await userEvent.type(textbox, "this will fail"); + await userEvent.keyboard("{Enter}"); + + // Verify the send was attempted. + await waitFor(() => { + expect(API.experimental.createChatMessage).toHaveBeenCalledTimes(1); + }); + + // The streaming text must survive the failed send. + expect( + canvas.getByText("I am helping you with the implementation"), + ).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentDetail.tsx b/site/src/pages/AgentsPage/AgentDetail.tsx index bf238c7b7c..fa91b6301c 100644 --- a/site/src/pages/AgentsPage/AgentDetail.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.tsx @@ -669,12 +669,12 @@ const AgentDetail: FC = () => { if (scrollContainerRef.current) { scrollContainerRef.current.scrollTop = 0; } - store.clearStreamState(); try { await editMutation.mutateAsync({ messageId: editedMessageID, req: request, }); + store.clearStreamState(); setPendingEditMessageId(null); } catch (error) { setPendingEditMessageId(null); @@ -698,10 +698,10 @@ const AgentDetail: FC = () => { scrollContainerRef.current.scrollTop = 0; } - // No optimistic rendering — the message will appear in the - // timeline when the server confirms via the POST response or - // via the SSE stream. - store.clearStreamState(); + // 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 + // below. Clearing eagerly causes a visible cutoff. let response: Awaited>; try { response = await sendMutation.mutateAsync(request); @@ -710,10 +710,14 @@ const AgentDetail: FC = () => { throw error; } // When the server accepts the message immediately (not - // queued), insert it into the store so it appears in the - // timeline without waiting for the SSE stream. - if (!response.queued && response.message) { - store.upsertDurableMessage(response.message); + // 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) { + store.clearStreamState(); + if (response.message) { + store.upsertDurableMessage(response.message); + } } if (selectedModelConfigID) { localStorage.setItem(lastModelConfigIDStorageKey, selectedModelConfigID);