Skip to content

fix(artifacts): expand recorded directories into per-file artifacts - #9395

Merged
zjgzx1988 merged 14 commits into
QwenLM:mainfrom
zjgzx1988:fix/artifact-directory-expansion
Aug 21, 2026
Merged

fix(artifacts): expand recorded directories into per-file artifacts#9395
zjgzx1988 merged 14 commits into
QwenLM:mainfrom
zjgzx1988:fix/artifact-directory-expansion

Conversation

@zjgzx1988

@zjgzx1988 zjgzx1988 commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

When a session records a generated folder as one workspace artifact, clients later try to open or download it as a regular file and get HTTP 400. This change keeps the path verification now on main from #9142, but a directory is no longer rejected: it is expanded into one artifact per recordable file. Hidden names, Excel lock files, symlinks, and well-known junk directories are skipped. Empty directories still fail. The original folder path is never stored as an openable artifact.

Office documents (Word / Excel / PowerPoint and OpenDocument) are a first-class downloadable artifact type. They no longer fall through to a text preview. The same download-only treatment applies to PDF, video, and audio. If a leftover dirty record still points at a directory, Open/Download refuse it after GET /stat instead of calling the file APIs.

Completed-turn artifact cards show at most three items, with the same expand/collapse control already used for edited files. Chat grouping treats the recorded folder path as a prefix, so the expanded files stay on that turn.

Rebased onto current main after #9142 landed. Review this PR as the follow-up delta only.

Why it's needed

