fix(artifacts): scope full previews to their thread (#4634)

This commit is contained in:
Kushida 2026-08-02 05:07:34 +03:00 committed by GitHub
parent c0f1cfef69
commit 7025ccee40
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 112 additions and 4 deletions

View File

@ -268,6 +268,7 @@ This section accumulates work toward the **2.1.0** milestone
### Fixed
- **artifacts:** Keep explicit full-file loading scoped to the source thread, so a same-path artifact in another conversation keeps its 1 MiB preview.
- **sandbox:** `SandboxAuditMiddleware` no longer blocks ordinary command
substitution that only captures output. The rule now judges *position* instead
of matching any `$(`: `x=$(curl url)`, `echo $(curl url)`, an argument, and a

View File

@ -18,8 +18,13 @@ export function useArtifactContent({
return filepath.startsWith("write-file:");
}, [filepath]);
const { thread, isMock } = useThread();
const [fullFilepath, setFullFilepath] = useState<string | null>(null);
const fullContentRequested = fullFilepath === filepath;
const [fullContentSelection, setFullContentSelection] = useState<{
filepath: string;
threadId: string;
} | null>(null);
const fullContentRequested =
fullContentSelection?.filepath === filepath &&
fullContentSelection.threadId === threadId;
const content = useMemo(() => {
if (isWriteFile) {
return loadArtifactContentFromToolCall({ url: filepath, thread });
@ -54,8 +59,8 @@ export function useArtifactContent({
}, [enabled, isWriteFile, refetch, thread.isLoading]);
const loadFullContent = useCallback(() => {
setFullFilepath(filepath);
}, [filepath]);
setFullContentSelection({ filepath, threadId });
}, [filepath, threadId]);
return {
content: isWriteFile ? content : data?.content,

View File

@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import type { PropsWithChildren } from "react";
rs.mock("@/components/workspace/messages/context", () => ({
useThread: rs.fn(),
}));
rs.mock("@/core/artifacts/loader", () => ({
loadArtifactContent: rs.fn(),
loadArtifactContentFromToolCall: rs.fn(),
}));
import { useThread } from "@/components/workspace/messages/context";
import { useArtifactContent } from "@/core/artifacts/hooks";
import { loadArtifactContent } from "@/core/artifacts/loader";
const mockedUseThread = rs.mocked(useThread);
const mockedLoadArtifactContent = rs.mocked(loadArtifactContent);
const filepath = "/mnt/user-data/outputs/report.md";
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return function QueryWrapper({ children }: PropsWithChildren) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
describe("useArtifactContent", () => {
beforeEach(() => {
mockedUseThread.mockReturnValue({
thread: { isLoading: false, messages: [] },
isMock: false,
} as never);
mockedLoadArtifactContent.mockImplementation(async ({ full }) => ({
content: full ? "complete report" : "preview",
url: filepath,
sha256: undefined,
truncated: !full,
previewBytes: full ? 15 : 7,
totalBytes: 15,
}));
});
afterEach(() => {
cleanup();
mockedUseThread.mockReset();
mockedLoadArtifactContent.mockReset();
});
it("keeps a full-content request scoped to its thread", async () => {
const { result, rerender } = renderHook(
({ threadId }: { threadId: string }) =>
useArtifactContent({ filepath, threadId, enabled: true }),
{
initialProps: { threadId: "thread-a" },
wrapper: createWrapper(),
},
);
await waitFor(() => {
expect(mockedLoadArtifactContent).toHaveBeenLastCalledWith({
filepath,
threadId: "thread-a",
isMock: false,
full: false,
});
});
act(() => {
result.current.loadFullContent();
});
await waitFor(() => {
expect(mockedLoadArtifactContent).toHaveBeenLastCalledWith({
filepath,
threadId: "thread-a",
isMock: false,
full: true,
});
});
rerender({ threadId: "thread-b" });
await waitFor(() => {
expect(mockedLoadArtifactContent).toHaveBeenLastCalledWith({
filepath,
threadId: "thread-b",
isMock: false,
full: false,
});
});
});
});