mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-28 00:48:07 +00:00
* fix(workspace-changes): classify a symlink replacing a file distinctly from deleted scan_workspace_roots() skipped every symlinked path entirely (host_file.is_symlink() -> continue), so the path was completely absent from a snapshot instead of being recorded as a metadata-only stub the way binary/large/sensitive-looking files already are. When an agent run replaces a tracked file with a symlink (e.g. rm config.txt && ln -s /some/other/path config.txt), the after-snapshot never contained that path at all, so compare_snapshots()'s _status() saw after_file=None and reported plain "deleted" -- silently hiding that the path is still alive on disk, now as a symlink that can point anywhere on the host, including outside the workspace root. Add a "symlink" classification mirroring the existing binary/large/ sensitive pattern: scan_workspace_roots() now records a symlink as a metadata-only FileSnapshot stub (symlink=True, symlink_target from os.readlink(), lstat'd without ever following the link) instead of omitting it. _status() reports "symlink_created" whenever a symlink newly occupies a path that was not already a symlink (brand new or replacing a prior file), so the security-relevant fact surfaces distinctly instead of collapsing into "deleted". A symlink genuinely removed with nothing replacing it is unchanged: still "deleted". Verified against a real POSIX symlink (WSL; native Windows symlink creation needs elevated privilege) driving the unmodified scan_workspace_roots()/compare_snapshots() functions, and via a patch-file revert/reapply cycle on this same fix to confirm the added regression tests fail before and pass after. * fix(workspace-changes): count symlink_created in the changed-file badge getChangedFileCount summed only created + modified + deleted, so a run whose only change was a symlink replacing a file (reported as the new symlink_created status, not deleted) produced a count of 0 and WorkspaceChangeBadge hid the badge entirely -- the exact scenario this PR targets, with the opposite of the intended result. Add symlink_created to the frontend WorkspaceChangeSummary interface (already emitted by the backend) and include it in the count. The per-file StatusIcon/statusLabel "modified" fallthrough is unaffected and left as a follow-up, per review. Regression test reverts cleanly to reproduce a count of 0 pre-fix. * fix(workspace-changes): rank symlink_created in file sort and complete the type contract sortWorkspaceChanges's statusRank had no entry for the new symlink_created status, so statusRank[left.status] - statusRank[right.status] evaluated to NaN for any comparison involving a symlink-created file, violating Array#sort's ordering contract instead of producing a deterministic order. The frontend WorkspaceChangeStatus and DiffUnavailableReason unions, and the WorkspaceFileChange symlink fields, also stayed narrower than what the backend now emits, so TypeScript's satisfies Record<...> guard on statusRank could not catch the gap. Widen WorkspaceChangeStatus to include "symlink_created" and DiffUnavailableReason to include "symlink", add the matching symlink/symlink_target_before/symlink_target_after fields to WorkspaceFileChange, and give symlink_created a rank alongside modified in statusRank -- restoring the satisfies guard's ability to catch a future unranked status. unavailableLabel now has an explicit "symlink" branch (new symlinkUnavailable i18n string, en-US + zh-CN) instead of falling through to the generic label. Also fixes the failing e2e-tests CI check: the existing workspace-changes.spec.ts mock summary predates the symlink_created field, so getChangedFileCount computed 1 + 1 + 0 + undefined = NaN and the badge rendered "Edited NaN files" instead of "Edited 2 files". Added symlink_created: 0 to the mock to match the real backend contract. New sortWorkspaceChanges unit tests revert cleanly against the unranked statusRank to reproduce the NaN-driven misordering. pnpm test (626 tests), pnpm check, and pnpm format are clean, and the previously-failing e2e spec plus the full e2e suite (94 tests) pass.
125 lines
4.3 KiB
TypeScript
125 lines
4.3 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
import { mockLangGraphAPI } from "./utils/mock-api";
|
|
|
|
const THREAD_ID = "00000000-0000-0000-0000-000000000321";
|
|
const RUN_ID = "run-workspace-changes";
|
|
|
|
test.describe("Workspace changes", () => {
|
|
test("shows changed files badge and opens the diff panel", async ({
|
|
page,
|
|
}) => {
|
|
const includeDiffValues: string[] = [];
|
|
mockLangGraphAPI(page, {
|
|
threads: [
|
|
{
|
|
thread_id: THREAD_ID,
|
|
title: "Workspace changes",
|
|
updated_at: "2026-07-04T10:00:00Z",
|
|
messages: [
|
|
{
|
|
type: "human",
|
|
id: "msg-human-workspace-changes",
|
|
content: [{ type: "text", text: "Create a report" }],
|
|
run_id: RUN_ID,
|
|
},
|
|
{
|
|
type: "ai",
|
|
id: "msg-ai-workspace-changes",
|
|
content: "I updated the workspace report.",
|
|
run_id: RUN_ID,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
await page.route(
|
|
`**/api/threads/${THREAD_ID}/runs/${RUN_ID}/workspace-changes?*`,
|
|
async (route) => {
|
|
const url = new URL(route.request().url());
|
|
const includeFiles = url.searchParams.get("include_files") !== "false";
|
|
includeDiffValues.push(url.searchParams.get("include_diff") ?? "");
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
available: true,
|
|
version: 1,
|
|
summary: {
|
|
created: 1,
|
|
modified: 1,
|
|
deleted: 0,
|
|
symlink_created: 0,
|
|
additions: 8,
|
|
deletions: 2,
|
|
truncated: false,
|
|
},
|
|
files: includeFiles
|
|
? [
|
|
{
|
|
path: "/mnt/user-data/outputs/report.md",
|
|
root: "outputs",
|
|
status: "modified",
|
|
binary: false,
|
|
sensitive: false,
|
|
size_before: 12,
|
|
size_after: 20,
|
|
sha256_before: "before",
|
|
sha256_after: "after",
|
|
diff: "--- a/mnt/user-data/outputs/report.md\n+++ b/mnt/user-data/outputs/report.md\n@@ -1,2 +1,2 @@\n-Draft\n+Ready",
|
|
diff_truncated: false,
|
|
diff_unavailable_reason: null,
|
|
additions: 1,
|
|
deletions: 1,
|
|
},
|
|
{
|
|
path: "/mnt/user-data/workspace/notes.txt",
|
|
root: "workspace",
|
|
status: "created",
|
|
binary: false,
|
|
sensitive: false,
|
|
size_before: null,
|
|
size_after: 8,
|
|
sha256_before: null,
|
|
sha256_after: "created",
|
|
diff: "--- a/mnt/user-data/workspace/notes.txt\n+++ b/mnt/user-data/workspace/notes.txt\n@@ -0,0 +1 @@\n+Notes",
|
|
diff_truncated: false,
|
|
diff_unavailable_reason: null,
|
|
additions: 1,
|
|
deletions: 0,
|
|
},
|
|
]
|
|
: [],
|
|
limits: {},
|
|
}),
|
|
});
|
|
},
|
|
);
|
|
|
|
await page.goto(`/workspace/chats/${THREAD_ID}`);
|
|
|
|
await expect(page.getByText("Edited 2 files")).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
// The human prompt carries the same run_id, but the badge must only render
|
|
// under the assistant turn — never under the user's message.
|
|
await expect(page.getByText("Edited 2 files")).toHaveCount(1);
|
|
await expect(page.getByText("outputs/report.md")).toBeVisible();
|
|
await expect(page.getByText("notes.txt")).toBeVisible();
|
|
expect(includeDiffValues).toContain("false");
|
|
|
|
await page.getByRole("button", { name: "View changes" }).click();
|
|
await expect(
|
|
page.getByRole("heading", { name: /workspace changes/i }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole("button", {
|
|
name: /\/mnt\/user-data\/outputs\/report\.md/i,
|
|
}),
|
|
).toBeVisible();
|
|
expect(includeDiffValues).toContain("true");
|
|
await expect(page.getByText("+Ready")).toBeVisible();
|
|
await expect(page.getByText("-Draft")).toBeVisible();
|
|
});
|
|
});
|