Models often produce a folder of spreadsheets or reports and register the folder itself. Users then click Open or Download and hit a 400 because those APIs require a regular file. Rejecting the directory (#9142) stops the 400 but still does not give the user one card per generated file. See #9385.

Reviewer Test Plan

How to verify

  1. In a session, create a folder with several .xlsx files (plus a hidden file and a ~$ Excel lock file). Record that folder as a workspace artifact. The tool result should say the directory was expanded, list the files, and must not say the folder itself was recorded. The artifacts panel should show one card per real file (not the folder, not the lock/hidden files).
  2. Open and download one of the Excel cards. It should download as a binary, not open in the text/code preview. Repeating this with .docx / .pptx should behave the same.
  3. Record an empty folder. The tool should fail. Record a missing or escaped path. It should still fail the same way as fix(artifacts): verify and canonicalize record_artifact workspace paths #9142.
  4. Write an .xlsx with the write-file tool (artifact recording enabled). It should be recorded as an office document, not as a generic text file. A .csv should stay a text-previewable file.
  5. In Web Shell, record more than three artifacts in one turn. The turn list should show three cards and a control to show the rest; collapsing should return to three cards.
  6. If a leftover dirty record still points at a directory, Open and Download must not call the regular-file APIs.

Unit coverage: directory expansion and empty/cap cases, write-file office kinds, turn grouping by folder prefix, office labels, and directory-stat refusal before reading bytes.

Evidence (Before & After)

Before: one card titled like "timeline data (17 Excel files)" with workspacePath pointing at the folder; Open/Download returned 400.

After: one card per file (Excel/Word/PowerPoint labeled as such), Open/Download work on files, folder path is not stored, and more than three cards collapse behind "show more".

Tested on

OS Status
🍏 macOS ✅ unit tests
🪟 Windows ⚠️ not tested
🐧 Linux ⚠️ not tested

Environment (optional)

Local vitest: packages/core record-artifact + write-file (124), packages/acp-bridge session artifacts (114), packages/web-shell artifact panel / turn outputs / selectors (102).

Risk & Scope

  • Main risk or tradeoff: a large generated tree is capped at 100 files (with a warning). Hidden files and junk directories are skipped, so a folder of only-dotfiles fails as empty.
  • Not validated / out of scope: a directory-browser artifact kind, zipping at record time, and auto-registering every write/shell output without an explicit record.
  • Breaking changes / migration notes: record_artifact on a non-empty directory now succeeds and emits per-file artifacts instead of TARGET_IS_DIRECTORY. Callers that expected a hard failure for any directory will see success plus expansion. Path canonicalization from fix(artifacts): verify and canonicalize record_artifact workspace paths #9142 is unchanged for regular files.

Linked Issues

Fixes #9385

Follow-up to #9142 (already merged)

中文说明

这个 PR 做什么

当会话把生成的文件夹登记成一条 workspace 产物时,客户端稍后会按普通文件去打开或下载,得到 HTTP 400。这次保留已合入 main#9142 路径校验,但目录不再被拒绝:会展开成「每个可登记文件一条产物」。隐藏名、Excel 锁文件、symlink 和常见垃圾目录会跳过。空目录仍然失败。原来的文件夹路径不会被存成可打开的产物。

Office 文档(Word / Excel / PowerPoint 以及 OpenDocument)成为一等可下载产物,不再掉进文本预览。PDF、视频、音频同样只走下载。如果历史脏数据仍指向目录,Open/Download 会在 GET /stat 后拒绝,而不会再打文件接口。

一轮完成的聊天产物卡片最多显示 3 张,展开/收起控制和已编辑文件相同。聊天归组把当时登记的文件夹路径当前缀,所以展开后的文件仍挂在同一轮。

已在 #9142 合入后 rebase 到当前 main。请只审这一层增量。

为什么需要

模型经常产出一文件夹表格或报告,却把文件夹本身登记进去。用户一点打开/下载就 400,因为那些接口要求普通文件。#9142 拒绝目录能挡住 400,但用户仍然拿不到「每个生成文件一张卡」。见 #9385

评审测试计划

如何验证

  1. 在会话里建一个含多个 .xlsx 的文件夹(再加一个隐藏文件和一个 ~$ Excel 锁文件),把该文件夹登记为 workspace 产物。工具结果应说明目录已展开并列文件,且不能声称登记了文件夹本身。产物面板应是每个真实文件一张卡(没有文件夹卡,也没有锁文件/隐藏文件)。
  2. 打开并下载其中一张 Excel 卡。应作为二进制下载,而不是进文本/代码预览。.docx / .pptx 同样。
  3. 登记空文件夹应失败。缺失或逃出工作区的路径仍按 fix(artifacts): verify and canonicalize record_artifact workspace paths #9142 失败。
  4. 在开启产物登记时用 write-file 写 .xlsx,应记成 office 文档而不是普通文本。.csv 仍可文本预览。
  5. Web Shell 里一轮登记超过 3 个产物。轮次列表应只显示 3 张卡,并有展开其余的控制;收起后回到 3 张。
  6. 若历史脏记录仍指向目录,Open/Download 不得再打普通文件接口。

证据(Before & After)

之前:一张「时间线数据(17 个 Excel 文件)」卡,workspacePath 指向文件夹;打开/下载 400。

之后:每个文件一张卡(Word/Excel/PowerPoint 有对应类型),打开/下载对文件生效,文件夹路径不落库,超过 3 张会收起。

风险与范围

大目录上限 100 个文件(并告警)。只含隐藏文件的目录按空目录失败。不做目录浏览器 kind、登记时打包 zip,也不自动登记每一次 write/shell 输出。对非空目录,record_artifact 从硬失败改为展开成功;普通文件的 #9142 规范化不变。

关联 issue

Fixes #9385

Follow-up to #9142(已合入)

@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Aug 18, 2026
@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Re-run gate notes — the picture changed since the last pass, mostly for the better.

  • Template: complete ✓ (all required sections, bilingual, concrete reviewer test plan).
  • Problem: real and observed, not theoretical — bug(artifacts): directory workspacePath is recorded as kind:file and open/download returns 400 #9385 reports a recorded folder (17 Excel workbooks) whose Open/Download returns HTTP 400, with the offending record quoted verbatim. The contract mismatch was statically verifiable on main: record_artifact performed no directory check while GET /file / GET /file/bytes require a regular file. The honest caveat is already on the PR as a label: the issue is self-reported by the same author (review/self-reported), and no independent user has confirmed the scenario yet.
  • Direction: aligned — this is qwen-code's own daemon/Web Shell artifact surface and the designated follow-up to fix(artifacts): verify and canonicalize record_artifact workspace paths #9142, which merged on 2026-08-18 and whose path canonicalization this PR keeps. The reference CHANGELOG (claude-code) has no comparable directory-expansion signal, which is not a rejection; the area is clearly part of this product. What still deserves a maintainer's explicit nod: record_artifact on a non-empty directory flips from hard error to successful expansion, and document enters the artifact-kind union surfaced through the SDK — a public tool-contract change.
  • Size: the fix(artifacts): verify and canonicalize record_artifact workspace paths #9142 stacking is gone (verified: its merge commit is an ancestor of this head). What remains is still core-touching and large: 1223 production-logic lines (+1120/−103) vs 1182 test lines, 97 docs lines, and 3668 bun.lock lines from the top commit refreshing the desktop lockfile for the Live Host frozen install. fix-type PRs are not blocked on size, but 500+ production lines across packages/core/src/tools, packages/core/src/services, the core index exports, acp-bridge, web-shell and sdk-typescript means maintainer awareness per the core-module gate, and the 1000+ large-PR advisory applies. Most of the diff is tests, which is the right shape.
  • Approach: scope matches what bug(artifacts): directory workspacePath is recorded as kind:file and open/download returns 400 #9385 asked for — store-side expansion at the one choke point every ingest path shares, a second expansion site in the tool so the model sees what happened (shared walker, not duplicated logic), the office document kind, a stat-first client guard for legacy records, and the 3-card collapse. Nothing in the diff reads as cuttable without dropping a requirement the issue spelled out. The branch sits 13 commits behind main (main moved on after the 03:45 push) — routine, but it will need a merge before landing.
  • Risk: no Stage 1e high-risk-path matches — no elevated risk signals.

Moving on to code review. 🔍

中文说明

Re-run 门禁结论——与上一轮相比情况已有变化,且大多在变好。

  • 模板:完整 ✓(所有必需章节、双语、含具体步骤的评审测试计划)。
  • 问题:真实且已观测到,不是理论问题——bug(artifacts): directory workspacePath is recorded as kind:file and open/download returns 400 #9385 报告了登记的文件夹(17 个 Excel 文件)打开/下载返回 HTTP 400,并附上了出问题的记录原文。该契约失配在 main 上可静态验证:record_artifact 没有目录检查,而 GET /file / GET /file/bytes 要求普通文件。诚实的提醒已在 PR 标签上注明:issue 为同一作者自报(review/self-reported),尚无独立用户确认该场景。
  • 方向:对齐——这是 qwen-code 自己的 daemon/Web Shell 产物面,也是 fix(artifacts): verify and canonicalize record_artifact workspace paths #9142 的指定后续;fix(artifacts): verify and canonicalize record_artifact workspace paths #9142 已于 2026-08-18 合入,本 PR 保留了其路径规范化。参考 CHANGELOG(claude-code)无目录展开的对应信号,这不构成拒绝;该领域显然是本产品的一部分。仍值得 maintainer 明确点头的是:非空目录上的 record_artifact 从硬失败翻转为展开成功,且 document 进入经 SDK 暴露的 artifact-kind 联合——这是公开的工具契约变更。
  • 规模fix(artifacts): verify and canonicalize record_artifact workspace paths #9142 堆叠已消除(已验证:其合并提交是本 head 的祖先)。剩余改动仍触及核心且不小:1223 行生产逻辑(+1120/−103),测试 1182 行、文档 97 行,以及顶部提交为 Live Host 冻结安装刷新 desktop bun.lock 的 3668 行。fix 类 PR 不因规模被阻塞,但跨 packages/core/src/toolspackages/core/src/services、core index 导出、acp-bridge、web-shell、sdk-typescript 的 500+ 生产行按核心模块门禁需维护者知悉,且 1000+ 大 PR 建议适用。diff 的大头是测试,形态是对的。
  • 方案:范围与 bug(artifacts): directory workspacePath is recorded as kind:file and open/download returns 400 #9385 的要求一致——在所有入口共享的唯一咽喉处做 store 侧展开、工具内第二个展开点让模型看到结果(共享 walker,非重复逻辑)、office document kind、针对历史记录的 stat 先行客户端守卫、3 卡折叠。diff 中看不出有什么可以在不砍掉 issue 明确要求的情况下删掉的部分。分支落后 main 13 个提交(03:45 推送后 main 前进所致)——常规情况,合并前需要先 merge 一次。
  • 风险:Stage 1e 高风险路径无匹配——无升级风险信号。

进入代码审查。🔍

Qwen Code · qwen3.8-max

Reviewed at 249452c4a58985088712b973ba2a3b7e15580b53 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head 249452c. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

⚠️ One or more scenarios failed to render on this head, so this preview may be missing views — see the workflow run. The composites below are the scenarios that did render.

drawer-fullscreen-dark before/after

drawer-fullscreen-light before/after

Full-resolution recordings (.webm) are attached to the workflow run.

Qwen Code · web-shell visuals

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Code review

Independent baseline first: the minimal way to satisfy #9385 is expansion at the store — the single choke point every ingest path shares — per-file kind inference with office documents download-only, a stat-first client guard for legacy dirty records, and the 3-card collapse. The PR matches that baseline. Its one addition is a second expansion site inside the record_artifact tool so the model gets an "Expanded directory …" result instead of silence, with the walking logic shared from one core util. That earns its place: hooks and client POST ingest bypass the tool, so the store had to expand anyway.

No critical blockers found in a full static pass over the delta. Verified integration points against the base:

  • The root cause is fixed where it lives: getWorkspaceStatus no longer calls a non-regular file available — it throws, and both existing callers already do the right thing with that: initial ingest converts it to a validation error, status refresh degrades the record to missing with a stderr log. Leftover dirty directory records fail soft on both the store side and the client side.
  • The client guard is live, not dead code: the daemon's GET /stat contract on main already returns type: 'file' | 'directory' | ..., so useWorkspaceFileContent refusing to preview and readWorkspaceFileAsBlob refusing to download a directory both fire on real data.
  • The walker is conservative: deterministic sort order, a 100-file cap, a depth cap of 4 that only discloses when a recordable descendant actually exists (no false "files were dropped" for empty over-deep chains), hidden-name / ~$ lock / symlink-entry / junk-directory skips, a per-entry workspace containment check, and realpath canonicalization before the skip gate so a symlink cannot alias node_modules or .qwen into the walk.
  • The new document kind is threaded through every exhaustive site I could find: the core ToolArtifactKind union, the record-artifact schema and isArtifactKind, persisted-record normalization, the store's normalizeKind/inferKind, the boundary-diagnostics satisfies Record<ToolArtifactKind, true>, the SDK's known-kind union, and the Web Shell labels/icons/download-only routing.
  • Turn grouping was tightened against the earlier review rounds: only completed record_artifact calls count as directory references, children attach by prefix + / (so reports-old/… does not match reports), expanded children carry the recording toolCallId so later writes under the same folder stay on their own turn, and absolute reference paths normalize through the workspace cwd.
  • The overflow warning that previously had no consumer now reaches stderr through the bridge client, worktree-root recording is explicitly rejected, and worktree subdirectories expand cleanly thanks to the .qwen/worktrees/<slug>/ prefix strip.

Non-blocking notes — several correspond to still-open earlier-round threads that the current code has largely outgrown (worth resolving on the PR):

  • The office-extension list exists twice (core and Web Shell) because the client bundle cannot import core; the second copy now carries a keep-in-sync comment. Fine at the boundary, but the two lists must be updated together.
  • expandedFromDirectory is not in the reserved-metadata set, so a caller-supplied marker can suppress display refresh for one merge; the store strips the marker when a real explicit record arrives. Display-only surface.
  • The walk is bounded by collected files, not directory visits, and does not consult the tool's AbortSignal — depth 4 keeps the worst case small; advisory only (earlier-round R2-12).
  • useWorkspaceFileContent pays one extra stat round-trip before every text preview — the price of the dirty-record guard, worth it.
  • The author's stated local unit counts (124 record-artifact/write-file, 114 session-artifacts, 102 web-shell) are their claim — triage never executes PR code, so they were not re-run here.
sequenceDiagram
    participant P1 as record_artifact tool
    participant P2 as hooks and client POST
    participant P3 as SessionArtifactStore
    participant P4 as directory walker
    participant P5 as Web Shell panel
    P1->>P4: walk directory, caps and skips
    P4-->>P1: recordable files
    P1->>P3: one artifact per file, expansion marker
    P2->>P3: raw input, directory path possible
    P3->>P4: expand at the choke point
    P4-->>P3: one input per file
    P3-->>P5: per-file artifact changes
    P5->>P5: stat before open or download
Loading
Files changed (28 of 28 shown)
File What changed
docs/design/session-artifacts-directory-expansion.md Design doc: expansion decision, coordination with 9142, out-of-scope list
packages/acp-bridge/src/bridgeClient.ts Store warnings now logged to stderr so overflow/truncation warnings have a consumer
packages/acp-bridge/src/sessionArtifacts.test.ts Store expansion tests: per-file output, symlink escape, junk aliasing, metadata budget, cap, curated-title ordering
packages/acp-bridge/src/sessionArtifacts.ts Store-side expansion choke point, document kind, overflow-warning plumbing, directory-status throw and degradation
packages/core/src/index.ts Exports the new walker util and the recordable-child predicate
packages/core/src/services/session-artifact-persistence.test.ts Roundtrip test for persisted document-kind artifacts
packages/core/src/services/session-artifact-persistence.ts Accepts document in persisted-record normalization
packages/core/src/tools/record-artifact.test.ts Tool expansion tests: cap disclosure, junk root, worktree root and subdirectory, depth warning
packages/core/src/tools/record-artifact.ts Tool-side expansion, directory-aware locator, workspace-root rejection, document kind in schema
packages/core/src/tools/tools.ts Adds document to ToolArtifactKind
packages/core/src/tools/write-file.test.ts xlsx now infers document; docx/pptx cases added
packages/core/src/tools/write-file.ts Auto-record map routes office extensions to document via the shared set
packages/core/src/utils/tool-result-boundary-diagnostics.ts Adds document to the exhaustive kind record
packages/core/src/utils/workspace-artifact-directory.test.ts Walker unit tests: skips, recordable predicate counting, depth probes, unreadable dirs
packages/core/src/utils/workspace-artifact-directory.ts New shared walker: caps, skip set, office extensions, worktree prefix strip
packages/desktop/bun.lock Lockfile refresh for the Live Host frozen install (the top commit)
packages/sdk-typescript/src/daemon/types.ts Adds document to the known daemon artifact kinds
packages/web-shell/client/components/artifacts/ArtifactPanel.module.css Styles for the download-only view
packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx Tests: download-only document rendering, missing state, stat-says-directory guard
packages/web-shell/client/components/artifacts/ArtifactPanel.tsx DownloadableWorkspaceArtifact view, stat-first preview guard, path-aware kind label
packages/web-shell/client/components/artifacts/TurnOutputs.dom.test.tsx DOM tests: 3-card cap and show-more for artifact cards
packages/web-shell/client/components/artifacts/TurnOutputs.test.ts Helper tests: visible-item cap and document icon
packages/web-shell/client/components/artifacts/TurnOutputs.tsx Artifact cards collapse at 3 with the same show-more control edited files use
packages/web-shell/client/components/artifacts/artifactUtils.test.ts Tests: office labels, download-only predicate, directory-stat refusal
packages/web-shell/client/components/artifacts/artifactUtils.ts Office/download-only sets, extension-aware kind label, directory refusal in blob read
packages/web-shell/client/components/artifacts/turnOutputSelectors.test.ts Grouping tests: prefix attach, later writes stay on their turn, failed calls ignored, sibling safety
packages/web-shell/client/components/artifacts/turnOutputSelectors.ts Directory-prefix turn grouping scoped to completed record calls
packages/web-shell/client/i18n.tsx EN/ZH strings for artifact show-more/collapse

Test evidence — the PR's own CI (unattended run; no PR code executed here)

CI has settled on this head: every pull_request-event workflow has a completed green run. A duplicated batch triggered at the same instant was cancelled by concurrency — that is the source of the many cancelled check-runs, and it is noise. The skipped legs are the repo's own CI design, not missing evidence: the macOS/Windows unit tests and the CLI integration lane are merge-queue-only jobs (ci.yml gates them on merge_group), and the same pattern was present on the previous head.

Check Conclusion
Test (ubuntu-latest, Node 22.x) success
Test (macos-latest, Node 22.x) skipped (merge-queue-only by design)
Test (windows-latest, Node 22.x) skipped (merge-queue-only by design)
Integration Tests (CLI, No Sandbox) skipped (merge-queue-only by design)
Desktop Shell (ubuntu-22.04) success
Desktop Shell (windows-2022) success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) success
Real daemon E2E / Java 11 success
Java matrix (ubuntu 11/17/21, macos 21, windows 21) success
SDK Java success
Security Checks success
Qwen Live Host CI + release dry run success
Web-shell Visuals success
Secret scan (TruffleHog) success
Dependency CVE audit success

The one red check on the commit (review-pr, failure) is bot orchestration under pull_request_target, not PR CI; a further instance of it is the in-flight run of this very triage.

Sandboxed verification would settle what this suite cannot: a sponsored @qwen-code /verify run kicked off with this triage trigger is already in progress on this head (see the verify thread on this PR) — the claims it pins are that a recorded directory actually expands into one downloadable card per file in a live session, that office documents download as binaries instead of falling into the text preview, and that a legacy directory record no longer 400s. The author has write access, so a maintainer can additionally trigger @qwen-code /tmux to drive the Web Shell surface as a real user.

中文说明

