diff --git a/CHANGELOG.md b/CHANGELOG.md index 1499b2bac5..3240e7539c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -804,6 +804,15 @@ This release closes that milestone with **439 merged pull requests**. ### Fixed +- **frontend:** Retrying a message after its attachment upload fails now keeps + the context that was attached to it. The composer dropped its quotes, + conversation references, staged project files and stored draft as soon as a + send started, before the attachments uploaded, so after a failed upload the + text and files were still there but a retry went out without that context. + That one-time state now clears only once the send is dispatched, after the + upload. A send that finishes uploading after the user has switched + conversations or left the page clears only the stored draft and staged files + it carried, keeping a draft saved or a document attached since. ([#6412]) - **client:** `DeerFlowClient.list_threads(limit)` now limits threads rather than checkpoints. It passed `limit` to a checkpoint scan across every thread, and one turn writes several checkpoints, so a single long conversation filled @@ -9119,4 +9128,5 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#6400]: https://github.com/bytedance/deer-flow/pull/6400 [#6401]: https://github.com/bytedance/deer-flow/pull/6401 [#6407]: https://github.com/bytedance/deer-flow/pull/6407 +[#6412]: https://github.com/bytedance/deer-flow/pull/6412 [#6426]: https://github.com/bytedance/deer-flow/pull/6426 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 8b8f9a2ac8..cc07fa547f 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -694,6 +694,10 @@ ### 修复 +- **前端:** 附件上传失败后重试发送时,现在会保留原先附带的上下文。输入框此前在发送开始时(附件上传之前)就清除了 + 引用、对话引用、已暂存的项目文件和已保存的草稿,因此上传失败后文字和文件虽仍在,重试发送却缺少这些上下文。 + 现在这些一次性状态只在发送真正派发(上传完成)后才清除;若上传完成时用户已切换对话或离开页面,只清除该次发送 + 携带的已保存草稿和暂存文件,保留此后保存的草稿或附加的文档。([#6412]) - **客户端:** `DeerFlowClient.list_threads(limit)` 现在限制的是线程数而不是检查点数。此前 `limit` 被传给跨所有线程的 检查点扫描,而一轮对话会写入多个检查点,因此一个较长的对话就会占满上限:TUI 线程选择器和 `--resume <标题>` 只能看到 最近一两个线程,较早线程的标题无法解析。现在 SQLite 和 Postgres 通过检查点索引列出线程,每个返回的线程只加载首个和最新 @@ -7496,4 +7500,5 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#6400]: https://github.com/bytedance/deer-flow/pull/6400 [#6401]: https://github.com/bytedance/deer-flow/pull/6401 [#6407]: https://github.com/bytedance/deer-flow/pull/6407 +[#6412]: https://github.com/bytedance/deer-flow/pull/6412 [#6426]: https://github.com/bytedance/deer-flow/pull/6426 diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md index db050006b0..8e18cd9f37 100644 --- a/frontend/src/AGENTS.md +++ b/frontend/src/AGENTS.md @@ -141,7 +141,7 @@ Run duration is run-scoped UI metadata even though the compatibility field `addi The workspace-change card follows the same rule: it is resolved from `(threadId, runId)` alone, so every AI message of a run would render an identical copy. A run ends in more than one terminal assistant bubble whenever the model emits answer text that never gains a tool call, so `core/messages/workspace-change-anchor.ts` picks the run's last assistant bubble and `MessageListItem` renders the badge only for that anchor (#4555). Any future run-scoped display belongs in the same place — do not hang one off every message. The two anchor helpers deliberately differ in which group types they accept as a run's last position, because an anchor is only useful where the display is actually rendered: run duration is emitted by `MessageList` around every group, so it accepts any type, while the workspace-change card comes from `MessageListItem` and so restricts to `assistant`. Keep a new helper's candidate set matched to its own render site rather than unifying them. -Composer drafts are tab-scoped browser state. `core/threads/composer-draft.ts` stores text with inline reference tokens and conversation metadata in `sessionStorage`, keyed by user, agent, and logical conversation scope. New-chat pages pass the stable scope `"new"` because their runtime `threadId` is a fresh UUID on every reload; established conversations use their real thread ID. New drafts write `skillName: null`; `InputBox` waits for the enabled agent skill scope before converting an older draft selection into an inline `@` reference, and degrades a missing/disabled skill back to editable slash text. The stored draft clears through `SendMessageOptions.onSent` only after the send passes the in-flight guard. Attachments, sidecar quotes, voice state, and polish undo state are not persisted. `PromptInput` clears only when `onSubmit` resolves, so the sidecar's `handleSubmit` rethrows a failed send after its toast, and its first send to a new side chat (queued until the thread reaches the stream hook) settles the submit with that send's outcome. +Composer drafts are tab-scoped browser state. `core/threads/composer-draft.ts` stores text with inline reference tokens and conversation metadata in `sessionStorage`, keyed by user, agent, and logical conversation scope. New-chat pages pass the stable scope `"new"` because their runtime `threadId` is a fresh UUID on every reload; established conversations use their real thread ID. New drafts write `skillName: null`; `InputBox` waits for the enabled agent skill scope before converting an older draft selection into an inline `@` reference, and degrades a missing/disabled skill back to editable slash text. The stored draft clears through `SendMessageOptions.onSent`, which fires only once the send is dispatched: after the in-flight guard and any attachment upload, so a failed upload keeps the draft, quotes, conversation references and staged project files for a retry. If the composer moved to another conversation or unmounted meanwhile, `onSent` leaves live state alone and retires only the stored draft and staged files that send carried, keeping a later draft or attachment. Attachments, sidecar quotes, voice state, and polish undo state are not persisted. Conversation references (`read_conversation`, opt-in on the backend) are attached from the composer. `ReferenceConversationsButton` (`components/workspace/conversation-references/`) renders only while `/api/features` reports `conversation_references.enabled`, opens a picker over the same `useThreads()` list the sidebar uses (the current thread excluded, capped at `max_references`), and shows removable chips in the composer header. On submit the thread IDs ride `InputBoxSubmitOptions.conversationReferences` → `SendMessageOptions.conversationReferences` → run `context.conversation_references`, which the Gateway consumes at admission; the LangGraph SDK drops unknown top-level body fields, so the top-level request field is not reachable from the web UI. `core/conversation-references` also writes display-only `additional_kwargs.conversation_references` (`{thread_id, title, agent_name?}`) on the visible human message so `message-list-item.tsx` can render read-only chips linking to the source — through `pathOfThread`, so custom-agent sources route to `/workspace/agents/{agent}/chats/{id}`; that metadata grants nothing. References are per message: they are not persisted with the draft, clear on send or thread switch, and regenerate/edit of a turn runs without them unless attached again. diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index 7ccdd5a972..81ebd2bb55 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -101,7 +101,10 @@ import { supportsThinking as modelSupportsThinking, } from "@/core/models/reasoning"; import { attachProjectDocument } from "@/core/projects/api"; -import { useStagedProjectAttachments } from "@/core/projects/composer-attach"; +import { + retireProjectAttachments, + useStagedProjectAttachments, +} from "@/core/projects/composer-attach"; import { buildReferenceMessageMetadata, type SidecarContext, @@ -118,6 +121,7 @@ import { getSessionComposerDraftStorage, readComposerDraft, resolveComposerDraft, + retireSentComposerDraft, type ComposerDraft, writeComposerDraft, } from "@/core/threads/composer-draft"; @@ -584,6 +588,10 @@ export function InputBox({ } | null>(null); const draftSaveTimerRef = useRef(null); const draftSaveGenerationRef = useRef(0); + // Bumped whenever the composer stops showing a conversation (switch or + // unmount). A send's onSent can arrive after an attachment upload, when + // the composer that sent it shows another conversation or is gone. + const composerLifetimeRef = useRef(0); const [followups, setFollowups] = useState([]); const { data: suggestionsConfig } = useSuggestionsConfig(); @@ -909,9 +917,10 @@ export function InputBox({ }, [cancelDraftSaveTimer]); const scheduleDraftSave = useCallback( (draft: ComposerDraft, key = draftKey) => { - // An accepted attachment send keeps its text visible until upload finishes. - // Reference cleanup can rerun this effect meanwhile; do not resurrect the - // accepted snapshot. Actual input edits release it, even for identical text. + // An accepted attachment send keeps its text visible until its submit + // resolves. Reference cleanup can rerun this effect meanwhile; do not + // resurrect the accepted snapshot. Actual input edits release it, even + // for identical text. const accepted = acceptedDraftRef.current; if ( accepted?.key === key && @@ -1015,6 +1024,7 @@ export function InputBox({ }, [thread.messages]); useLayoutEffect(() => { + composerLifetimeRef.current += 1; promptHistoryIndexRef.current = null; promptHistoryDraftRef.current = ""; setTextInput(""); @@ -1033,6 +1043,7 @@ export function InputBox({ latestDraftRef.current = null; invalidateDraftSaveTimer(); return () => { + composerLifetimeRef.current += 1; mentionEpoch.current += 1; flushLatestDraft(draftKey); }; @@ -1429,12 +1440,13 @@ export function InputBox({ ), ); } - const activeConversations = reconcileConversationReferences( + const reconciledDraft = reconcileConversationReferences( textInput.value, conversationReferences, conversationCapability, threadId, - ).references; + ); + const activeConversations = reconciledDraft.references; const referenceIds = activeConversations.map( (reference) => reference.threadId, ); @@ -1481,6 +1493,12 @@ export function InputBox({ text: textInput.value, skillName: null, }; + // What this send carries, so a late onSent retires only that. + const sendingLifetime = composerLifetimeRef.current; + const sentDraftTexts = [textInput.value, reconciledDraft.text]; + const sentAttachmentPaths = new Set( + projectAttachments.map((attachment) => attachment.virtual_path), + ); const additionalKwargs = { ...extensionMetadata, ...(skillReferences.length @@ -1506,9 +1524,23 @@ export function InputBox({ ...(referenceIds.length ? { conversationReferences: referenceIds } : {}), - // Clear one-time state only once the send genuinely proceeds. If the - // send is dropped by the in-flight guard, `onSent` never fires. + // Clear one-time state only once the send is genuinely dispatched. + // `onSent` never fires for a dropped send or a failed attachment + // upload, so a retry keeps its quotes, references and staged files. onSent: () => { + if (composerLifetimeRef.current !== sendingLifetime) { + // The upload finished after the composer moved to another + // conversation or unmounted. Leave live state alone and retire + // only what this send persisted, keeping anything a later + // composer saved or staged since. + retireSentComposerDraft( + getSessionComposerDraftStorage(), + draftKey, + sentDraftTexts, + ); + retireProjectAttachments(threadId, projectAttachments); + return; + } setMentionQuery(null); setMentionButtonOpen(false); if (pendingDraftSubmissionRef.current?.key === draftKey) { @@ -1524,7 +1556,11 @@ export function InputBox({ } sidecar?.clearConversationQuotes(quoteIds); setConversationReferences([]); - setProjectAttachments([]); + setProjectAttachments((previous) => + previous.filter( + (attachment) => !sentAttachmentPaths.has(attachment.virtual_path), + ), + ); }, }; const submit = () => onSubmit?.(message, submitOptions); diff --git a/frontend/src/core/projects/composer-attach.ts b/frontend/src/core/projects/composer-attach.ts index 1e0477647d..1fdebcedad 100644 --- a/frontend/src/core/projects/composer-attach.ts +++ b/frontend/src/core/projects/composer-attach.ts @@ -19,9 +19,10 @@ import type { AttachProjectDocumentResult } from "./types"; * between attaches cannot drop the earlier attachment. * - Mounting the composer only *reads* the list — nothing is consumed on * mount, so a reload or a remount before submission keeps every chip. - * - ``setAttachments`` writes every change through, and submission clears - * the list in its ``onSent`` callback, which only fires when the send - * genuinely proceeds. + * - ``setAttachments`` writes every change through, and submission retires + * the entries it sent in its ``onSent`` callback, which only fires once the + * send is dispatched (after any upload), so a failed upload keeps the chip + * and a document staged during the upload is kept for the next message. * * sessionStorage (not module memory) so the pending list also survives a * full page load of the target thread. @@ -106,6 +107,31 @@ export function readProjectAttachments( return []; } +/** + * Drop the given attachments from a thread's pending list, keeping any staged + * since. For a send dispatched after its composer has unmounted or moved on to + * another thread: that send carries only what it captured, so documents + * attached to the thread meanwhile must stay for the next message. + */ +export function retireProjectAttachments( + threadId: string, + sent: readonly AttachProjectDocumentResult[], +): void { + const storage = safeSessionStorage(); + if (!storage) { + return; + } + const sentPaths = new Set(sent.map((attachment) => attachment.virtual_path)); + const remaining = readProjectAttachments(threadId).filter( + (attachment) => !sentPaths.has(attachment.virtual_path), + ); + if (remaining.length === 0) { + storage.removeItem(storageKey(threadId)); + } else { + storage.setItem(storageKey(threadId), JSON.stringify(remaining)); + } +} + /** * The current thread's pending attachments, held by the mounted composer. * diff --git a/frontend/src/core/threads/composer-draft.ts b/frontend/src/core/threads/composer-draft.ts index 4382469cff..f4980ca252 100644 --- a/frontend/src/core/threads/composer-draft.ts +++ b/frontend/src/core/threads/composer-draft.ts @@ -145,6 +145,23 @@ export function clearComposerDraft( } } +/** + * Clear a stored draft only while it still holds the text that was sent. + * A send can be dispatched after its composer is gone, by which time another + * composer may have saved a different draft under the same key. + */ +export function retireSentComposerDraft( + storage: ComposerDraftStorage | null | undefined, + key: string, + sentTexts: readonly string[], +) { + const stored = readComposerDraft(storage, key); + if (stored && (stored.skillName || !sentTexts.includes(stored.text))) { + return; + } + clearComposerDraft(storage, key); +} + export function resolveComposerDraft( draft: ComposerDraft, enabledSkillNames: ReadonlySet, diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 37d90174e1..d744d48d41 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -106,10 +106,14 @@ type SendMessageOptions = { */ conversationReferences?: string[]; /** - * Invoked exactly once when the send passes the in-flight guard and is - * genuinely dispatched. It never fires on the early-return path, so callers - * can safely perform one-time cleanup (e.g. clearing quoted references) - * without losing state when a concurrent send is dropped. + * Invoked exactly once when the send is genuinely dispatched: after the + * in-flight guard and after any attachments upload, right before the run is + * submitted. It never fires for a dropped send or one whose attachments fail + * to prepare or upload, so callers can safely perform one-time cleanup (e.g. + * clearing quoted references) without losing state a retry still needs. + * The guarantee ends at dispatch: a run that fails afterwards does not + * reject `thread.submit` (the SDK reports it through the stream's + * `onError`), so the send resolves and the composer is cleared as a whole. */ onSent?: () => void; }; @@ -2552,10 +2556,6 @@ export function useThreadStream({ } sendInFlightRef.current = true; - // The send has genuinely proceeded past the in-flight guard, so callers - // can now run one-time cleanup that must not fire on the dropped path. - options?.onSent?.(); - const text = message.text.trim(); // Capture the current human message count before showing optimistic @@ -2702,6 +2702,10 @@ export function useThreadStream({ } } + // Attachments are uploaded, so the send is genuinely dispatched and + // callers can drop one-time state. A failed upload rejected above + // without reaching here, leaving that state for a retry. + options?.onSent?.(); await thread.submit( { messages: buildThreadSubmitMessages({ diff --git a/frontend/tests/e2e/chat.spec.ts b/frontend/tests/e2e/chat.spec.ts index 9dce7ea6d0..c6e83eae4f 100644 --- a/frontend/tests/e2e/chat.spec.ts +++ b/frontend/tests/e2e/chat.spec.ts @@ -435,12 +435,14 @@ test.describe("Chat workspace", () => { await textarea.fill("Send this immediately"); await textarea.press("Enter"); + // The attachment is still uploading, so the send is not dispatched yet: + // the draft stays stored, and a reload or a failed upload cannot lose it. await page.waitForTimeout(500); expect( await page.evaluate(() => Object.values(window.sessionStorage).join("\n"), ), - ).not.toContain("Send this immediately"); + ).toContain("Send this immediately"); releaseUpload(); await expect @@ -450,6 +452,15 @@ test.describe("Chat workspace", () => { timeout: 10_000, }); + // Dispatch retired the draft, and nothing scheduled before it may write + // the accepted text back. + await page.waitForTimeout(500); + expect( + await page.evaluate(() => + Object.values(window.sessionStorage).join("\n"), + ), + ).not.toContain("Send this immediately"); + // A new user edit must still save, even when it repeats the accepted text. await page .getByPlaceholder(/how can i assist you/i) diff --git a/frontend/tests/unit/components/workspace/input-box-failed-upload.dom.test.tsx b/frontend/tests/unit/components/workspace/input-box-failed-upload.dom.test.tsx new file mode 100644 index 0000000000..aeb5a7e7d0 --- /dev/null +++ b/frontend/tests/unit/components/workspace/input-box-failed-upload.dom.test.tsx @@ -0,0 +1,205 @@ +import { afterEach, beforeEach, expect, it, rs } from "@rstest/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; + +import { PromptInputProvider } from "@/components/ai-elements/prompt-input"; +import { InputBox } 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 { stageProjectAttachment } from "@/core/projects/composer-attach"; +import type { AttachProjectDocumentResult } from "@/core/projects/types"; +import { DEFAULT_LOCAL_SETTINGS } from "@/core/settings/local"; +import { useThreadStream } from "@/core/threads/hooks"; +import type { UploadResponse } from "@/core/uploads/api"; + +// Drives the real composer through the real sendMessage, as the chat pages +// wire them, so a failed upload is observed where the user sees it. + +const mocks = rs.hoisted(() => ({ submit: rs.fn(async () => undefined) })); + +rs.mock("next/navigation", () => ({ + useRouter: () => ({ push: rs.fn(), replace: rs.fn(), refresh: rs.fn() }), + usePathname: () => "/workspace", + useSearchParams: () => new URLSearchParams(), +})); + +rs.mock("@/core/models/hooks", () => ({ + useModels: () => ({ + models: [], + tokenUsageEnabled: false, + isLoading: false, + isFetching: false, + error: null, + refetch: rs.fn(), + }), +})); + +rs.mock("@langchain/langgraph-sdk/react", () => ({ + useStream: () => ({ + isLoading: false, + messages: [], + stop: rs.fn(async () => undefined), + submit: mocks.submit, + values: { artifacts: [], messages: [], title: "", todos: [] }, + }), +})); + +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 UPLOAD_RESPONSE: UploadResponse = { + success: true, + files: [ + { + filename: "notes.txt", + size: 5, + path: "/data/uploads/notes.txt", + virtual_path: "/mnt/user-data/uploads/notes.txt", + artifact_url: "/api/threads/thread-1/artifacts/notes.txt", + }, + ], + message: "ok", + skipped_files: [], +}; + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function ChatComposer() { + const { sendMessage, isUploading } = useThreadStream({ + context: DEFAULT_LOCAL_SETTINGS.context, + isMock: false, + threadId: "thread-1", + }); + return ( + + sendMessage("thread-1", message, undefined, options) + } + /> + ); +} + +function renderChat() { + return render( + + + + + + + + + + + , + ); +} + +let uploadAttempts = 0; + +beforeEach(() => { + uploadAttempts = 0; + mocks.submit.mockClear(); + // happy-dom's File is not a Node Blob, so the composer's preview URL needs + // a stand-in. + rs.spyOn(URL, "createObjectURL").mockReturnValue("blob:attachment"); + rs.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); + rs.stubGlobal("fetch", async (input: unknown) => { + const url = String(input); + if (/\/api\/threads\/[^/]+\/uploads$/.test(url)) { + uploadAttempts += 1; + return uploadAttempts === 1 + ? json({ detail: "disk full" }, 500) + : json(UPLOAD_RESPONSE); + } + if (/\/runs(\?|$)/.test(url)) { + return json([]); + } + return json({ data: [], has_more: false, next_before_seq: null }); + }); +}); + +afterEach(() => { + cleanup(); + rs.restoreAllMocks(); + rs.unstubAllGlobals(); + window.sessionStorage.clear(); +}); + +it("retries a failed upload with the staged project attachment still attached", async () => { + stageProjectAttachment("thread-1", ATTACHMENT); + const { container } = renderChat(); + await screen.findByTestId("project-attachment-chip"); + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "summarize it" }, + }); + fireEvent.change(document.querySelector('input[type="file"]')!, { + target: { + files: [new File(["notes"], "notes.txt", { type: "text/plain" })], + }, + }); + await screen.findByText("notes.txt"); + + fireEvent.submit(container.querySelector("form")!); + await waitFor(() => expect(uploadAttempts).toBe(1)); + await waitFor(() => + expect(screen.getByRole("textbox").disabled).toBe( + false, + ), + ); + + expect(mocks.submit).not.toHaveBeenCalled(); + expect(screen.getByTestId("project-attachment-chip")).toBeTruthy(); + + fireEvent.submit(container.querySelector("form")!); + await waitFor(() => expect(mocks.submit).toHaveBeenCalledTimes(1)); + const [input] = mocks.submit.mock.calls[0] as unknown as [ + { + messages: Array<{ + content: unknown; + additional_kwargs?: { files?: Array<{ path?: string }> }; + }>; + }, + ]; + const sent = input.messages.at(-1); + expect(sent?.additional_kwargs?.files?.map((file) => file.path)).toEqual([ + ATTACHMENT.virtual_path, + "/mnt/user-data/uploads/notes.txt", + ]); +}); diff --git a/frontend/tests/unit/components/workspace/input-box-project-attachment.dom.test.tsx b/frontend/tests/unit/components/workspace/input-box-project-attachment.dom.test.tsx index 7989f4c251..b753a6657f 100644 --- a/frontend/tests/unit/components/workspace/input-box-project-attachment.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/input-box-project-attachment.dom.test.tsx @@ -19,8 +19,17 @@ 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 { stageProjectAttachment } from "@/core/projects/composer-attach"; +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() }), @@ -77,7 +86,7 @@ function renderComposer({ onSubmit }: { onSubmit: SubmitSpy }) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); - const tree = (onSubmitProp: SubmitSpy): ReactNode => ( + const tree = (onSubmitProp: SubmitSpy, threadId = "thread-1"): ReactNode => ( ); - return render(tree(onSubmit)); + const rendered = render(tree(onSubmit)); + return { + ...rendered, + switchThread: (threadId: string) => + rendered.rerender(tree(onSubmit, threadId)), + }; } function getSubmitButton(container: HTMLElement): HTMLButtonElement { @@ -266,4 +280,141 @@ describe("InputBox staged project attachments", () => { ); 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(() => 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(() => 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(() => 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(() => 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]); + }, + ); }); diff --git a/frontend/tests/unit/core/threads/send-message-on-sent.dom.test.tsx b/frontend/tests/unit/core/threads/send-message-on-sent.dom.test.tsx new file mode 100644 index 0000000000..598b9324b9 --- /dev/null +++ b/frontend/tests/unit/core/threads/send-message-on-sent.dom.test.tsx @@ -0,0 +1,214 @@ +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) { + 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((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 | 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"]); +});