mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-05 01:50:36 +00:00
* 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>
421 lines
14 KiB
TypeScript
421 lines
14 KiB
TypeScript
import { afterEach, describe, expect, it, rs } from "@rstest/core";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import {
|
|
cleanup,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
} from "@testing-library/react";
|
|
import type { ReactNode } from "react";
|
|
|
|
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
|
|
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
|
import {
|
|
InputBox,
|
|
type InputBoxSubmitOptions,
|
|
} from "@/components/workspace/input-box";
|
|
import { ThreadContext } from "@/components/workspace/messages/context";
|
|
import { AuthProvider } from "@/core/auth/AuthProvider";
|
|
import { DEFAULT_LOCALE } from "@/core/i18n";
|
|
import { I18nProvider } from "@/core/i18n/context";
|
|
import {
|
|
readProjectAttachments,
|
|
stageProjectAttachment,
|
|
} from "@/core/projects/composer-attach";
|
|
import type { AttachProjectDocumentResult } from "@/core/projects/types";
|
|
import {
|
|
buildComposerDraftKey,
|
|
getSessionComposerDraftStorage,
|
|
readComposerDraft,
|
|
writeComposerDraft,
|
|
} from "@/core/threads/composer-draft";
|
|
|
|
rs.mock("next/navigation", () => ({
|
|
useRouter: () => ({ push: rs.fn(), replace: rs.fn(), refresh: rs.fn() }),
|
|
usePathname: () => "/workspace",
|
|
useSearchParams: () => new URLSearchParams(),
|
|
}));
|
|
|
|
// The composer's model selector is irrelevant to submit gating; keep the
|
|
// react-query + network machinery out of the way entirely.
|
|
rs.mock("@/core/models/hooks", () => ({
|
|
useModels: () => ({
|
|
models: [],
|
|
tokenUsageEnabled: false,
|
|
isLoading: false,
|
|
isFetching: false,
|
|
error: null,
|
|
refetch: rs.fn(),
|
|
}),
|
|
}));
|
|
|
|
const ATTACHMENT: AttachProjectDocumentResult = {
|
|
filename: "roadmap.md",
|
|
size_bytes: 2048,
|
|
virtual_path: "/mnt/user-data/uploads/roadmap.md",
|
|
artifact_url:
|
|
"/api/threads/thread-1/artifacts/mnt/user-data/uploads/roadmap.md",
|
|
};
|
|
|
|
const STAGED_FILE = {
|
|
filename: "roadmap.md",
|
|
size: 2048,
|
|
path: "/mnt/user-data/uploads/roadmap.md",
|
|
status: "uploaded",
|
|
};
|
|
|
|
const OTHER_ATTACHMENT: AttachProjectDocumentResult = {
|
|
filename: "notes.txt",
|
|
size_bytes: 128,
|
|
virtual_path: "/mnt/user-data/uploads/notes.txt",
|
|
artifact_url:
|
|
"/api/threads/thread-1/artifacts/mnt/user-data/uploads/notes.txt",
|
|
};
|
|
|
|
const OTHER_STAGED_FILE = {
|
|
filename: "notes.txt",
|
|
size: 128,
|
|
path: "/mnt/user-data/uploads/notes.txt",
|
|
status: "uploaded",
|
|
};
|
|
|
|
type SubmitSpy = ReturnType<typeof rs.fn>;
|
|
|
|
function renderComposer({ onSubmit }: { onSubmit: SubmitSpy }) {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
const tree = (onSubmitProp: SubmitSpy, threadId = "thread-1"): ReactNode => (
|
|
<I18nProvider initialLocale={DEFAULT_LOCALE}>
|
|
<QueryClientProvider client={queryClient}>
|
|
<AuthProvider
|
|
initialUser={{
|
|
id: "user-1",
|
|
email: "user@example.test",
|
|
system_role: "user",
|
|
needs_setup: false,
|
|
oauth_provider: null,
|
|
}}
|
|
>
|
|
<ThreadContext.Provider
|
|
value={{ thread: { messages: [] } as never, isMock: true }}
|
|
>
|
|
<PromptInputProvider>
|
|
<InputBox
|
|
threadId={threadId}
|
|
status="ready"
|
|
context={{ mode: "flash" } as never}
|
|
onSubmit={onSubmitProp}
|
|
/>
|
|
</PromptInputProvider>
|
|
</ThreadContext.Provider>
|
|
</AuthProvider>
|
|
</QueryClientProvider>
|
|
</I18nProvider>
|
|
);
|
|
const rendered = render(tree(onSubmit));
|
|
return {
|
|
...rendered,
|
|
switchThread: (threadId: string) =>
|
|
rendered.rerender(tree(onSubmit, threadId)),
|
|
};
|
|
}
|
|
|
|
function getSubmitButton(container: HTMLElement): HTMLButtonElement {
|
|
const button = container.querySelector('button[type="submit"]');
|
|
if (!(button instanceof HTMLButtonElement)) {
|
|
throw new Error("submit button not rendered");
|
|
}
|
|
return button;
|
|
}
|
|
|
|
function lastSubmit(onSubmit: SubmitSpy): {
|
|
message: PromptInputMessage;
|
|
options: InputBoxSubmitOptions | undefined;
|
|
} {
|
|
const [message, options] = onSubmit.mock.calls.at(-1) as [
|
|
PromptInputMessage,
|
|
InputBoxSubmitOptions?,
|
|
];
|
|
return { message, options };
|
|
}
|
|
|
|
afterEach(() => {
|
|
rs.restoreAllMocks();
|
|
cleanup();
|
|
window.sessionStorage.clear();
|
|
});
|
|
|
|
describe("InputBox staged project attachments", () => {
|
|
it("submits an attachment-only message instead of reading it as empty", async () => {
|
|
stageProjectAttachment("thread-1", ATTACHMENT);
|
|
const onSubmit = rs.fn();
|
|
const { container } = renderComposer({ onSubmit });
|
|
|
|
// The staged chip is applied from session storage on mount.
|
|
await screen.findByTestId("project-attachment-chip");
|
|
fireEvent.click(getSubmitButton(container));
|
|
|
|
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
|
const { message, options } = lastSubmit(onSubmit);
|
|
expect(message.text).toBe("");
|
|
expect(options?.additionalKwargs?.files).toEqual([STAGED_FILE]);
|
|
});
|
|
|
|
it("does not intercept /goal while an attach chip is present", async () => {
|
|
stageProjectAttachment("thread-1", ATTACHMENT);
|
|
const onSubmit = rs.fn();
|
|
const { container } = renderComposer({ onSubmit });
|
|
|
|
await screen.findByTestId("project-attachment-chip");
|
|
fireEvent.change(screen.getByRole("textbox"), {
|
|
target: { value: "/goal check this" },
|
|
});
|
|
fireEvent.click(getSubmitButton(container));
|
|
|
|
// The line goes out as an ordinary message WITH the attachment — the
|
|
// goal interception would have stripped the "/goal " prefix and dropped
|
|
// the staged files.
|
|
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
|
const { message, options } = lastSubmit(onSubmit);
|
|
expect(message.text).toBe("/goal check this");
|
|
expect(options?.additionalKwargs?.files).toEqual([STAGED_FILE]);
|
|
});
|
|
|
|
it("keeps plain text-only and truly-empty submit behavior unchanged", async () => {
|
|
const onSubmit = rs.fn();
|
|
const { container } = renderComposer({ onSubmit });
|
|
|
|
// No staged attachment, empty text: nothing is submitted.
|
|
fireEvent.click(getSubmitButton(container));
|
|
await waitFor(() =>
|
|
expect(screen.queryByTestId("project-attachment-chip")).toBeNull(),
|
|
);
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
|
|
// Plain text submits with no staged files attached.
|
|
fireEvent.change(screen.getByRole("textbox"), {
|
|
target: { value: "hello" },
|
|
});
|
|
fireEvent.click(getSubmitButton(container));
|
|
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
|
const { message, options } = lastSubmit(onSubmit);
|
|
expect(message.text).toBe("hello");
|
|
expect(options?.additionalKwargs).toBeUndefined();
|
|
});
|
|
|
|
it("submits every pending shelf attachment when several were staged across a navigation", async () => {
|
|
// Attaching the first document and navigating away unmounts the
|
|
// composer; the project page appends the second one to the same pending
|
|
// list. Both must reach the next message.
|
|
stageProjectAttachment("thread-1", ATTACHMENT);
|
|
stageProjectAttachment("thread-1", OTHER_ATTACHMENT);
|
|
const onSubmit = rs.fn();
|
|
const { container } = renderComposer({ onSubmit });
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getAllByTestId("project-attachment-chip")).toHaveLength(2),
|
|
);
|
|
fireEvent.click(getSubmitButton(container));
|
|
|
|
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
|
const { options } = lastSubmit(onSubmit);
|
|
expect(options?.additionalKwargs?.files).toEqual([
|
|
STAGED_FILE,
|
|
OTHER_STAGED_FILE,
|
|
]);
|
|
});
|
|
|
|
it("keeps a removed chip removed across a remount", async () => {
|
|
stageProjectAttachment("thread-1", ATTACHMENT);
|
|
stageProjectAttachment("thread-1", OTHER_ATTACHMENT);
|
|
const onSubmit = rs.fn();
|
|
const first = renderComposer({ onSubmit });
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getAllByTestId("project-attachment-chip")).toHaveLength(2),
|
|
);
|
|
const [firstRemoveButton] = screen.getAllByLabelText(
|
|
"Remove attached document",
|
|
);
|
|
if (!firstRemoveButton) {
|
|
throw new Error("remove button not rendered");
|
|
}
|
|
fireEvent.click(firstRemoveButton);
|
|
await waitFor(() =>
|
|
expect(screen.getAllByTestId("project-attachment-chip")).toHaveLength(1),
|
|
);
|
|
|
|
first.unmount();
|
|
const second = renderComposer({ onSubmit });
|
|
await waitFor(() =>
|
|
expect(screen.getAllByTestId("project-attachment-chip")).toHaveLength(1),
|
|
);
|
|
expect(screen.getByText(OTHER_ATTACHMENT.filename)).toBeTruthy();
|
|
second.unmount();
|
|
});
|
|
|
|
it("resets the staged count after a sent message clears the chip", async () => {
|
|
stageProjectAttachment("thread-1", ATTACHMENT);
|
|
const onSubmit: SubmitSpy = rs.fn(
|
|
(_message: unknown, options?: InputBoxSubmitOptions) => {
|
|
options?.onSent?.();
|
|
},
|
|
);
|
|
const { container } = renderComposer({ onSubmit });
|
|
|
|
await screen.findByTestId("project-attachment-chip");
|
|
fireEvent.click(getSubmitButton(container));
|
|
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
|
|
|
// onSent cleared the staged attachments; a second empty submit is a
|
|
// no-op again.
|
|
await waitFor(() =>
|
|
expect(screen.queryByTestId("project-attachment-chip")).toBeNull(),
|
|
);
|
|
fireEvent.click(getSubmitButton(container));
|
|
await waitFor(() =>
|
|
expect(screen.queryByTestId("project-attachment-chip")).toBeNull(),
|
|
);
|
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("retires only the sending thread's state when its upload finishes after a thread switch", async () => {
|
|
stageProjectAttachment("thread-1", ATTACHMENT);
|
|
stageProjectAttachment("thread-2", OTHER_ATTACHMENT);
|
|
let dispatched!: () => void;
|
|
const onSubmit: SubmitSpy = rs.fn(
|
|
(_message: unknown, options?: InputBoxSubmitOptions) => {
|
|
// Keep the send open, as an attachment upload does, and report it
|
|
// dispatched only later.
|
|
dispatched = () => options?.onSent?.();
|
|
return new Promise<void>(() => undefined);
|
|
},
|
|
);
|
|
const { container, switchThread } = renderComposer({ onSubmit });
|
|
await screen.findByTestId("project-attachment-chip");
|
|
fireEvent.change(screen.getByRole("textbox"), {
|
|
target: { value: "summarize it" },
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
const sendingDraftKey = buildComposerDraftKey({
|
|
userId: "user-1",
|
|
threadId: "thread-1",
|
|
});
|
|
const storage = window.sessionStorage;
|
|
expect(readComposerDraft(storage, sendingDraftKey)?.text).toBe(
|
|
"summarize it",
|
|
);
|
|
fireEvent.click(getSubmitButton(container));
|
|
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
|
|
|
switchThread("thread-2");
|
|
await screen.findByText(OTHER_ATTACHMENT.filename);
|
|
dispatched();
|
|
|
|
// The composer now shows thread-2: its staged chip survives, while the
|
|
// sent thread's persisted draft and staged attachment are retired.
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
expect(screen.getAllByTestId("project-attachment-chip")).toHaveLength(1);
|
|
expect(screen.getByText(OTHER_ATTACHMENT.filename)).toBeTruthy();
|
|
expect(readProjectAttachments("thread-2")).toEqual([OTHER_ATTACHMENT]);
|
|
expect(readProjectAttachments("thread-1")).toEqual([]);
|
|
expect(readComposerDraft(storage, sendingDraftKey)).toBeNull();
|
|
});
|
|
|
|
it("keeps a replacement draft saved under the same key after an unmounted composer's send is dispatched", async () => {
|
|
let dispatched!: () => void;
|
|
const onSubmit: SubmitSpy = rs.fn(
|
|
(_message: unknown, options?: InputBoxSubmitOptions) => {
|
|
dispatched = () => options?.onSent?.();
|
|
return new Promise<void>(() => undefined);
|
|
},
|
|
);
|
|
const { container, unmount } = renderComposer({ onSubmit });
|
|
fireEvent.change(screen.getByRole("textbox"), {
|
|
target: { value: "first question" },
|
|
});
|
|
fireEvent.click(getSubmitButton(container));
|
|
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
|
|
|
// Leaving mid-upload persists the pending draft so a failed send could
|
|
// be retried; a later composer for the same key then saves its own.
|
|
unmount();
|
|
const draftKey = buildComposerDraftKey({
|
|
userId: "user-1",
|
|
threadId: "thread-1",
|
|
});
|
|
const storage = getSessionComposerDraftStorage();
|
|
expect(readComposerDraft(storage, draftKey)?.text).toBe("first question");
|
|
writeComposerDraft(storage, draftKey, {
|
|
text: "a different question",
|
|
skillName: null,
|
|
});
|
|
dispatched();
|
|
|
|
expect(readComposerDraft(storage, draftKey)?.text).toBe(
|
|
"a different question",
|
|
);
|
|
});
|
|
|
|
it("retires an unmounted composer's sent draft once its send is dispatched", async () => {
|
|
let dispatched!: () => void;
|
|
const onSubmit: SubmitSpy = rs.fn(
|
|
(_message: unknown, options?: InputBoxSubmitOptions) => {
|
|
dispatched = () => options?.onSent?.();
|
|
return new Promise<void>(() => undefined);
|
|
},
|
|
);
|
|
const { container, unmount } = renderComposer({ onSubmit });
|
|
fireEvent.change(screen.getByRole("textbox"), {
|
|
target: { value: "first question" },
|
|
});
|
|
fireEvent.click(getSubmitButton(container));
|
|
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
|
|
|
unmount();
|
|
const draftKey = buildComposerDraftKey({
|
|
userId: "user-1",
|
|
threadId: "thread-1",
|
|
});
|
|
const storage = getSessionComposerDraftStorage();
|
|
expect(readComposerDraft(storage, draftKey)?.text).toBe("first question");
|
|
dispatched();
|
|
|
|
expect(readComposerDraft(storage, draftKey)).toBeNull();
|
|
});
|
|
|
|
it.each(["switches threads", "unmounts"] as const)(
|
|
"keeps a document staged during the upload after the composer %s",
|
|
async (leave) => {
|
|
stageProjectAttachment("thread-1", ATTACHMENT);
|
|
let dispatched!: () => void;
|
|
const onSubmit: SubmitSpy = rs.fn(
|
|
(_message: unknown, options?: InputBoxSubmitOptions) => {
|
|
dispatched = () => options?.onSent?.();
|
|
return new Promise<void>(() => undefined);
|
|
},
|
|
);
|
|
const { container, switchThread, unmount } = renderComposer({
|
|
onSubmit,
|
|
});
|
|
await screen.findByTestId("project-attachment-chip");
|
|
fireEvent.click(getSubmitButton(container));
|
|
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
|
|
|
if (leave === "unmounts") {
|
|
unmount();
|
|
} else {
|
|
switchThread("thread-2");
|
|
}
|
|
// Attached from the project page while the first send still uploads;
|
|
// that send cannot carry it, so the next message must.
|
|
stageProjectAttachment("thread-1", OTHER_ATTACHMENT);
|
|
dispatched();
|
|
|
|
expect(readProjectAttachments("thread-1")).toEqual([OTHER_ATTACHMENT]);
|
|
},
|
|
);
|
|
});
|