代码审查:我独立给出的最小方案与 PR 的实现形状一致——在所有入口共享的 store 咽喉处展开、按文件推断 kind、office 文档只走下载、用现有 stat 接口守住客户端、3 卡折叠。它多出的一处(工具内第二个展开点,让模型拿到"已展开目录…"的结果)站得住脚:hooks 与客户端 POST 入口不经过工具,store 本来也必须展开。静态通读整个增量未发现阻塞性缺陷。已对照 base 验证关键集成点:根因修在正确位置(getWorkspaceStatus 不再把非普通文件标为 available,既有两个调用方分别转为校验错误与降级为 missing);客户端守卫不是死代码(mainGET /stat 契约本就返回 type: directory);walker 保守(确定性排序、100 文件上限、4 层深度且仅在确有可记录后代时告警、跳过隐藏名/锁文件/symlink 项/垃圾目录、逐条工作区包含校验、skip 门前先 realpath 规范化,symlink 无法把 node_modules/.qwen 别名进遍历);新 document kind 贯穿我能找到的所有穷举点;轮次归组针对早前评审轮次收紧(仅完成的调用、前缀 + / 防兄弟路径误配、展开子项携带 toolCallId);溢出告警有了消费方;worktree 根目录被显式拒绝而子目录可正常展开。

非阻塞备注(多条对应仍未关闭的早前评审线程,当前代码已基本解决,建议在 PR 上 resolve):office 扩展名列表在 core 与 Web Shell 各存一份(客户端 bundle 无法 import core,已加同步注释);expandedFromDirectory 未列入保留 metadata 键,调用方注入该键最多抑制一次展示刷新(store 在真正的显式记录到达时会剥掉它);遍历按收集文件数而非目录访问数封顶、不消费 AbortSignal(深度 4 限制了最坏情况,建议级);文本预览前多一次 stat 往返(脏记录守卫的代价,值得)。作者自述的本地单测数字是其声明——triage 从不执行 PR 代码,未在此复跑。

测试证据(无人值守运行,未执行任何 PR 代码):该 head 的 CI 已收敛,所有 pull_request 事件的工作流都有完成的绿色运行;同一时刻重复触发的批次被并发取消,那是众多 cancelled 检查的来源,属噪声。跳过的项是仓库自身的 CI 设计:macOS/Windows 单测与 CLI 集成仅 merge queue 运行(ci.ymlmerge_group 门控),上一个 head 也是同样形态。提交上唯一的红色检查(review-pr)是 pull_request_target 的 bot 编排任务,不是 PR CI。沙箱验证可以补上单测补不了的部分:一次由本次 /triage 触发的赞助 @qwen-code /verify 运行正在该 head 上进行(见本 PR 的 verify 线程)——它要钉住的是:真实会话中登记的目录确实展开为每文件一张可下载卡片、office 文档以二进制下载而非文本预览、历史目录记录不再 400。作者有 write 权限,maintainer 还可以触发 @qwen-code /tmux 以真实用户身份驱动 Web Shell 界面。

Qwen Code · qwen3.8-max

Reviewed at 249452c4a58985088712b973ba2a3b7e15580b53 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — clean review, nothing blocking found in the delta; capped by policy, because a core-touching fix at this size gets a maintainer's decision, not a bot approval.

Stepping back: what changed since the last pass is all in this PR's favor. The #9142 stacking is gone — it merged on 2026-08-18 and I verified its merge commit is an ancestor of this head, so the previous "review the delta, merge order undecided" limbo no longer exists. CI settled green on this head across every lane that runs for PR events. And the earlier-round review threads have been largely answered by the code itself: the store-side validation gap, the orphaned overflow warning, the false depth disclosures, the worktree-root edge, and the sibling-prefix grouping bug are all verifiably handled in the current diff.

My independent proposal for this problem and the PR's implementation are the same shape, and the one place it goes beyond the minimum — expanding inside the tool as well so the model sees what happened — is justified because hooks and client POST ingest never touch the tool. The root cause is fixed where it lives, the new document kind is threaded through every exhaustive site I could find, the client guards sit on a daemon stat contract that already exists on main, and the test volume is proportional. If I had to maintain this in six months, the things to remember would be small: two expansion paths sharing one walker, and two office-extension lists that must stay in sync. I'm not declining to approve because I ran out of objections — the remaining reasons are about who owns the decision:

  • Policy cap. 1223 production-logic lines across core tools/services, acp-bridge, web-shell and the SDK surface — the core-module gate escalates that to a maintainer decision regardless of review cleanliness, and the 1000+ advisory applies.
  • Public contract change on a self-reported problem. record_artifact on a non-empty directory flips from hard error to successful expansion, and document enters the SDK kind union. The product rules baked into the issue were written by the author, and no one else has confirmed the scenario end-to-end yet. Direction sign-off should come from a maintainer, not from the absence of objections.
  • Behavioural evidence is still landing. The green lanes are unit/E2E suites; the sponsored /verify run that A/B-tests the actual expansion and download behaviour is still in progress on this head.

⏸️ Deferring to @yiliang114 (who triggered this re-run) and @wenshao (engaged on the related #9142) — this needs a human sign-off on direction and the contract flip, not a code-quality veto: nothing in the current delta looks blocking to me. Once the verify report lands and a maintainer signs off, re-running @qwen-code /triage will re-evaluate against whatever head stands then.

中文说明

置信度:3/5 —— 审查干净,增量中未发现阻塞项;按政策封顶:触及核心且达到该规模的 fix 应由 maintainer 决策,而不是由 bot 批准。

整体回顾:上一轮之后的变化全部有利于本 PR。#9142 堆叠已消除——它已于 2026-08-18 合入,我已验证其合并提交是本 head 的祖先,此前"审增量、合并顺序未定"的悬置状态不复存在。CI 在该 head 上所有 PR 事件会运行的通道均为绿色。早前评审轮次的线程也大多已被代码本身回答:store 侧校验缺口、无人消费的溢出告警、误报的深度披露、worktree 根目录边界、兄弟路径前缀误配,在当前 diff 中均可验证已处理。

我独立提出的方案与 PR 的实现形状一致,而它超出最小集的一处(工具内也展开、让模型看到结果)是合理的:hooks 与客户端 POST 入口不经过工具。根因修在正确位置,新 document kind 贯穿我能找到的所有穷举点,客户端守卫依托 main 上已存在的 daemon stat 契约,测试体量与改动相称。若六个月后由我维护,需要记住的只有小事:两条展开路径共享一个 walker,以及两份必须同步更新的 office 扩展名列表。我不是因为提不出反对意见才不批准——剩下的理由关乎该由谁来拍板:

  • 政策封顶。 跨 core tools/services、acp-bridge、web-shell 与 SDK 面的 1223 行生产逻辑——核心模块门禁无论审查多干净都将其升级给 maintainer 决策,且 1000+ 大 PR 建议适用。
  • 自报问题上的公开契约变更。 非空目录上的 record_artifact 从硬失败翻转为展开成功,document 进入 SDK kind 联合。issue 中写入的产品规则出自作者本人,且尚无其他人端到端确认过该场景。方向背书应来自 maintainer,而不是"没人反对"。
  • 行为证据仍在路上。 绿色通道是单测/E2E;对该 head 做 A/B 行为验证的赞助 /verify 运行仍在进行中。

⏸️ 转交 @yiliang114(本次 re-run 的触发者)与 @wenshao(参与过相关的 #9142)——需要人类对方向与契约翻转拍板,而非代码质量上的否决:当前增量中没有我认为阻塞的内容。待 verify 报告落地且 maintainer 背书后,重新运行 @qwen-code /triage 将针对届时的 head 重新评估。

Qwen Code · qwen3.8-max

Reviewed at 249452c4a58985088712b973ba2a3b7e15580b53 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head e125cd3, drove a fixed endpoint set against each, and diffed the JSON responses. Only fields that changed are shown.

No response changes against the PR base across 4 scenario(s).

Qwen Code · serve A/B

@zjgzx1988
zjgzx1988 force-pushed the fix/artifact-directory-expansion branch from e125cd3 to d014a39 Compare August 18, 2026 07:57
@github-actions

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Overview (AI Generated)

PR: #9395 fix(artifacts): expand recorded directories into per-file artifacts
Type: Bug Fix + New Feature (office document kind)
Change size: +994/-87 across 23 files

Findings Summary

  • Critical/Major: 0
  • Minor: 1 — silent depth-limit truncation
  • Nit: 2 — duplicate extension set, redundant type label

Key Observations

This is a well-scoped, well-tested fix. The core logic (collectRecordableWorkspaceFiles, expandWorkspaceDirectoryInput, expandDirectoryLocator) is clean and correctly decomposes at record time rather than read time. The isSameWorkspacePathOrChild fix in turnOutputSelectors.ts and the new TURN_OUTPUT_VISIBLE_LIMIT collapse control for artifact cards are both solid additions. Test coverage is thorough for happy paths, empty-directory rejection, and the file-count cap.

One behaviour gap: the 100-file count cap correctly surfaces a truncated warning, but the depth-4 cap silently drops files from over-deep subtrees — returning false ("not truncated") even though content was omitted. The two nits are minor maintainability items.

Cross-Validation

No prior reviews on this PR — independent analysis only.

Finding Other Reviewer My Assessment
Unique-1: silent depth truncation (workspace-artifact-directory.ts:76) New finding
Unique-2: duplicate OFFICE_DOCUMENT_EXTENSIONS (artifactUtils.ts:49) Nit
Unique-3: type label rendered twice (ArtifactPanel.tsx:2548+2553) Nit

Additional Audit Coverage

  • Handler parallelism: expandWorkspaceDirectoryInput and expandDirectoryLocator both delegate to the same collectRecordableWorkspaceFiles — no drift between the two call sites.
  • Data structure blast radius: 'document' kind added consistently to normalizeKind, inferKind, isArtifactKind, and JSON schema; write-file.ts map and ARTIFACT_FORMAT_ICONS are updated too.
  • i18n completeness: common.download / common.downloading already exist; the two new turnOutputs.* keys are correctly added to both EN and ZH.
  • Path safety: isOutsidePath + lstat + isWithinRoot chain in both call paths provides adequate traversal defence; path.relative normalises before the escape check.
  • Backward compatibility: the optional workspacePath parameter on artifactKindLabel is backward-compatible; existing callers without it default to extension-agnostic behaviour.
  • inferKind extension casing: path.extname(...).toLowerCase() is applied before isOfficeDocumentExtension, so the backend correctly handles uppercase extensions (.XLSX etc.).
  • getWorkspaceStatus refactor: the old if (stat.isFile()) guard implicitly returned { status: 'available' } (no sizeBytes/mtimeMs) for non-file paths; the new early-throw is a correct clean-up.

Final Verdict

Ready to merge once the depth-truncation behaviour is addressed (or explicitly decided to be acceptable) — the rest of the change is high quality.


This review was generated by QoderWork AI

Comment thread packages/core/src/utils/workspace-artifact-directory.ts Outdated
}
}

