mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-28 06:00:38 +00:00
fix(artifacts): parse write-file URLs through the guarded helper (#7609)
`ArtifactFileDetail` and `loadArtifactContentFromToolCall` re-parsed `write-file:` URLs with `new URL` + `decodeURIComponent` after `parseWriteFileArtifact` had already rejected malformed values. The panel restores its selection from session storage and only type-checks it as a string, so a stale or hand-edited `write-file:%E0%A4%A` (or `write-file://[`) threw `URIError`/`TypeError` during render. Export the guarded parser and read `path` / `tool_call_id` from one parsed result in both places; a rejected URL degrades to the raw string or `undefined` instead of taking the panel down.
This commit is contained in:
parent
54a09c467e
commit
f0ba280365
@ -47,6 +47,7 @@ import { useArtifactContent } from "@/core/artifacts/hooks";
|
||||
import {
|
||||
getArtifactViewState,
|
||||
getTabularDelimiter,
|
||||
parseWriteFileArtifact,
|
||||
} from "@/core/artifacts/preview";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import {
|
||||
@ -106,13 +107,15 @@ export function ArtifactFileDetail({
|
||||
const isWriteFile = useMemo(() => {
|
||||
return filepathFromProps.startsWith("write-file:");
|
||||
}, [filepathFromProps]);
|
||||
const filepath = useMemo(() => {
|
||||
if (isWriteFile) {
|
||||
const url = new URL(filepathFromProps);
|
||||
return decodeURIComponent(url.pathname);
|
||||
}
|
||||
return filepathFromProps;
|
||||
}, [filepathFromProps, isWriteFile]);
|
||||
// `write-file:` URLs come from messages and from the panel's persisted
|
||||
// selection, so parse them through the guarded helper instead of `new URL`
|
||||
// + `decodeURIComponent` directly: a malformed value must degrade to the
|
||||
// raw string, not throw during render.
|
||||
const parsedWriteFile = useMemo(
|
||||
() => (isWriteFile ? parseWriteFileArtifact(filepathFromProps) : undefined),
|
||||
[filepathFromProps, isWriteFile],
|
||||
);
|
||||
const filepath = parsedWriteFile?.path ?? filepathFromProps;
|
||||
// Keep these local because ChatBox replaces context artifacts with thread state.
|
||||
const [openedPresentedFilepaths, setOpenedPresentedFilepaths] = useState<
|
||||
string[]
|
||||
@ -178,11 +181,7 @@ export function ArtifactFileDetail({
|
||||
const isSupportPreview =
|
||||
language === "html" || language === "markdown" || isTabular;
|
||||
const toolResult = (() => {
|
||||
if (!isWriteFile) {
|
||||
return undefined;
|
||||
}
|
||||
const url = new URL(filepathFromProps);
|
||||
const toolCallId = url.searchParams.get("tool_call_id");
|
||||
const toolCallId = parsedWriteFile?.toolCallId;
|
||||
if (!toolCallId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import { fetch } from "@/core/api/fetcher";
|
||||
|
||||
import type { AgentThreadState } from "../threads";
|
||||
|
||||
import { buildWriteFileDraftContent } from "./preview";
|
||||
import { buildWriteFileDraftContent, parseWriteFileArtifact } from "./preview";
|
||||
import { urlOfArtifact } from "./utils";
|
||||
|
||||
function fnv1aHash(value: string): string {
|
||||
@ -122,6 +122,11 @@ export function loadArtifactContentFromToolCall({
|
||||
url: string;
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
}) {
|
||||
const target = parseWriteFileArtifact(urlString);
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const draftContent = buildWriteFileDraftContent({
|
||||
filepath: urlString,
|
||||
messages: thread.messages,
|
||||
@ -130,9 +135,7 @@ export function loadArtifactContentFromToolCall({
|
||||
return draftContent;
|
||||
}
|
||||
|
||||
const url = new URL(urlString);
|
||||
const toolCallId = url.searchParams.get("tool_call_id");
|
||||
const messageId = url.searchParams.get("message_id");
|
||||
const { messageId, toolCallId } = target;
|
||||
if (messageId && toolCallId) {
|
||||
const message = thread.messages.find((message) => message.id === messageId);
|
||||
if (message?.type === "ai" && message.tool_calls) {
|
||||
|
||||
@ -68,7 +68,7 @@ function findToolResult(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseWriteFileArtifact(filepath: string) {
|
||||
export function parseWriteFileArtifact(filepath: string) {
|
||||
if (!isWriteFileArtifact(filepath)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@ -167,3 +167,12 @@ describe("ArtifactFileDetail browser-preview iframe", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ArtifactFileDetail malformed write-file URLs", () => {
|
||||
it.each(["write-file:%E0%A4%A", "write-file://["])(
|
||||
"renders the raw value without throwing: %s",
|
||||
(filepath) => {
|
||||
expect(() => renderDetail(filepath)).not.toThrow();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, rs } from "@rstest/core";
|
||||
import {
|
||||
ARTIFACT_PREVIEW_MAX_BYTES,
|
||||
loadArtifactContent,
|
||||
loadArtifactContentFromToolCall,
|
||||
} from "@/core/artifacts/loader";
|
||||
|
||||
describe("loadArtifactContent", () => {
|
||||
@ -190,3 +191,46 @@ describe("loadArtifactContent", () => {
|
||||
expect(loaded.sha256).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadArtifactContentFromToolCall", () => {
|
||||
type ToolCallThread = Parameters<
|
||||
typeof loadArtifactContentFromToolCall
|
||||
>[0]["thread"];
|
||||
|
||||
const thread = {
|
||||
messages: [
|
||||
{
|
||||
type: "ai",
|
||||
id: "ai-1",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call-1",
|
||||
name: "str_replace",
|
||||
args: {
|
||||
path: "/mnt/user-data/outputs/report.md",
|
||||
content: "patched",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as unknown as ToolCallThread;
|
||||
|
||||
it("returns undefined instead of throwing for a malformed write-file URL", () => {
|
||||
expect(
|
||||
loadArtifactContentFromToolCall({ url: "write-file://[", thread }),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
loadArtifactContentFromToolCall({ url: "write-file:%E0%A4%A", thread }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads non-write_file tool output through the parsed coordinates", () => {
|
||||
expect(
|
||||
loadArtifactContentFromToolCall({
|
||||
url: "write-file:/mnt/user-data/outputs/report.md?message_id=ai-1&tool_call_id=call-1",
|
||||
thread,
|
||||
}),
|
||||
).toBe("patched");
|
||||
});
|
||||
});
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
buildWriteFileDraftContent,
|
||||
createHtmlPreviewScrollKey,
|
||||
getArtifactViewState,
|
||||
parseWriteFileArtifact,
|
||||
} from "@/core/artifacts/preview";
|
||||
|
||||
const ARTIFACT_PATH = "/artifact-fixtures/report.html";
|
||||
@ -283,3 +284,22 @@ test("scopes HTML scroll restoration without exposing the artifact path", () =>
|
||||
expect(html).not.toContain("/artifact-fixtures/a");
|
||||
expect(html).not.toContain("<script>alert");
|
||||
});
|
||||
|
||||
test("parses a write-file artifact URL and its tool-call coordinates", () => {
|
||||
expect(
|
||||
parseWriteFileArtifact(
|
||||
`write-file:${ARTIFACT_PATH}?message_id=ai-1&tool_call_id=call-1`,
|
||||
),
|
||||
).toEqual({
|
||||
path: ARTIFACT_PATH,
|
||||
messageId: "ai-1",
|
||||
toolCallId: "call-1",
|
||||
});
|
||||
});
|
||||
|
||||
test.each(["write-file:%E0%A4%A", "write-file://["])(
|
||||
"rejects a malformed write-file URL: %s",
|
||||
(filepath) => {
|
||||
expect(parseWriteFileArtifact(filepath)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user