From 4fd2b211ccc97188b26663de9d8fb4b7f212c96d Mon Sep 17 00:00:00 2001 From: N <15332201130@163.com> Date: Thu, 29 Oct 2026 23:23:08 +0800 Subject: [PATCH] fix(artifacts): keep saved revisions ahead of stale reads (#7650) * fix(artifacts): keep saved revisions ahead of stale reads * fix(artifacts): abort cancelled content requests Signed-off-by: Willem Jiang --------- Signed-off-by: Willem Jiang Co-authored-by: Willem Jiang --- README.md | 2 +- frontend/src/AGENTS.md | 3 +- .../artifacts/artifact-file-detail.tsx | 5 + frontend/src/core/artifacts/hooks.ts | 6 +- frontend/src/core/artifacts/loader.ts | 3 + .../artifact-file-save-race.dom.test.tsx | 275 ++++++++++++++++++ .../artifacts/artifact-viewer.dom.test.tsx | 3 + .../content-cancellation.dom.test.tsx | 112 +++++++ .../unit/core/artifacts/hooks.dom.test.tsx | 3 + 9 files changed, 408 insertions(+), 4 deletions(-) create mode 100644 frontend/tests/unit/components/workspace/artifacts/artifact-file-save-race.dom.test.tsx create mode 100644 frontend/tests/unit/core/artifacts/content-cancellation.dom.test.tsx diff --git a/README.md b/README.md index 66cab51a9d..40cbab8363 100644 --- a/README.md +++ b/README.md @@ -3053,7 +3053,7 @@ recording the others. A concurrent scratch-file removal therefore does not discard the whole snapshot or suppress detection of other produced outputs. This remains a best-effort scan, not an atomic filesystem snapshot. -Files presented through `present_files` remain part of the thread's artifact state, and the Web UI restores the artifact panel and selected document after a page refresh. Long artifact lists scroll within the panel on desktop and in the mobile drawer, keeping every file reachable. When a completed response successfully presents between 2 and 50 files, its final file card also offers one ZIP download. Archive membership comes from the terminal delivery receipt rather than browser-supplied paths, and the ZIP contains the current file versions, which may have changed since the response. The currently selected formal artifact is refreshed once when the run finishes so edits become visible without a manual reload. Existing UTF-8 text artifacts under `/mnt/user-data/outputs` can also be edited and explicitly saved from the panel on Unix and Windows while the thread is idle; saves use content revisions to prevent overwriting agent changes. Source previews also recognize extensionless `Dockerfile` and `Makefile` artifacts by their file names. Unknown file types, including names such as `constructor` and `__proto__`, retain the download fallback. +Files presented through `present_files` remain part of the thread's artifact state, and the Web UI restores the artifact panel and selected document after a page refresh. Long artifact lists scroll within the panel on desktop and in the mobile drawer, keeping every file reachable. When a completed response successfully presents between 2 and 50 files, its final file card also offers one ZIP download. Archive membership comes from the terminal delivery receipt rather than browser-supplied paths, and the ZIP contains the current file versions, which may have changed since the response. The currently selected formal artifact is refreshed once when the run finishes so edits become visible without a manual reload. Existing UTF-8 text artifacts under `/mnt/user-data/outputs` can also be edited and explicitly saved from the panel on Unix and Windows while the thread is idle; saves use content revisions to prevent overwriting agent changes. A delayed background read cannot replace a successfully saved revision in the editor; cancelled preview and full-file reads also abort their HTTP requests. Source previews also recognize extensionless `Dockerfile` and `Makefile` artifacts by their file names. Unknown file types, including names such as `constructor` and `__proto__`, retain the download fallback. Unsaved artifact edits survive switching chats, workspace pages, or visiting the home page and returning in the same browser tab. Drafts stay in memory for the diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md index 9a198a3a63..143e29eee4 100644 --- a/frontend/src/AGENTS.md +++ b/frontend/src/AGENTS.md @@ -29,7 +29,8 @@ Tests: `html-assets.dom.test.ts`, `artifact-file-preview.dom.test.tsx`, 2. Stream events update thread state (messages, artifacts, todos, goal). The main thread stream uses the LangGraph SDK's `throttle: true` mode so updates received in the same macrotask coalesce before React is notified; do not replace it with a numeric delay without validating the SDK's trailing-debounce behavior on a continuous stream. File-tool artifact auto-open work must run in an effect with timer cleanup; never schedule timers while rendering streamed `write_file` or `str_replace` updates. `ThreadState.artifacts` remains the authoritative artifact list. The artifacts provider persists only thread-scoped panel UI state (`open`, selected path, and a refresh bootstrap cache) in session storage; an initial empty stream value must not overwrite that restored state before history finishes loading. - Formal artifact content is refreshed once when the run finishes; transient `write-file:` previews remain message-driven. + Refetch formal artifacts when runs finish; `write-file:` previews stay message-driven. + On save, cancel reads before cache/draft writes; pass both hooks' signals via loader to fetch. UTF-8 outputs under `/mnt/user-data/outputs` are editable while idle. Root drafts survive SPA/home/showcase routes and unknown auth; confirmed account change/logout clears them. Owner/generation fences reject stale updates. Auth binds through a stable setter context, outside the typing state subscription. Drafts preview in Markdown/HTML and warn on unload off-route. SHA-256 conflicts preserve edits. Layout, draft-context and editor DOM tests cover this. Regular artifact text loads request at most the first 1 MiB through an HTTP byte range. A truncated preview must stay lightweight and expose an explicit diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index cbf7af2ae2..977479a541 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -330,6 +330,11 @@ export function ArtifactFileDetail({ content: submittedDraftContent, expectedSha256: submittedBaselineSha256, }); + // A refetch started before or during the save can still return old bytes. + // Cancel both preview and full reads before publishing the saved revision. + await queryClient.cancelQueries({ + queryKey: ["artifact", filepathFromProps, threadId, isMock], + }); setDrafts((current) => ({ ...current, [filepath]: (() => { diff --git a/frontend/src/core/artifacts/hooks.ts b/frontend/src/core/artifacts/hooks.ts index 1b1ae68ca1..8b384c27ea 100644 --- a/frontend/src/core/artifacts/hooks.ts +++ b/frontend/src/core/artifacts/hooks.ts @@ -34,12 +34,13 @@ export function useArtifactContent({ const { data, isLoading, error, refetch } = useQuery({ queryKey: ["artifact", filepath, threadId, isMock, fullContentRequested], - queryFn: () => { + queryFn: ({ signal }) => { return loadArtifactContent({ filepath, threadId, isMock, full: fullContentRequested, + signal, }); }, enabled, @@ -103,12 +104,13 @@ export function useStandaloneArtifactContent({ const { data, isLoading, error } = useQuery({ queryKey: ["artifact", filepath, threadId, isMock, fullContentRequested], - queryFn: () => + queryFn: ({ signal }) => loadArtifactContent({ filepath, threadId, isMock, full: fullContentRequested, + signal, }), staleTime: 0, refetchOnWindowFocus: true, diff --git a/frontend/src/core/artifacts/loader.ts b/frontend/src/core/artifacts/loader.ts index 6192ce92f0..54c41e2a20 100644 --- a/frontend/src/core/artifacts/loader.ts +++ b/frontend/src/core/artifacts/loader.ts @@ -52,11 +52,13 @@ export async function loadArtifactContent({ threadId, isMock, full = false, + signal, }: { filepath: string; threadId: string; isMock?: boolean; full?: boolean; + signal?: AbortSignal; }) { let enhancedFilepath = filepath; if (filepath.endsWith(".skill")) { @@ -65,6 +67,7 @@ export async function loadArtifactContent({ const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock }); const response = await fetch(url, { cache: "no-store", + signal, headers: full ? undefined : { Range: `bytes=0-${ARTIFACT_PREVIEW_MAX_BYTES - 1}` }, diff --git a/frontend/tests/unit/components/workspace/artifacts/artifact-file-save-race.dom.test.tsx b/frontend/tests/unit/components/workspace/artifacts/artifact-file-save-race.dom.test.tsx new file mode 100644 index 0000000000..974a8027cf --- /dev/null +++ b/frontend/tests/unit/components/workspace/artifacts/artifact-file-save-race.dom.test.tsx @@ -0,0 +1,275 @@ +import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import type { PropsWithChildren } from "react"; +import { toast } from "sonner"; + +rs.mock("@/components/workspace/messages/context", () => ({ + useThread: () => ({ + thread: { isLoading: false, messages: [] }, + isMock: false, + }), +})); +rs.mock("@/components/ui/sidebar", () => ({ + useSidebar: () => ({ setOpen: rs.fn() }), +})); +rs.mock("next/navigation", () => ({ + usePathname: () => "/workspace/chats/artifact-save-race", +})); +rs.mock("@/core/auth/AuthProvider", () => ({ + useAuth: () => ({ user: null }), +})); +rs.mock("@/core/config", () => ({ + getBackendBaseURL: () => "/backend", +})); +rs.mock("@/env", () => ({ + env: { NEXT_PUBLIC_STATIC_WEBSITE_ONLY: "false" }, +})); +rs.mock("@/core/api/fetcher", () => ({ fetch: rs.fn() })); +rs.mock("sonner", () => ({ toast: { success: rs.fn(), error: rs.fn() } })); + +// Keep the production content hook, query client and draft providers. Only the +// editor surface is simplified so the test can exercise actual edit/save events. +rs.mock("@/components/workspace/code-editor", () => ({ + CodeEditor: ({ + value, + readonly, + disabled, + onChange, + }: { + value: string; + readonly?: boolean; + disabled?: boolean; + onChange?: (value: string) => void; + }) => ( +