const OFFICE_DOCUMENT_EXTENSIONS = new Set([

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit] OFFICE_DOCUMENT_EXTENSIONS is defined identically in both artifactUtils.ts and workspace-artifact-directory.ts (core)

The two sets are currently in sync, but they live in different packages with no compile-time link between them. If a new extension is added to one (e.g. .docb, .numbers), a developer may forget to update the other, causing the backend to record the file as kind: 'file' while the frontend labels it 'Word' (or vice versa).

A short comment in each file pointing to the other is the minimum fix:

// NOTE: keep in sync with OFFICE_DOCUMENT_EXTENSIONS in
// packages/core/src/utils/workspace-artifact-directory.ts
const OFFICE_DOCUMENT_EXTENSIONS = new Set([...]);

Longer-term, exporting a shared constant from a package both sides depend on (e.g. @qwen-code/sdk) would remove the duplication entirely.


This review was generated by QoderWork AI

<div className={styles.fieldGrid}>
<Field
label="Type"
value={artifactKindLabel(artifact.kind, artifact.workspacePath)}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit] artifactKindLabel is called twice for the same value in DownloadableWorkspaceArtifact

Line 2548 renders the kind label as the sectionTitle, then line 2553 renders it again as <Field label="Type" value={...} />. The two renders are identical, so the panel shows the type twice.

You could drop the <Field label="Type"> row (the title already serves as the type header) or replace the sectionTitle with a fixed string like 'Download' / t('common.download') to keep both rows meaningful.


This review was generated by QoderWork AI

qwen-code-ci-bot pushed a commit that referenced this pull request Aug 18, 2026

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.

中文说明

仅完成部分审查,审查缺口已披露。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

未审查:反向审计——在 5 轮的反审轮数上限内未收敛。

— qwen3.8-max via Qwen Code /review (v0.21.13)

