From f0476677de2f5011c86847e00adb41a08862c046 Mon Sep 17 00:00:00 2001 From: Hyeonsang Cho Date: Wed, 7 Oct 2026 18:55:15 +0900 Subject: [PATCH] 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 --- CHANGELOG.md | 10 + CHANGELOG_zh.md | 5 + frontend/src/AGENTS.md | 2 +- .../src/components/workspace/input-box.tsx | 54 ++++- frontend/src/core/projects/composer-attach.ts | 32 ++- frontend/src/core/threads/composer-draft.ts | 17 ++ frontend/src/core/threads/hooks.ts | 20 +- frontend/tests/e2e/chat.spec.ts | 13 +- .../input-box-failed-upload.dom.test.tsx | 205 +++++++++++++++++ .../input-box-project-attachment.dom.test.tsx | 159 ++++++++++++- .../threads/send-message-on-sent.dom.test.tsx | 214 ++++++++++++++++++ 11 files changed, 705 insertions(+), 26 deletions(-) create mode 100644 frontend/tests/unit/components/workspace/input-box-failed-upload.dom.test.tsx create mode 100644 frontend/tests/unit/core/threads/send-message-on-sent.dom.test.tsx 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"]); +});