deer-flow/frontend/tests/unit/core/threads/send-message-on-sent.dom.test.tsx
Hyeonsang Cho f0476677de
fix(frontend): keep composer context when an attachment upload fails (#6412)
* fix(frontend): keep composer context when an attachment upload fails

sendMessage called onSent right after the in-flight guard, before any
attachment was prepared or uploaded. The composer's onSent drops its
one-time state there: quotes, conversation references, staged project
files and the stored draft. PromptInput keeps the text and files when the
upload rejects, so the user saw the same message and retried it, but the
retry went out without that context.

onSent now fires once the send is dispatched: after the attachments
upload, right before the run is submitted. A failed preparation or upload
rejects first, so every caller (the chat composer and the side chat)
keeps its state for a retry.

Because onSent can now arrive after the composer has moved to another
conversation, InputBox checks which conversation it shows. If it changed,
onSent leaves that conversation's live state alone and retires only the
sending conversation's stored draft and staged files, through a new
clearProjectAttachments helper that does not touch mounted state.

The chat E2E that held an attachment upload asserted the draft was gone
mid-upload, which encoded the old timing. It now asserts the draft stays
stored while the upload is pending and is gone, without being rewritten,
once the send is dispatched.

* docs(changelog): reference #6412

* docs(frontend): note onSent's guarantee ends at dispatch

* fix(frontend): retire only what a detached send carried

A send whose upload finishes after its composer unmounted still took the
live path, because only a conversation switch was detected, and could
delete a draft a new composer saved under the same reusable key. A late
send also cleared the thread's whole staged list, dropping a document
attached during the upload.

Track the composer's lifetime instead of its key, and in the detached
path clear the stored draft only while it still holds the sent text and
retire only the staged entries the send captured.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-07 17:55:15 +08:00

215 lines
5.6 KiB
TypeScript

import { afterEach, beforeEach, expect, rs, test } from "@rstest/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook } from "@testing-library/react";
import { createElement, type ReactNode } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { I18nContext } from "@/core/i18n/context";
import { enUS } from "@/core/i18n/locales/en-US";
import { DEFAULT_LOCAL_SETTINGS } from "@/core/settings/local";
import type { UploadResponse } from "@/core/uploads/api";
// onSent tells callers they may drop one-time composer state (quotes,
// references, staged files, the stored draft). It must not fire for a send
// whose attachments never upload, or a retry goes out without that context.
const mocks = rs.hoisted(() => ({
events: [] as string[],
submit: rs.fn(async () => undefined),
}));
rs.mock("@langchain/langgraph-sdk/react", () => ({
useStream: () => ({
isLoading: false,
messages: [],
stop: rs.fn(async () => undefined),
submit: mocks.submit,
values: { artifacts: [], messages: [], title: "", todos: [] },
}),
}));
const UPLOAD_RESPONSE: UploadResponse = {
success: true,
files: [
{
filename: "report.pdf",
size: 42,
path: "/data/uploads/report.pdf",
virtual_path: "/mnt/user-data/uploads/report.pdf",
artifact_url: "/api/threads/thread-1/artifacts/report.pdf",
},
],
message: "ok",
skipped_files: [],
};
const MESSAGE_WITH_FILE: PromptInputMessage = {
text: "Compare these",
files: [
{
type: "file",
url: "blob:report",
mediaType: "application/pdf",
filename: "report.pdf",
file: new File(["%PDF"], "report.pdf", { type: "application/pdf" }),
},
],
};
function stubUploadFetch(upload: () => Promise<Response>) {
rs.stubGlobal("fetch", async (input: unknown) => {
const url = String(input);
if (/\/api\/threads\/[^/]+\/uploads$/.test(url)) {
mocks.events.push("upload");
return upload();
}
if (/\/api\/threads\/[^/]+\/messages\/page/.test(url)) {
return new Response(
JSON.stringify({ data: [], has_more: false, next_before_seq: null }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
throw new Error(`Unexpected fetch: ${url}`);
});
}
function createWrapper(queryClient: QueryClient) {
return function SendMessageTestWrapper({
children,
}: {
children: ReactNode;
}) {
return createElement(
QueryClientProvider,
{ client: queryClient },
createElement(
I18nContext.Provider,
{
value: { locale: "en-US", setLocale: () => undefined, t: enUS },
},
children,
),
);
};
}
async function flushFrames() {
for (let index = 0; index < 6; index += 1) {
await act(async () => undefined);
}
}
async function renderThreadStream() {
const { useThreadStream } = await import("@/core/threads/hooks");
const rendered = renderHook(
() =>
useThreadStream({
context: DEFAULT_LOCAL_SETTINGS.context,
isMock: false,
threadId: "thread-1",
}),
{
wrapper: createWrapper(
new QueryClient({ defaultOptions: { queries: { retry: false } } }),
),
},
);
await flushFrames();
return rendered;
}
beforeEach(() => {
mocks.events = [];
mocks.submit.mockReset();
mocks.submit.mockImplementation(async () => {
mocks.events.push("submit");
});
});
afterEach(() => {
rs.unstubAllGlobals();
});
test("does not report a send whose attachment upload fails", async () => {
stubUploadFetch(
async () =>
new Response(JSON.stringify({ detail: "disk full" }), { status: 500 }),
);
const { result } = await renderThreadStream();
const onSent = rs.fn();
let sendError: unknown;
await act(async () => {
await result.current
.sendMessage("thread-1", MESSAGE_WITH_FILE, undefined, { onSent })
.catch((error: unknown) => {
sendError = error;
});
});
expect(sendError).toBeInstanceOf(Error);
expect(mocks.events).toEqual(["upload"]);
expect(onSent).not.toHaveBeenCalled();
});
test("reports the send after its attachments upload, right before the run is submitted", async () => {
let finishUpload!: () => void;
stubUploadFetch(
() =>
new Promise<Response>((resolve) => {
finishUpload = () =>
resolve(
new Response(JSON.stringify(UPLOAD_RESPONSE), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
}),
);
const { result } = await renderThreadStream();
const onSent = rs.fn(() => {
mocks.events.push("onSent");
});
let sendPromise: Promise<unknown> | undefined;
act(() => {
sendPromise = result.current.sendMessage(
"thread-1",
MESSAGE_WITH_FILE,
undefined,
{ onSent },
);
});
await flushFrames();
expect(onSent).not.toHaveBeenCalled();
finishUpload();
await act(async () => {
await sendPromise;
});
expect(mocks.events).toEqual(["upload", "onSent", "submit"]);
expect(onSent).toHaveBeenCalledTimes(1);
});
test("reports a text-only send before the run is submitted", async () => {
stubUploadFetch(async () => {
throw new Error("no upload expected");
});
const { result } = await renderThreadStream();
const onSent = rs.fn(() => {
mocks.events.push("onSent");
});
await act(async () => {
await result.current.sendMessage(
"thread-1",
{ text: "Hello", files: [] },
undefined,
{ onSent },
);
});
expect(mocks.events).toEqual(["onSent", "submit"]);
});