Comment thread packages/web-shell/client/components/artifacts/TurnOutputs.tsx
Comment thread packages/core/src/tools/record-artifact.ts Outdated
Comment thread packages/web-shell/client/components/artifacts/turnOutputSelectors.ts Outdated
Comment on lines +76 to +78
if (depth > MAX_DIRECTORY_ARTIFACT_DEPTH) {
return false;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The depth cap silently omits recordable files — Concrete cost: the walk returns false (not truncated) for entries beyond MAX_DIRECTORY_ARTIFACT_DEPTH, so neither the tool message nor the store warnings disclose depth-skipped files: recording a tree whose report files sit deeper than 4 levels below the recorded root silently excludes them, the tool result lists what was recorded with no hint of omission, and a tree with only deep files reports a directory with no recordable files. The PR's Risk section and design doc disclose only the 100-file cap.

Witness:

Probe: tree with only exports/2026/08/18/region/store/report.xlsx (deep) + exports/shallow.xlsx
  → {"files":["exports/shallow.xlsx"],"truncated":false}
Boundary: depth-4 file collected, depth-5 dropped

Propagate a truncation signal when the depth limit cuts the walk off (e.g. a depthLimited flag alongside truncated) and disclose it in the tool message and store warnings.

中文说明

[Suggestion] 深度上限会静默漏掉可记录的文件 — 具体代价:对超过 MAX_DIRECTORY_ARTIFACT_DEPTH 的条目,遍历直接返回 false(而不是 truncated),因此工具消息和 store 告警都不会披露因深度被跳过的文件:当目录中的报告文件位于记录根目录下超过 4 层时会被静默排除;工具结果只列出已记录的文件、毫无遗漏提示;只有深层文件的目录会被报成 a directory with no recordable files。PR 的风险说明和设计文档只披露了 100 文件上限。建议:当深度上限截断遍历时传播一个截断信号(例如在 truncated 之外增加 depthLimited 标志),并在工具消息和 store 告警中披露。

— qwen3.8-max via Qwen Code /review (v0.21.13)

Comment thread packages/core/src/utils/workspace-artifact-directory.ts Outdated
Comment on lines +2948 to +2950
if (!stat.isFile()) {
throw new Error('path is not a regular file');
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Test coverage pattern — changed behavior with no test (location 2 of 6): this store-side refusal of legacy dirty directory records is untested — Concrete cost: the new empty-dir test short-circuits earlier in expandWorkspaceDirectoryInput and never reaches this throw, which is reachable via a symlinked-directory input or a persisted directory record being restored. If it regresses to the old { status: 'available' } fallback, a legacy directory record restores as available again — the exact dirty state this PR says it refuses — and clients retry open/download against a directory; no test goes red.

Witness:

Probe flip check: with the pre-PR fallback restored, a persisted directory record and a symlink->dir input
  → {"artifactCount":1,"statuses":["available"],"warnings":[]} (dirty state restored silently)

Add a store test seeding/restoring a record whose workspacePath is a non-empty directory and assert rejection rather than available.

中文说明

[Suggestion] 测试覆盖模式 — 行为已改但无测试(6 处之 2):这个「store 端拒绝历史脏目录记录」的改动没有测试 — 具体代价:新增的空目录测试在 expandWorkspaceDirectoryInput 更早处就短路,永远到不了这个 throw;而该 throw 可通过符号链接目录输入或恢复已持久化的目录记录到达。若它回归为旧的 { status: 'available' } 回退,历史目录记录会再次以 available 恢复 —— 正是本 PR 声称要拒绝的脏状态 —— 客户端会再次对目录尝试打开/下载;没有测试会变红。建议:增加一个 store 测试,构造/恢复 workspacePath 为非空目录的记录,断言被拒绝而非 available

— qwen3.8-max via Qwen Code /review (v0.21.13)

Comment on lines +42 to +46
export function shouldSkipDirectoryArtifactName(name: string): boolean {
return (
name.startsWith('.') ||
name.startsWith('~$') ||
SKIP_DIRECTORY_ARTIFACT_NAMES.has(name)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Test coverage pattern — changed behavior with no test (location 3 of 6): the junk-directory skip set, the depth cap, and the symlink-entry skip have no fixture coverage anywhere — Concrete cost: this file has no test file at all; the existing expansion fixtures cover hidden/~$ skips and depth-1/2 nesting only. If the skip set (node_modules, .git, __pycache__, dist, .qwen), the depth check, or the symlink-entry skip regresses, expansion records up to 100 junk files (wrong files occupying the cap) or walks unbounded depth, and no test goes red in either test suite.

Extend one expansion fixture with node_modules/junk.txt, a file at depth > 5, and a symlink entry, asserting none are recorded.

中文说明

[Suggestion] 测试覆盖模式 — 行为已改但无测试(6 处之 3):垃圾目录跳过集合、深度上限、符号链接条目跳过在任何地方都没有 fixture 覆盖 — 具体代价:本文件完全没有对应的测试文件;现有展开 fixture 只覆盖隐藏文件/~$ 跳过和 1-2 层嵌套。若跳过集合(node_modules.git__pycache__dist.qwen)、深度检查或符号链接条目跳过发生回归,展开会记录最多 100 个垃圾文件(错误的文件占满上限)或无深度限制地遍历,两个测试套件都不会变红。建议:在某个展开 fixture 中加入 node_modules/junk.txt、一个深度 > 5 的文件和一个符号链接条目,断言它们都不被记录。

— qwen3.8-max via Qwen Code /review (v0.21.13)

Comment on lines 857 to 862
'audio',
'pdf',
'notebook',
'document',
'other',
]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Test coverage pattern — changed behavior with no test (location 4 of 6): persistence restore accepting the new 'document' kind is untested (no test references kind: 'document'; the persistence fixtures use only kind: 'link') — Concrete cost: normalizeLiteral returns undefined for disallowed values and the guard below drops the WHOLE artifact with skipped malformed artifact; if 'document' regresses out of this allowlist, every persisted document artifact fails to restore after a daemon restart — worse than just losing its kind — and no roundtrip test goes red.

Add a restore/normalize case asserting a persisted artifact with kind: 'document' roundtrips with its kind intact.

中文说明

[Suggestion] 测试覆盖模式 — 行为已改但无测试(6 处之 4):持久化恢复接受新 'document' kind 的路径没有测试(没有任何测试引用 kind: 'document';持久化 fixture 只用 kind: 'link')— 具体代价:normalizeLiteral 对不在白名单的值返回 undefined,下方守卫会以 skipped malformed artifact 丢弃整个产物;若 'document' 从这个白名单回归掉,所有已持久化的 document 产物在 daemon 重启后都会恢复失败 —— 比单纯丢失 kind 更严重 —— 且没有往返测试会变红。建议:增加一个恢复/规范化用例,断言 kind: 'document' 的持久化产物往返后 kind 保持不变。

— qwen3.8-max via Qwen Code /review (v0.21.13)

Comment on lines +2369 to +2371
if (isDownloadOnlyWorkspaceArtifact(artifact)) {
return (
<DownloadableWorkspaceArtifact

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Test coverage pattern — changed behavior with no test (location 5 of 6): the routing of document/pdf/video/audio artifacts to the new download-only view is only tested at the predicate level — Concrete cost: no component test asserts the routing, the Download button, or that clicking it calls downloadWorkspaceFile; if the isDownloadOnlyWorkspaceArtifact check regresses or moves after the generic WorkspaceFilePreview branch, an .xlsx artifact falls back to reading a binary as text — the original defect this PR fixes — and no test goes red.

Add an ArtifactPanel test rendering a kind: 'document' workspace artifact and asserting the download button renders (and readWorkspaceFile is not called for preview).

中文说明

[Suggestion] 测试覆盖模式 — 行为已改但无测试(6 处之 5):document/pdf/video/audio 产物路由到新的「仅下载」视图只在谓词层面有测试 — 具体代价:没有组件级测试断言该路由、Download 按钮、或点击后会调用 downloadWorkspaceFile;若 isDownloadOnlyWorkspaceArtifact 检查回归或被移到通用 WorkspaceFilePreview 分支之后,.xlsx 产物会回退到把二进制当文本读取 —— 正是本 PR 要修的原始缺陷 —— 且没有测试会变红。建议:增加一个 ArtifactPanel 测试,渲染 kind: 'document' 的工作区产物,断言出现下载按钮(且预览未调用 readWorkspaceFile)。

— qwen3.8-max via Qwen Code /review (v0.21.13)

Comment on lines +2736 to +2738
if (stat.type === 'directory') {
throw new Error('Directories cannot be opened as artifacts.');
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Test coverage pattern — changed behavior with no test (location 6 of 6): the stat-first directory gate in useWorkspaceFileContent is untested — Concrete cost: the only added ArtifactPanel.test.tsx mock resolves type: 'file' to keep an existing test green; the directory rejection is tested only for the blob-download utility, not this hook. If the gate regresses (e.g. the stat call is removed), the preview issues readWorkspaceFile against a directory and surfaces a raw backend error instead of the friendly message for legacy directory records; no test goes red.

Add a panel test where the stat mock resolves { type: 'directory' } and assert the error is shown and readWorkspaceFile is never called.

中文说明

[Suggestion] 测试覆盖模式 — 行为已改但无测试(6 处之 6):useWorkspaceFileContent 中「先 stat 再读取」的目录门禁没有测试 — 具体代价:ArtifactPanel.test.tsx 新增的唯一 stat mock 解析为 type: 'file'(仅为让既有测试通过);目录拒绝只在 blob 下载工具函数上测过,未覆盖此 hook。若该门禁回归(例如 stat 调用被移除),预览会对目录调用 readWorkspaceFile,历史脏目录记录将看到原始后端错误而非友好提示;没有测试会变红。建议:增加一个面板测试,让 stat mock 解析 { type: 'directory' },断言显示错误且 readWorkspaceFile 从未被调用。

— qwen3.8-max via Qwen Code /review (v0.21.13)

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully. The review pipeline failed before a review could be posted. A transient error is retried automatically; if you are seeing this, retry with @qwen-code /review. See workflow logs.

zjgzx1988 and others added 2 commits August 18, 2026 20:01
A generated folder was stored as one file artifact, so Open and Download hit file APIs on a directory and returned 400. Record each file instead, and treat office documents as downloadable artifacts.

Co-authored-by: Cursor <cursoragent@cursor.com>
Report depth truncation, keep later files on their own turn, and stop one bad filename or a full-content hash from breaking directory ingest.

Co-authored-by: Cursor <cursoragent@cursor.com>
@zjgzx1988
zjgzx1988 force-pushed the fix/artifact-directory-expansion branch from 0d53cb4 to e54995c Compare August 18, 2026 12:01
qwen-code-ci-bot pushed a commit that referenced this pull request Aug 18, 2026

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed. Suggestions are inline.

Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

中文说明

仅完成部分审查,审查缺口已披露。 建议见行内评论。

未审查:reverse audit — did not converge within the reverse-audit round cap of 5。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

— qwen3.8-max via Qwen Code /review (v0.21.13)

options.trustedPublisher === true,
),
);
const expanded = await this.expandWorkspaceDirectoryInput(input);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-17: The per-request input cap is enforced BEFORE directory expansion, so each capped input can fan out to up to 100 children inside upsertMany — one hook event can drive ~40,000 sequential normalizeInput calls on the serialized store queue — Failure scenario: a hook/tool update carrying up to 400 directory artifacts → ~200k awaited fs round-trips execute serially in enqueue, stalling every list()/get() for the session (the web-shell artifact panel) for the duration; the cap exists to bound exactly this work, but expansion bypasses it by up to 100×.

Witness:

Probe: upsertMany(3 directory inputs) → changes.length=200
  warnings: ["dropped 100 newly created artifacts because the store is full"]
store.inputBatchLimit() === 400 (applied pre-expansion)

Suggested fix: enforce the bound on the post-expansion total — pass a remaining-input budget into expandWorkspaceDirectoryInput (or stop collecting once accumulated inputs reach the limit) and surface a batch-limit warning like the pre-expansion cap does.

中文说明

[Suggestion] 每请求输入上限在目录展开「之前」执行,因此每个受限输入最多可在 upsertMany 内展开成 100 个子项 —— 单个 hook 事件可在串行化的 store 队列上触发约 40,000 次顺序 normalizeInput 调用 —— 失败场景:携带最多 400 个目录产物的 hook/工具更新 → 约 20 万次 await 的 fs 往返在 enqueue 中串行执行,期间该会话的所有 list()/get()(web-shell 产物面板)被阻塞;该上限本就是为了限制这类工作而存在,但展开可将其绕过最多 100 倍。

证据见上(probe 输出)。

建议修复: 对展开后的总量设限 —— 把剩余输入预算传入 expandWorkspaceDirectoryInput(或累计输入达到上限即停止收集),并像展开前上限一样给出批量超限告警。

— qwen3.8-max via Qwen Code /review (v0.21.13)

Comment thread packages/acp-bridge/src/sessionArtifacts.ts Outdated
Comment thread packages/acp-bridge/src/sessionArtifacts.ts Outdated
persistenceStrict,
);
changes.push(...overflowRemoved.removed);
if (overflowRemoved.droppedCreated > 0) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-19: Test coverage pattern — changed behavior with no test (location 1 of 3): the model-visible overflow warning ("dropped N newly created artifacts because the store is full") has no test — Failure scenario: mutation droppedCreated > 0 → false (or deleting the push) keeps all 115 tests green; callers/models silently lose the only disclosure that newly created artifacts were dropped. Directory expansion makes same-batch overflow realistically reachable in a single call.

Witness: mutation droppedCreated > 0 && falseTests 115 passed (115); a control probe asserting the warning flips red under the mutation and is green on pristine code.

Suggested fix: extend the same-batch overflow test (drops newest artifacts created in the same batch…) to also assert warnings contains the drop message.

中文说明

[Suggestion] 测试覆盖模式 —— 行为有变更但无测试(第 1/3 处):模型可见的溢出告警("dropped N newly created artifacts because the store is full")没有任何测试 —— 失败场景:变异 droppedCreated > 0 → false(或删除该 push)时全部 115 个测试仍为绿;调用方/模型会静默失去「新建产物被丢弃」的唯一披露。目录展开使同批溢出在单次调用中即可真实触发。

证据:变异 droppedCreated > 0 && falseTests 115 passed (115);断言该告警的对照 probe 在变异下变红、在原始代码下为绿。

建议修复: 扩展同批溢出测试,额外断言 warnings 包含该丢弃信息。

— qwen3.8-max via Qwen Code /review (v0.21.13)

Comment thread packages/acp-bridge/src/sessionArtifacts.ts Outdated
Comment thread packages/core/src/utils/workspace-artifact-directory.ts Outdated
Comment thread packages/core/src/utils/workspace-artifact-directory.ts Outdated
artifact.workspacePath,
workspaceCwd,
) &&
(!artifact.toolCallId || artifact.toolCallId === reference.callId)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-6: The directory-child branch treats a MISSING artifact.toolCallId as matching every recorded-directory reference whose path is an ancestor — store-side expansion from client POST and hook ingestion stamp no toolCallId, yet the metadata.expandedFromDirectory marker (stamped by both expansion paths and exposed on DaemonSessionArtifact.metadata) is not consulted — Failure scenario: turn 1 records reports; later a hook/client-POST expansion creates reports/extra/… children without toolCallId → the cards render under turn 1 instead of their creation turn; with reports recorded in two turns the same card appears under BOTH (dedup is per-turn only). The diff's own negative test ("does not attach later artifacts…") stays green only because its fixture is stamped.

Witness:

Probe (real selectors, PR code):
  scenarioA (later unstamped child under recorded dir): attachedTurns = ["u1"]  (old turn)
  scenarioB (dir recorded in two turns): attachedTurns = ["u1","u2"]  (duplicate)
  Flip (require a matching toolCallId): both → []

Suggested fix: gate the fallback on the expansion marker instead of absence of toolCallId, e.g. (!artifact.toolCallId ? artifact.metadata?.['expandedFromDirectory'] === true : artifact.toolCallId === reference.callId), and update the first test's fixtures to carry the marker both expansion paths actually produce.

中文说明

[Suggestion] 目录子项分支把「缺失 artifact.toolCallId」当作匹配所有路径为祖先的已记录目录引用 —— 客户端 POST 与 hook 摄取的 store 侧展开不打 toolCallId,而两条展开路径都会打、且已暴露在 DaemonSessionArtifact.metadata 上的 metadata.expandedFromDirectory 标记却未被使用 —— 失败场景:第 1 轮记录 reports;之后 hook/客户端 POST 展开在 reports/extra/… 下创建无 toolCallId 的子项 → 卡片渲染在第 1 轮而不是创建它的轮次;若 reports 在两个轮次都被记录,同一张卡片会同时出现在两轮(去重只在单轮内进行)。diff 自带的反向测试("does not attach later artifacts…")之所以保持绿,仅因为其 fixture 打了 toolCallId。

证据见上(probe 输出)。

建议修复: 让回退分支以展开标记为门槛,而不是以 toolCallId 缺失为门槛,例如 (!artifact.toolCallId ? artifact.metadata?.['expandedFromDirectory'] === true : artifact.toolCallId === reference.callId);并更新第一个测试的 fixture,携带两条展开路径实际产生的标记。

— qwen3.8-max via Qwen Code /review (v0.21.13)

Comment on lines +524 to +525
const normalizedParent = stripWorkspacePath(parent, workspaceCwd);
const normalizedChild = stripWorkspacePath(child, workspaceCwd);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-4: isSameWorkspacePathOrChild inherits stripWorkspacePath's workspace-root collapse (path === cwd returns the cwd's BASENAME), so a workspace-root recording reference is treated as a recording of a top-level subdirectory named after the workspace's basename — the false-positive twin of the root false-negative in Needs Human Review (filed Critical, downgraded by verification: UI mis-grouping, narrow trigger) — Failure scenario: in a worktree session (where root recording succeeds), a record_artifact of the workspace root normalizes to project (the basename); an unstamped artifact under <root>/project/... — a top-level directory sharing the workspace's basename, e.g. a nested checkout of the same repo — satisfies 'project/report.pdf'.startsWith('project/') and attaches to the root-recording turn; if a genuine recording of that subdirectory also exists, the same artifact duplicates across both turns.

Witness:

Probe (cwd=/home/user/project, root recording in u1, unstamped 'project/report.pdf'):
  PR: single recording → {"u1":["artifact-1"]}  (attached to root turn)
  PR: + genuine 'project' record → {"u1":["artifact-1"],"u2":["artifact-1"]}  (duplicate)
  FLIP (root → '' instead of basename): {} and {"u2":["artifact-1"]}

Suggested fix: special-case a root parent in isSameWorkspacePathOrChild — when the parent IS the workspace root, match any cwd-relative child instead of comparing against the collapsed basename.

中文说明

[Suggestion] isSameWorkspacePathOrChild 继承了 stripWorkspacePath 的工作区根折叠(path === cwd 时返回 cwd 的「basename」),于是对工作区根的登记引用会被当作对「与工作区同名的顶层子目录」的登记 —— 这是「根目录假阴性」(见 Needs Human Review)的假阳性孪生问题(按 Critical 提交,验证后降级:UI 归组错误,触发条件窄)—— 失败场景:在 worktree 会话中(根登记可成功),对工作区根的 record_artifact 规范化为 project(basename);位于 <root>/project/... 下的未打 toolCallId 产物 —— 与工作区同名的顶层目录(例如同仓库的嵌套 checkout)—— 满足 'project/report.pdf'.startsWith('project/'),被挂到根登记轮次;若该子目录还存在真实登记,同一产物会在两轮重复出现。

证据见上(probe 输出)。

建议修复:isSameWorkspacePathOrChild 中对根目录父项特判 —— 当父项就是工作区根时,匹配任何 cwd 相对子项,而不是与折叠后的 basename 比较。

— qwen3.8-max via Qwen Code /review (v0.21.13)

Comment thread docs/design/session-artifacts-directory-expansion.md Outdated

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed.

Not explored to full depth (tool budget reached): "agent test-matrix": None. All checks completed within the tool budget..

Not reviewed: reverse audit — no auditor was launched with a prompt this skill builds — the pass that hunts what the rest of the review missed ran, if at all, without the method its brief carries.

中文说明

仅完成部分审查,审查缺口已披露。

未探索到全部深度(达到工具调用预算):"agent test-matrix"None. All checks completed within the tool budget.

未审查:反向审计——没有审计 agent 是用本 skill 构建的 prompt 启动的——负责搜寻评审其余部分遗漏问题的这道工序,即便运行过,也缺失了 brief 承载的方法。

— qwen3.8-max via Qwen Code /review (v0.21.13)

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two-stage review summary for PR #9395

Model: deepseek-v4-flash (round 1)
Head reviewed: e54995ce864ea8b759567281732dd2b669271999
Verdict: ✅ No new Critical issues, 2 Suggestion-level findings remain.

Scope

  • 26 files, +1,519 / −103
  • Directory expansion for session artifacts, kind: document for Office files, 3-card collapse in the artifacts panel, turn grouping by folder prefix.

Findings

Dimension Result
Issue Fidelity PR faithfully implements #9385; root cause (client-side contract mismatch) is fixed.
Code-path review (7 chunks) No new Critical findings.
Cross-file tracer Signatures consistent.
Removed-behavior audit Removed behaviors are intentionally superseded.
Build & Test Affected packages build; the only failure is pre-existing (missing Python for node-gyp in an untouched package).
Test Coverage Adequate, with 2 Suggestion-level coverage gaps noted in the detailed review.

Previously raised Critical blockers — verified fixed

  1. Title / workspacePath validation — fixed via per-child try/catch in normalizeInput.
  2. 3-card cap breaking tests — fixed, test updated to drive the show-more toggle.
  3. Path-prefix hijacking — fixed via status filtering, toolCallId gate, and trailing-slash guard.

The detailed review comments (including the Suggestion-level coverage gaps) have been posted inline on this PR.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not reviewed: the executable-script lint — the report is stale or its diff could not be verified; re-run qwen review script-lint.

Not reviewed: reverse audit — stopped before round 1 by the review time budget.

中文说明

仅完成部分审查,审查缺口已披露。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

未审查:the executable-script lint — the report is stale or its diff could not be verified; re-run qwen review script-lint

未审查:反向审计——评审时间预算不足,未能开始第 1 轮。

— qwen3.8-max via Qwen Code /review (v0.21.13)

Comment thread packages/acp-bridge/src/sessionArtifacts.ts
Comment thread packages/core/src/tools/record-artifact.ts Outdated
Comment thread packages/acp-bridge/src/sessionArtifacts.ts Outdated
persistenceStrict,
);
changes.push(...overflowRemoved.removed);
if (overflowRemoved.droppedCreated > 0) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-3: The new droppedCreated overflow warning has no consumer on the only ingest path where it can fire — bridgeClient.upsertAndPublishArtifacts reads result.changes and discards result.warnings (grep-confirmed). — Concrete cost: directory expansion makes store overflow reachable with 2-3 inputs (a hook event reporting three directory artifacts of ≤100 children each creates up to 300 entries in one upsertMany against the 200-artifact cap). Probe: 3×100 expanded files into an empty store → warnings=['dropped 100 newly created artifacts because the store is full'], created changes stripped, store size 200 — no artifact_changed event reaches clients; the drop is observable only in daemon stderr while the session believes the artifacts exist. Fix: surface ingest warnings — e.g. have upsertAndPublishArtifacts log them via writeStderrLine or publish a warning event.

