qin-chenghan 8234370a6a
feat(artifacts): inline editing for text artifacts in the panel (#4596)
* feat(artifacts): inline editing for text artifacts in the panel

Add a PUT /api/threads/{id}/artifacts/{path} endpoint that atomically
replaces an existing UTF-8 text file under /mnt/user-data/outputs after
verifying its SHA-256 revision. Active runs conflict (409); binary,
symlink, oversized, and non-output paths are rejected.

Frontend: edit/save/discard buttons, draft state with conflict detection,
CodeEditor onChange/onSave, loader SHA-256 from ETag, i18n, beforeunload guard.

Backend: PUT endpoint with thread reservation, atomic temp-file replacement,
sandbox sync for non-mounted providers, rollback on failure, ETag on GET.

Tests: 8 backend + 1 blocking-IO + 3 frontend test files.

* fix(artifacts): scope replacement permissions and release sandboxes

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-08-01 09:15:21 +08:00

61 lines
1.8 KiB
TypeScript

import { afterEach, describe, expect, it, rs } from "@rstest/core";
import { updateArtifactContent } from "@/core/artifacts/api";
afterEach(() => {
rs.restoreAllMocks();
});
describe("updateArtifactContent", () => {
it("sends the draft and expected revision to the opened artifact URL", async () => {
const fetchMock = rs.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
path: "/mnt/user-data/outputs/report.md",
sha256: "b".repeat(64),
size: 7,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
await updateArtifactContent({
threadId: "thread-1",
filepath: "/mnt/user-data/outputs/report.md",
content: "updated",
expectedSha256: "a".repeat(64),
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0]!;
expect(typeof url).toBe("string");
expect(url as string).toContain(
"/api/threads/thread-1/artifacts/mnt/user-data/outputs/report.md",
);
expect(init?.method).toBe("PUT");
expect(typeof init?.body).toBe("string");
expect(JSON.parse(init?.body as string)).toEqual({
content: "updated",
expected_sha256: "a".repeat(64),
});
});
it("preserves the response status for conflict handling", async () => {
rs.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ detail: "Artifact changed" }), {
status: 412,
headers: { "Content-Type": "application/json" },
}),
);
await expect(
updateArtifactContent({
threadId: "thread-1",
filepath: "/mnt/user-data/outputs/report.md",
content: "updated",
expectedSha256: "a".repeat(64),
}),
).rejects.toMatchObject({ status: 412 });
});
});