中文说明

[Suggestion] R2-3:新增的 droppedCreated 溢出告警在唯一可能触发的入口路径上没有消费者 —— bridgeClient.upsertAndPublishArtifacts 只读 result.changes,丢弃 result.warnings(grep 已证实)。— 具体代价:目录展开让 store 溢出只需 2-3 个输入即可触发(一个 hook 事件上报 3 个各 ≤100 子项的目录产物,一次 upsertMany 就产生最多 300 条,对上 200 的容量上限)。探针:空 store 一次写入 3×100 个展开文件 → warnings=['dropped 100 newly created artifacts because the store is full'],created 变更被剥离,store 停在 200 —— 客户端收不到任何 artifact_changed 事件;丢弃只出现在 daemon stderr,而会话以为产物都存在。修复:把入口告警透出 —— 例如让 upsertAndPublishArtifacts 通过 writeStderrLine 记录或发布告警事件。

— qwen3.8-max via Qwen Code /review (v0.21.13)

>,
existing: Pick<NormalizedArtifact, 'toolName' | 'source' | 'hookEventName'>,
): boolean {
if (incoming.metadata?.['expandedFromDirectory'] === true) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-4: The expandedFromDirectory display-refresh suppression chain has no test at either end. Location 1 (here, consumer): deleting this guard keeps the full sessionArtifacts suite green (115/115 measured), while re-recording a directory with a new toolCallId then overwrites each child's toolCallId/description — silently moving files out of their original turn. Location 2 (producer, record-artifact.ts:212): no test asserts the flag on the tool's output (repo-wide grep: zero test references to expandedFromDirectory); the new expansion tests use toMatchObject without the metadata key, so dropping or misspelling the flag at the producer ships green and re-opens the same consequence for tool-originated records. Fix: add a store test (record reports/a.xlsx with one toolCallId, upsert the reports directory with a different toolCallId, assert the child keeps its title/description/toolCallId) and assert metadata: { expandedFromDirectory: true } in the existing record-artifact expansion test.

中文说明

[Suggestion] R2-4:expandedFromDirectory 显示刷新抑制链两端都没有测试。位置 1(此处,消费端):删除该守卫后 sessionArtifacts 全套测试仍为绿(实测 115/115),而用新的 toolCallId 重新记录目录会覆盖每个子项的 toolCallId/description —— 把文件悄悄移出它原来的轮次。位置 2(生产端,record-artifact.ts:212):没有任何测试断言工具输出上的该标志(全库 grep:测试对 expandedFromDirectory 零引用);新增的展开测试用的 toMatchObject 不含 metadata 键,因此生产端删掉或写错该标志也能绿灯通过,并对工具来源的记录重新打开同样的后果。修复:补一个 store 测试(先用某个 toolCallId 记录 reports/a.xlsx,再用不同 toolCallId 登记 reports 目录,断言子项保留原 title/description/toolCallId),并在现有 record-artifact 展开测试中断言 metadata: { expandedFromDirectory: true }

— qwen3.8-max via Qwen Code /review (v0.21.13)

Comment thread packages/core/src/tools/record-artifact.ts Outdated
Comment thread packages/acp-bridge/src/sessionArtifacts.ts Outdated
Comment thread packages/acp-bridge/src/sessionArtifacts.ts Outdated
Comment thread packages/acp-bridge/src/sessionArtifacts.ts Outdated
Comment thread packages/acp-bridge/src/sessionArtifacts.ts Outdated
Use the locator's realpath workspace root, map unlistable directories to validation errors, skip junk roots, and keep walk caps/warnings aligned with what was actually recorded.

Co-authored-by: Cursor <cursoragent@cursor.com>
@zjgzx1988
zjgzx1988 requested a review from doudouOUC August 19, 2026 02:23
@zjgzx1988
zjgzx1988 dismissed stale reviews from qwen-code-ci-bot, qwen-code-ci-bot, and qwen-code-ci-bot August 20, 2026 12:57

Addressed on later commits (through cb316e5). Round 8 re-review on current head posted COMMENTED with 0 findings.

yiliang114
yiliang114 previously approved these changes Aug 20, 2026

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review (AI Generated) — Round 3

PR: #9395 fix(artifacts): expand recorded directories into per-file artifacts


❌ CI Blocker — cannot approve

Live Host (macos-latest) is red (run 32439854331):

error: lockfile had changes, but lockfile is frozen
note: try re-running without --frozen-lockfile and commit the updated lockfile

bun.lockb has uncommitted changes. Run bun install at the repo root, commit the updated lockfile, and push.


⏳ Pending / skipped CI

Check Status
Test (ubuntu-latest, Node 22.x) pending
Real daemon E2E / Java 11 pending
Integration Tests (CLI, No Sandbox) skipped — no evidence
Test (windows-latest, Node 22.x) skipped — no evidence
Test (macos-latest, Node 22.x) skipped — no evidence

The PR author's own test matrix flags Linux and Windows as "not tested". The skipped platform jobs leave those dimensions unreviewed.


What I checked at this head

  • workspace-artifact-directory.ts: pathHasSkippedDirectoryComponent correctly uses stripWorktreeArtifactPrefix before the junk-segment check, so worktree-relative paths are handled.
  • inspectWorkspaceCandidate: directory now flows through with isDirectory: true; workspace-root guard fires before toCanonicalWorkspaceArtifactPath.
  • expandWorkspaceDirectoryInput (store side): validation order — title/description/metadata — looks correct; normalizeInput runs on each child after expansion.
  • Existing bot findings R2-5 (metadata-budget overflow after appending expandedFromDirectory marker) and R2-8 (files excluded by isRecordableDerivedChild silently skipped without disclosure) remain open per the bot's round-5+ ledger; I'll defer to the bot's ongoing tracking.

Fix the lockfile and let the pending jobs resolve before merging.

Reviewed with AI assistance.

chiga0
chiga0 previously approved these changes Aug 21, 2026

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

qwen-code-ci-bot pushed a commit that referenced this pull request Aug 21, 2026
zjgzx1988 and others added 2 commits August 21, 2026 11:44
Qwen Live Host CI runs `bun install --frozen-lockfile` with Bun 1.3.9; the checked-in lockfile drifted and fails on main and any PR that touches Live Host path filters. Regenerate with the CI Bun version so install stays reproducible.

Co-authored-by: Cursor <cursoragent@cursor.com>
@zjgzx1988
zjgzx1988 dismissed stale reviews from chiga0 and yiliang114 via 249452c August 21, 2026 03:45
qwen-code-ci-bot pushed a commit that referenced this pull request Aug 21, 2026
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully. The review pipeline failed before a review could be posted. A transient error is retried automatically; if you are seeing this, retry with @qwen-code /review. See workflow logs.

@yiliang114

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 549 passed · 0 failed · 549 total

Flakiness gate: ✅ 10 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:549 通过 · 0 失败 · 549 总计

抖动门:✅ 10 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR 9395 deep verification — fix(artifacts): expand recorded directories into per-file artifacts

Verdict: findings — 549/549 scripted assertions passed (77 harness + matrix-adjudication assertions, 472 targeted-gate tests), no blocking defect in the artifact change; three non-blocking findings on the bundled bun.lock refresh commit and one metadata nit (see Findings). Verified head: 249452c4a58985088712b973ba2a3b7e15580b53 (merge 0cbd5d4c0a, base 54a3a7f669).

中文摘要
  • 结论: findings(非阻塞)。核心改动(目录展开为逐文件产物、office 文档 download-only、脏目录记录不再 available、3 卡折叠)全部经 A/B 与 mock-free harness 证明 load-bearing,549/549 断言通过;三个低严重度发现都在随附的 bun.lock 刷新提交和一个元数据细节上,不阻塞产物改动本身。
  • A/B 结论: 工具层 18 个场景:head 把非空目录展开为逐文件产物(隐藏名/~$ 锁文件/symlink/垃圾目录跳过,100 文件上限与 4 层深度均披露),base(fix(artifacts): verify and canonicalize record_artifact workspace paths #9142)控制臂对同样输入全部 target_is_directory 拒绝;常规文件/缺失/越界/逃逸路径两臂行为逐字节一致。存储层:目录输入经 upsertMany 同样展开(每文件 kind=document),禁用展开的控制臂重现「文件夹本身被存成一张卡」的 pre-PR bug 形态;脏目录记录 head 读为 missing 且 stderr 留有 status_refresh_failed 原因,base 语义控制臂读为 available(即原 bug)。
  • Findings: F1(低)bun.lock 再生引入 1831 个 registry.npmmirror.com tarball URL(基线为 0),与仓库声明的 registry(根 .npmrc = npmjs.org)及 CI 默认 registry 不符;sha512 完整性哈希全部未变(1844 个包逐一比对,0 处换哈希),故为路由/来源问题而非内容篡改。F2(nit)同一提交顺带把 @types/bun/bun-types 1.3.14→1.4.0,提交信息未提及。F3(nit)跨批次合并时 expandedFromDirectory 标记残留在 curated 记录上(同批次两种顺序已被测试钉住;无功能性读取方,下一次显式记录会自愈)。
  • 未覆盖: 逐提交归因(shallow checkout 仅可达 1/14 提交,验证的是聚合 diff);bun.lock 再生本身(容器无 bun,改为身份/哈希审计);真实浏览器 Open/Download 点击流(以单测/DOM 测试 + mock-free 函数级 harness 代替);Windows 路径行为;Live Host bun run live-host:typecheck

Central claim and A/B proof

Central claim: record_artifact on a non-empty directory no longer rejects (TARGET_IS_DIRECTORY, #9142) — it expands into one artifact per recordable file, skipping hidden names, Excel lock files, symlinks, and junk directories, capping at 100 files / 4 levels with disclosure, and never storing the folder path itself.

Control arm = head's compiled dist with exactly two reverted hunks reproducing HEAD^1's #9142 rejection (lstat-level directory rejection + post-stat !st.isFile() rejection; anchors verified unique). Witness: evidence/01-ab-tool-directory-expansion-flip.png.

cell head (PR) control (base #9142) assertions
reports/ (2 xlsx + nested + hidden + ~$ + symlink + node_modules) expands into exactly [reports/nested/deep.txt, reports/q1.xlsx, reports/q2.xlsx], message Expanded directory "reports" into 3 artifacts., children carry expandedFromDirectory, no folder artifact target_is_directory, "is a directory, not a file" 6
empty dir / hidden-only dir target_is_directory "no recordable files" target_is_directory 4
files only deeper than 4 levels rejected with depth disclosure target_is_directory 1
mixed depth records shallow file + "Skipped files deeper than 4 directory levels." 2
120 files exactly 100 recorded + "Recorded the first 100 files." target_is_directory 3
junk root / nested junk component "is a skipped directory and cannot be recorded" target_is_directory 2
regular file / missing / outside / ../ escape / workspace root / symlink-to-dir / worktree subdir / unrecordable + unsafe filenames / stamped-metadata budget identical on both arms where #9142 semantics must hold; head-specific disclosure where expansion applies same 15
total 33/33

Store-side flip (every ingest path shares it). Witness: evidence/02-ab-store-expansion-and-dirty-record.png.

cell head control assertions
directory input via upsertMany 3 per-file artifacts, kind=document, titles by filename, toolCallId/description inherited, folder never stored C2 (expansion disabled): stores the folder itself as one card — the pre-PR bug shape 4
empty dir / non-object metadata / symlink aliasing a skipped tree validation errors with named causes 3
120-file dir 100 created + cap warning surfaced in result.warnings 2
leftover record whose path became a directory (5.5 s > 5 s stat TTL) status missing, stderr keeps action=status_refresh_failed … not a regular file C1 (base status semantics): status available — the original bug 3
curated record then expansion of same file (cross-batch) curated title/toolCallId win; marker survival documented (nit F3) 2
.docxdocument, .csvfile inference as claimed 2
total 16/16

Client-side guards (web-shell source, driven mock-free with real fs where a seam exists): 13-case download-only matrix, extension-first labels (Word/Excel/PowerPoint/CSV), readWorkspaceFileAsBlob refusing type: 'directory' stats with zero byte reads (positive control: real binary file round-trips byte-identical), turn grouping by recorded folder prefix (children with matching/absent toolCallId stay on the turn; later files with other call ids stay on theirs; sibling reports2/ not matched; failed record_artifact creates no reference), and the compiled write_file accept path recording .xlsx as document / .csv as file. 11/11 (evidence/04-boundary-probes.png carries the walker boundary probes, 6/6).

Targeted gates (all green, witness evidence/05-targeted-gates.png): core record-artifact + workspace-artifact-directory + write-file + session-artifact-persistence = 171/171; acp-bridge sessionArtifacts = 126/126; web-shell client/components/artifacts (11 files incl. the new panel/turn/selector tests) = 175/175.

Mutation matrix (witnesses evidence/03-mutation-matrix-live.png, evidence/06-matrix-adjudication.png; full table in logs/mutation-matrix.json): 7/8 single mutants killed with named killing tests (hidden-skip, lock-skip, junk-dir-skip, file cap, depth cap, junk-component gate, plus the M0 positive control proving the harness turns the suite red). The one survivor — removing the walker's explicit entry.isSymbolicLink() guard — is redundant defence, not a coverage gap: Dirent type gates (isFile()/isDirectory() are false for symlink Dirents) independently exclude symlink entries (M3b also survived), while the combination mutant M3c kills exactly skips junk directories, lock files, hidden names, and symlink entries with expected [keep/report.xlsx, link.txt, real.txt] to equal [keep/report.xlsx, real.txt]. Store-side vacuity: reverting the new path is not a regular file throw makes does not mark a leftover directory workspacePath as available fail with expected 'available' not to be 'available' — the new test pins the intended behavior. Adjudication 11/11.

Reviewer Test Plan walkthrough: step 1 ✅ (harness1 dir-basic + harness2 S1); step 2 ✅ at the unit level (G1/G2 + ArtifactPanel tests renders a document artifact as download-only and does not preview it, live browser click-through not driven — see Not covered); step 3 ✅ (empty/missing/escape cells); step 4 ✅ (G5 + write-file gate); step 5 ✅ (TurnOutputs.test.ts caps collapsed outputs at 3 and the DOM test clicks "more artifacts"); step 6 ✅ (G3 zero-byte-read refusal + S4 + does not read workspace bytes when stat says the path is a directory).

Findings

F1 (low) — the bun.lock refresh hard-codes 1,831 registry.npmmirror.com tarball URLs. The base lockfile had 0; the regenerated one embeds mirror URLs for essentially every package, while the repo's declared registry is registry.npmjs.org (root .npmrc) and the Live Host CI (live-host.yml, Bun 1.3.9, bun install --frozen-lockfile on GitHub runners) has no mirror configuration. Future desktop installs will therefore fetch tarballs from npmmirror regardless of environment. What it is not: content tampering — I diffed both lockfiles package-by-package (JSON5-parsed): 1,844 shared entries have identical version and sha512 integrity hashes, 0 hash swaps. Reproduce: grep -c registry.npmmirror.com packages/desktop/bun.lock (1831) vs git show HEAD^1:packages/desktop/bun.lock | grep -c npmmirror (0).

Suggested fix (measured shape, not applied) Regenerate `bun.lock` on a machine whose bun resolves the default registry (or the repo's `.npmrc` registry) so the resolved-URL fields return to `""` as on base; keep the workspace-version and `@​qwen-code/live-host` entries that fix the frozen install. I could not execute this in-container (no `bun` binary), so it is unmeasured — flagging for the author instead.

F2 (nit) — the same commit silently bumps @types/bun/bun-types 1.3.14 → 1.4.0. The commit message describes a regeneration for the frozen install; the version bump rides along unnamed. Not a defect per se (range-satisfying), but a reviewer of a "refresh lockfile" commit should see it.

F3 (nit) — expandedFromDirectory survives cross-batch coalescing onto a curated record. The PR pins both same-batch orders (marker stripped when the curated record arrives after the expansion in one batch); across two upsertMany batches the merged curated artifact retains expandedFromDirectory: true (harness2 S5). No consumer reads the marker outside the store's merge guard, and the next explicit record strips it, so impact is metadata-bag cosmetics only — noted for completeness, not blocking.

Not covered

  • Per-commit attribution: shallow checkout exposes 1 of the snapshot's 14 commits; all evidence is against the aggregate HEAD^1..HEAD diff.
  • bun.lock regeneration not re-executed (no bun in this container); audited by identity/hash census instead, and the frozen-install premise confirmed structurally (base lockfile lacks the @qwen-code/live-host entry and records workspace versions 0.0.1 vs 0.0.5 in-tree). bun run live-host:typecheck not run for the same reason.
  • Live browser Open/Download click-through not driven; client behavior verified at unit/DOM level (gates) plus mock-free function harnesses against real fs.
  • Windows path semantics (backslash fallback, redirector hops) not exercised — Linux container; the PR's own Windows-safe fixtures are untested here.
  • Repo-wide suite and npm run typecheck not re-run (targeted gates only); the PR's CI covers those lanes.
  • The full-base-worktree A/B was abandoned early (the base tree cannot type-check under the shared root node_modules because the @lydell/node-pty paths mapping is tree-relative); the single-hunk-revert control on head's dist replaces it and is named as such in the table.

Methodology

Environment: CI node:22-bookworm container, merge-ref checkout (HEAD merge, HEAD^1 base 54a3a7f669, HEAD^2 head 249452c4a5). Harnesses import the compiled dist of head and of scratch control copies (packages/{core,acp-bridge}/dist-{ctl,c2}-9395, head dist + anchor-verified reverted hunks; pre-existing core symbols imported by the bridge verified unchanged so the bridge controls are clean), driven through the same makeFakeConfig/build().execute() and SessionArtifactStore.upsertMany APIs the tests use, against real temp-dir filesystems; the client harness drives web-shell TS source via tsx with type-only deps erased and real fs stat/read seams. Control arms: core control re-inserts HEAD^1's two directory-rejection hunks; bridge C1 reverts only the getWorkspaceStatus non-file throw (base returned available); bridge C2 = faithful pre-PR store (expansion disabled and base status semantics) and reproduces the reported bug shape (folder stored as an available card). rebuild-controls.mjs in this directory recreates all three from pristine dists. Mutation matrix ran in a scratch worktree (tmp/mutant-tree, symlinked node_modules) with per-mutant anchor checks, unmutated 58/58 controls before and after, and combination rows for layered guards. Raw logs in logs/ (harness runs, matrix runs, per-mutant vitest output, adjudication), harness sources in this directory; evidence PNGs rendered with scripts/verify-capture.mjs. Assertion counts: harness1 33 + harness2 16 + harness3 11 + harness4 6 + matrix adjudication 11 + gate tests 472 = 549 pass, 0 fail.

Flakiness gate log

rounds=5 files=10 skipped=0
file packages/acp-bridge/src/sessionArtifacts.test.ts: (cd packages/acp-bridge) npx --no-install vitest run ./src/sessionArtifacts.test.ts
file packages/core/src/services/session-artifact-persistence.test.ts: (cd packages/core) npx --no-install vitest run ./src/services/session-artifact-persistence.test.ts
file packages/core/src/tools/record-artifact.test.ts: (cd packages/core) npx --no-install vitest run ./src/tools/record-artifact.test.ts
file packages/core/src/tools/write-file.test.ts: (cd packages/core) npx --no-install vitest run ./src/tools/write-file.test.ts
file packages/core/src/utils/workspace-artifact-directory.test.ts: (cd packages/core) npx --no-install vitest run ./src/utils/workspace-artifact-directory.test.ts
file packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/components/artifacts/ArtifactPanel.test.tsx
file packages/web-shell/client/components/artifacts/TurnOutputs.dom.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/components/artifacts/TurnOutputs.dom.test.tsx
file packages/web-shell/client/components/artifacts/TurnOutputs.test.ts: (cd packages/web-shell) npx --no-install vitest run ./client/components/artifacts/TurnOutputs.test.ts
file packages/web-shell/client/components/artifacts/artifactUtils.test.ts: (cd packages/web-shell) npx --no-install vitest run ./client/components/artifacts/artifactUtils.test.ts
file packages/web-shell/client/components/artifacts/turnOutputSelectors.test.ts: (cd packages/web-shell) npx --no-install vitest run ./client/components/artifacts/turnOutputSelectors.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/acp-bridge/src/sessionArtifacts.test.ts: PPPPP
  packages/core/src/services/session-artifact-persistence.test.ts: PPPPP
  packages/core/src/tools/record-artifact.test.ts: PPPPP
  packages/core/src/tools/write-file.test.ts: PPPPP
  packages/core/src/utils/workspace-artifact-directory.test.ts: PPPPP
  packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx: PPPPP
  packages/web-shell/client/components/artifacts/TurnOutputs.dom.test.tsx: PPPPP
  packages/web-shell/client/components/artifacts/TurnOutputs.test.ts: PPPPP
  packages/web-shell/client/components/artifacts/artifactUtils.test.ts: PPPPP
  packages/web-shell/client/components/artifacts/turnOutputSelectors.test.ts: PPPPP

verdict: pass
summary: 10 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/acp-bridge/src/sessionArtifacts.test.ts: P (exit 0)
round 1 · packages/core/src/services/session-artifact-persistence.test.ts: P (exit 0)
round 1 · packages/core/src/tools/record-artifact.test.ts: P (exit 0)
round 1 · packages/core/src/tools/write-file.test.ts: P (exit 0)
round 1 · packages/core/src/utils/workspace-artifact-directory.test.ts: P (exit 0)
round 1 · packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx: P (exit 0)
round 1 · packages/web-shell/client/components/artifacts/TurnOutputs.dom.test.tsx: P (exit 0)
round 1 · packages/web-shell/client/components/artifacts/TurnOutputs.test.ts: P (exit 0)
round 1 · packages/web-shell/client/components/artifacts/artifactUtils.test.ts: P (exit 0)
round 1 · packages/web-shell/client/components/artifacts/turnOutputSelectors.test.ts: P (exit 0)
round 2 · packages/acp-bridge/src/sessionArtifacts.test.ts: P (exit 0)
round 2 · packages/core/src/services/session-artifact-persistence.test.ts: P (exit 0)
round 2 · packages/core/src/tools/record-artifact.test.ts: P (exit 0)
round 2 · packages/core/src/tools/write-file.test.ts: P (exit 0)
round 2 · packages/core/src/utils/workspace-artifact-directory.test.ts: P (exit 0)
round 2 · packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/components/artifacts/TurnOutputs.dom.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/components/artifacts/TurnOutputs.test.ts: P (exit 0)
round 2 · packages/web-shell/client/components/artifacts/artifactUtils.test.ts: P (exit 0)
round 2 · packages/web-shell/client/components/artifacts/turnOutputSelectors.test.ts: P (exit 0)
round 3 · packages/acp-bridge/src/sessionArtifacts.test.ts: P (exit 0)
round 3 · packages/core/src/services/session-artifact-persistence.test.ts: P (exit 0)
round 3 · packages/core/src/tools/record-artifact.test.ts: P (exit 0)
round 3 · packages/core/src/tools/write-file.test.ts: P (exit 0)
round 3 · packages/core/src/utils/workspace-artifact-directory.test.ts: P (exit 0)
round 3 · packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/components/artifacts/TurnOutputs.dom.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/components/artifacts/TurnOutputs.test.ts: P (exit 0)
round 3 · packages/web-shell/client/components/artifacts/artifactUtils.test.ts: P (exit 0)
round 3 · packages/web-shell/client/components/artifacts/turnOutputSelectors.test.ts: P (exit 0)
round 4 · packages/acp-bridge/src/sessionArtifacts.test.ts: P (exit 0)
round 4 · packages/core/src/services/session-artifact-persistence.test.ts: P (exit 0)
round 4 · packages/core/src/tools/record-artifact.test.ts: P (exit 0)
round 4 · packages/core/src/tools/write-file.test.ts: P (exit 0)
round 4 · packages/core/src/utils/workspace-artifact-directory.test.ts: P (exit 0)
round 4 · packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx: P (exit 0)
round 4 · packages/web-shell/client/components/artifacts/TurnOutputs.dom.test.tsx: P (exit 0)
round 4 · packages/web-shell/client/components/artifacts/TurnOutputs.test.ts: P (exit 0)
round 4 · packages/web-shell/client/components/artifacts/artifactUtils.test.ts: P (exit 0)
round 4 · packages/web-shell/client/components/artifacts/turnOutputSelectors.test.ts: P (exit 0)
round 5 · packages/acp-bridge/src/sessionArtifacts.test.ts: P (exit 0)
round 5 · packages/core/src/services/session-artifact-persistence.test.ts: P (exit 0)
round 5 · packages/core/src/tools/record-artifact.test.ts: P (exit 0)
round 5 · packages/core/src/tools/write-file.test.ts: P (exit 0)
round 5 · packages/core/src/utils/workspace-artifact-directory.test.ts: P (exit 0)
round 5 · packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx: P (exit 0)
round 5 · packages/web-shell/client/components/artifacts/TurnOutputs.dom.test.tsx: P (exit 0)
round 5 · packages/web-shell/client/components/artifacts/TurnOutputs.test.ts: P (exit 0)
round 5 · packages/web-shell/client/components/artifacts/artifactUtils.test.ts: P (exit 0)
round 5 · packages/web-shell/client/components/artifacts/turnOutputSelectors.test.ts: P (exit 0)

Evidence images

01-ab-tool-directory-expansion-flip

02-ab-store-expansion-and-dirty-record

03-mutation-matrix-live

04-boundary-probes

05-targeted-gates

06-matrix-adjudication

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

上次阻断项(lockfile 未提交)已修复,所有关键 CI 现在全绿:Test / Live Host / Real daemon E2E / Desktop Shell 均 SUCCESS,mergeable。Approve。

@zjgzx1988
zjgzx1988 added this pull request to the merge queue Aug 21, 2026
Merged via the queue into QwenLM:main with commit 07405e6 Aug 21, 2026
104 of 124 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot has neither a verdict nor a deferral on 249452c4a58985088712b973ba2a3b7e15580b53 — no APPROVED, CHANGES_REQUESTED, or COMMENTED review of its own. A DISMISSED one does not count: dismiss_stale_reviews voids the bot's approval on every push, which is exactly when a fresh one is needed. If this re-run was meant to review or approve, it did not, and an approval left by another account is a separate vote that does not count as the bot's own.

⚠️ 机器人在 249452c4a58985088712b973ba2a3b7e15580b53既没有裁决也没有 defer —— 没有属于它自己的 APPROVEDCHANGES_REQUESTEDCOMMENTED 评审。DISMISSED 不算:dismiss_stale_reviews 会在每次推送时作废机器人的批准,而那恰恰是需要一次新批准的时刻。如果这次重跑本应评审或批准,那么它没有做到;而其他账号留下的批准是另一张票,不能算作机器人自己的。

The stage comments above were updated with the latest result. View workflow run.

上方各阶段评论已更新为最新结果。查看工作流运行

wenshao added a commit to wenshao/qwen-code that referenced this pull request Aug 21, 2026
bun.lock conflict: main's 07405e6 (QwenLM#9395) regenerated the desktop
lockfile properly (npmmirror URLs, @types/bun 1.4.0, live-host entry) —
take main's version; this branch's earlier regenerated copy was made
with a local bun whose registry config differs from CI.

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final verification pass at 249452 (post-rebase delta + bun.lock refresh). The feature code checks out:

  • Walker caps: the 100-file cap warns at record time, and the earlier silent depth-4 truncation is fixed — over-deep directories now set depthLimited and surface 'files deeper than 4 levels' via a warning or dedicated error in both the tool and bridge paths.
  • Turn grouping: isSameWorkspacePathOrChild requires a separator boundary, so a recorded /a/b cannot claim /a/b-x; expanded files stay attached to the recording turn via toolCallId.
  • Directory records never reach the file-content APIs: expansion happens at record time on both tool and bridge paths, Open/Download stat-refuse directories, and office-kind detection shares one extension set between write-file and bridge inference (csv stays text-previewable).

Security pass is clean: expanded paths are containment-proven via realpath + path.relative/isOutsidePath rather than string prefix, symlinks are skipped via dirent lstat semantics before any descent, non-regular files cannot become artifacts, and the previously flagged unguarded lstat is try/catch-guarded at this head.

I also independently reproduced the F1 lockfile comparison: all 1831 new registry.npmmirror.com URLs are routing-only — the sha512 set differs solely by the @types/bun/bun-types 1.3.14→1.4.0 bump, no content changes.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.22.0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(artifacts): directory workspacePath is recorded as kind:file and open/download returns 400

5 participants