Skip to content

fix(core): auto-record artifact file writes - #7914

Merged
chiga0 merged 10 commits into
QwenLM:mainfrom
chiga0:fix/artifact-auto-record-write-file
Jul 29, 2026
Merged

fix(core): auto-record artifact file writes#7914
chiga0 merged 10 commits into
QwenLM:mainfrom
chiga0:fix/artifact-auto-record-write-file

Conversation

@chiga0

@chiga0 chiga0 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

This PR makes successful file writes for artifact-like workspace outputs emit structured artifact metadata directly in the tool result. The user-facing note now says the file was already recorded instead of asking the model to perform a second registration step.

The metadata stays workspace-scoped and metadata-only. It does not publish HTML, upload files, open a browser, or change the existing publishing flow for interactive artifacts.

Why it's needed

Artifact recording was still probabilistic for generated HTML reports because the file write only reminded the model to call the registration tool afterwards. If the model ended the turn immediately, the artifacts panel never received metadata even though the file had been created successfully.

Returning artifact metadata from the successful write path makes the common generated-HTML case deterministic while preserving the explicit disable behavior, workspace-boundary checks, and worktree path contract.

Reviewer Test Plan

How to verify

Ask the model to create an HTML report using the normal file-write path. The resulting tool call should include a workspace artifact with kind html, storage workspace, the workspace-relative path, MIME type, and size metadata without requiring a follow-up registration tool call. Verify that ordinary source files, files outside the workspace, and artifact-disabled sessions do not emit artifact metadata.

Evidence (Before & After)

Before: a generated HTML file could be written successfully, but no artifact appeared unless the model chose to make a second registration call. After: the successful file write itself carries the artifact metadata, so the artifacts panel can update from the same tool result.

Local validation:

cd packages/core && npx vitest run src/tools/write-file.test.ts
cd packages/cli && npx vitest run src/serve/routes/workspace-file-read.test.ts
npx prettier --check packages/core/src/tools/write-file.ts packages/core/src/tools/write-file.test.ts packages/cli/src/serve/routes/workspace-file-read.test.ts
git diff --check
npm run typecheck --workspace=packages/core
npm run build --workspace=packages/core

npm run typecheck --workspace=packages/cli was also attempted after rebuilding core, but it still fails on existing ACP bridge/replay type mismatches that are unrelated to this change, including missing sessionMcpRuntimeAdd, sessionMcpRuntimeRemove, TODO_STOP_GUARD_CONTINUATION_CLAIM_METHOD, and goalState type fields.

Tested on

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

Environment (optional)

Local macOS development checkout with package-targeted Vitest, Prettier, TypeScript typecheck, and core build commands.

Risk & Scope

  • Main risk or tradeoff: generated artifact titles default to the filename because this intentionally avoids reading or parsing output contents for richer metadata.
  • Not validated / out of scope: files generated through shell commands or other non-file-write paths are not auto-recorded by this change; interactive HTML publishing remains an explicit artifact publishing flow.
  • Breaking changes / migration notes: none expected. Existing artifact disable and SDK-mode behavior remain respected.

Linked Issues

N/A

中文说明

What this PR does

这个 PR 让成功写入的、看起来像 Artifact 的 workspace 文件直接在工具结果里返回结构化 artifact metadata。面向模型的提示也从“请再执行一次注册”改为“文件已经自动记录”。

这些 metadata 仍然只表示 workspace 内的元数据。它不会发布 HTML、上传文件、打开浏览器,也不会改变现有交互式 Artifact 的发布流程。

Why it's needed

之前生成 HTML 报告时,Artifact 记录仍然是概率行为:文件写入成功后,系统只是提醒模型后续调用注册工具。如果模型直接结束当前轮次,Artifacts 面板就收不到 metadata,即使文件已经成功创建。

让成功写入路径直接返回 artifact metadata,可以让常见的 HTML 生成场景变成确定性行为,同时保留显式关闭、workspace 边界检查和 worktree 路径约定。

Reviewer Test Plan

How to verify

让模型通过普通文件写入路径创建一个 HTML 报告。对应工具调用应该直接包含一个 workspace artifact,字段包括 kind html、storage workspace、workspace-relative path、MIME type 和 size metadata,并且不需要后续注册工具调用。还应验证普通源码文件、workspace 外文件以及 artifact 关闭场景不会返回 artifact metadata。

Evidence (Before & After)

Before:HTML 文件可以成功写出,但除非模型额外执行一次注册调用,否则不会出现在 Artifacts 面板。After:成功写入本身携带 artifact metadata,Artifacts 面板可以从同一个工具结果更新。

本地验证:

cd packages/core && npx vitest run src/tools/write-file.test.ts
cd packages/cli && npx vitest run src/serve/routes/workspace-file-read.test.ts
npx prettier --check packages/core/src/tools/write-file.ts packages/core/src/tools/write-file.test.ts packages/cli/src/serve/routes/workspace-file-read.test.ts
git diff --check
npm run typecheck --workspace=packages/core
npm run build --workspace=packages/core

重建 core 后也尝试过 npm run typecheck --workspace=packages/cli,但它仍然因为现有 ACP bridge/replay 类型不一致失败,错误包括缺少 sessionMcpRuntimeAddsessionMcpRuntimeRemoveTODO_STOP_GUARD_CONTINUATION_CLAIM_METHODgoalState 等字段;这些与本次改动无关。

Tested on

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

Environment (optional)

本地 macOS 开发 checkout,执行了 package-targeted Vitest、Prettier、TypeScript typecheck 和 core build 命令。

Risk & Scope

  • Main risk or tradeoff: 自动生成的 artifact 标题默认使用文件名,因为这里刻意避免读取或解析输出内容来生成更丰富的 metadata。
  • Not validated / out of scope: 通过 shell 命令或其他非文件写入路径生成的文件不在这次自动记录范围内;交互式 HTML 发布仍然需要显式发布流程。
  • Breaking changes / migration notes: 预计没有。现有 artifact 显式关闭和 SDK-mode 行为保持不变。

Linked Issues

N/A

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finished — CI landed green on b9ae027 and the deferred approval was posted. finalize run

Qwen Triage 已完成 —— b9ae027 的 CI 全绿,延迟审批已提交。查看 finalize 运行

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: the issue is real and structural — artifact recording depends on the model making a second record_artifact tool call after a successful write, and if the model ends the turn early, the artifacts panel never gets metadata. This is an observed limitation of the reminder-based approach, not a theoretical concern.

Direction: aligned. Making artifact recording deterministic for the common file-write path is a natural improvement to an existing feature. It doesn't expand scope — it tightens a probabilistic mechanism into a reliable one.

Size: 78 production lines (write-file.ts), 84 test lines. Core path (packages/core/src/tools/) touched — well under the 500-line threshold. No escalation needed.

Approach: the scope feels right. Extracting the workspace-path logic into a shared helper and returning structured ToolArtifact metadata from the write result is the minimal change that solves the problem. The record_artifact tool remains available for explicit registration. No unrelated edits or drive-by refactors.

Risk: no elevated risk signals.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:问题是真实且结构性的——Artifact 记录依赖模型在成功写入后再调用一次 record_artifact 工具,如果模型提前结束轮次,Artifacts 面板就收不到 metadata。这是提醒机制的已知局限,不是理论性问题。

方向:对齐。让文件写入路径的 Artifact 记录变成确定性行为,是对现有功能的自然改进,不扩大范围。

规模:78 行生产代码(write-file.ts),84 行测试代码。触及核心路径(packages/core/src/tools/),远低于 500 行阈值,无需升级。

方案:范围合理。将 workspace-path 逻辑提取为共享辅助函数,并在写入结果中返回结构化 ToolArtifact metadata,是解决问题的最小改动。record_artifact 工具仍可用于显式注册。无无关改动。

风险:无升级风险信号。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Code Review

The approach is what I'd have done independently: extract the workspace-path guard into a shared helper, build a ToolArtifact on the successful write path, and return it in ToolResult.artifacts — which the scheduler and ACP session layer already consume. The implementation matches existing patterns (record-artifact.ts and image-gen.ts return artifacts the same way).

A few things done well:

  • postWriteSizeBytes piggybacks on the existing fs.statSync call — zero extra I/O.
  • The buildRecordArtifactReminder export keeps its signature and workspacePath format, so the cross-package contract pinned by workspace-file-read.test.ts is preserved. The changes in that test file are comment-only.
  • inferWorkspaceArtifactKind covers exactly the ARTIFACT_LIKE_EXTENSIONS set; the default: 'file' branch is unreachable but harmless.

No critical blockers found. No convention violations.

Testing

CI is still running on b9ae027875bc65c529050f0f6f5be52fbe88547d. Current status:

Final CI results for b9ae027 (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Classify PR ✅ success
macos-latest / Java 21 ✅ success
Real daemon E2E / Java 11 ✅ success
route ✅ success
Serve A/B (ubuntu-latest, Node 22.x) ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
ubuntu-latest / Java 11 ✅ success
ubuntu-latest / Java 17 ✅ success
ubuntu-latest / Java 21 ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
windows-latest / Java 21 ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

No failures so far. The macOS/Windows test jobs and integration tests are skipped (likely gated on the ubuntu unit suite passing first). The author reports write-file.test.ts and workspace-file-read.test.ts pass locally, plus typecheck and build for packages/core — that's the author's claim, not independently verified here.

Not verified: real-scenario tmux testing (unattended CI run — a maintainer can trigger the isolated @qwen-code /tmux job if behavioral verification is desired, though this change is metadata-only and well-covered by unit tests).

中文说明

代码审查

实现方案与我的独立评估一致:将 workspace-path 守卫提取为共享辅助函数,在成功写入路径上构建 ToolArtifact,并通过 ToolResult.artifacts 返回——scheduler 和 ACP session 层已经消费这个字段。实现遵循现有模式(record-artifact.tsimage-gen.ts 以相同方式返回 artifacts)。

几个做得好的地方:

  • postWriteSizeBytes 复用已有的 fs.statSync 调用,零额外 I/O。
  • buildRecordArtifactReminder 导出保持签名和 workspacePath 格式不变,workspace-file-read.test.ts 固定的跨包契约得以保留。该测试文件的改动仅为注释。
  • inferWorkspaceArtifactKind 覆盖的范围与 ARTIFACT_LIKE_EXTENSIONS 集合完全一致;default: 'file' 分支不可达但无害。

未发现关键阻塞问题。无规范违反。

测试

CI 仍在运行中。目前无失败。macOS/Windows 测试和集成测试被跳过(可能依赖 ubuntu 单元测试先通过)。作者报告本地测试通过——这是作者的声明,此处未独立验证。

未验证:真实场景 tmux 测试(无人值守 CI 运行——如需行为验证,维护者可触发隔离的 @qwen-code /tmux 任务,但此改动仅涉及 metadata,单元测试已充分覆盖)。

Qwen Code · qwen3.8-max-preview

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — clean, minimal change that follows existing patterns; CI still pending.

This PR does one thing and does it well: it turns a probabilistic artifact-recording mechanism (model must remember to call record_artifact) into a deterministic one (the write tool returns structured metadata directly). The refactoring is tidy — the workspace-path guard is extracted into a shared helper, the new buildWorkspaceArtifactMetadata follows the same ToolArtifact contract used by record-artifact.ts and image-gen.ts, and the existing cross-package contract test is preserved with only comment updates.

Every change in the diff is necessary for the stated goal. No scope creep, no drive-by refactors. The test coverage is thorough — positive cases, negative cases (disabled, non-artifact, outside workspace), case-insensitivity, and the worktree path contract. If I had to maintain this in six months, I'd thank the author.

Approval deferred until CI lands green on b9ae027875bc65c529050f0f6f5be52fbe88547d.

中文说明

置信度:4/5 —— 干净、最小的改动,遵循现有模式;CI 仍在运行中。

这个 PR 只做一件事并且做得很好:将概率性的 Artifact 记录机制(模型需要记住调用 record_artifact)变为确定性的(写入工具直接返回结构化 metadata)。重构整洁——workspace-path 守卫被提取为共享辅助函数,新的 buildWorkspaceArtifactMetadata 遵循与 record-artifact.tsimage-gen.ts 相同的 ToolArtifact 契约,现有的跨包契约测试仅更新注释即保留。

diff 中的每个改动都是目标所必需的。无范围蔓延,无顺手重构。测试覆盖全面——正向用例、反向用例(禁用、非 Artifact、workspace 外)、大小写不敏感、以及 worktree 路径契约。

批准推迟至 CI 在 b9ae027875bc65c529050f0f6f5be52fbe88547d 上全部通过后执行。

Qwen Code · qwen3.8-max-preview

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

@chiga0

chiga0 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /takeover

@qwen-code-dev-bot qwen-code-dev-bot added the autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) label Jul 28, 2026
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head 9379f5b, 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

@yiliang114

yiliang114 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Recovered after restart; restarting task

@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.

Reviewed. Suggestions are inline.

中文说明

已审查。 建议见行内评论。

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/tools/write-file.ts Outdated

@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.

LGTM, looks ready to ship — CI landed green after the review. ✅

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. Remove the autofix/takeover label (or comment @qwen-code /takeover stop) to release.

中文说明

🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。移除 autofix/takeover 标签(或评论 @qwen-code /takeover stop)即可释放。

@qwen-code-dev-bot

qwen-code-dev-bot commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

AutoFix round 5 finishedview run. See this round's report below.

中文说明

AutoFix 第 5 轮已完成 —— 查看运行。本轮报告见下方。

…7914)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 1/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 1/100 轮)。改动内容与我反驳保留之处如下:

Review feedback addressed

[rc:3663994649] inferWorkspaceArtifactKind kind groups untested — Resolved

Feedback: Only .html/.htm'html' had test coverage; .ipynb'notebook', .pdf'pdf', and image extensions → 'image' had none.

Decision: Implement. This is a valid, in-scope Suggestion — the kind mapping is a contract with the artifact panel renderer and a mis-mapping would ship silently.

Change: Added a parameterized it.each test in packages/core/src/tools/write-file.test.ts covering all seven previously-untested extension→kind pairs (.ipynb, .pdf, .png, .jpeg, .jpg, .svg, .webp). Each case writes a file and asserts the artifact's kind, title, storage, and workspacePath. The existing .html/.htm cases remain covered by the pre-existing tests.

No conflict resolution was needed (--conflict false).

Verification

  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • vitest run src/tools/write-file.test.ts (packages/core, touched) — 65 passed (7 new parameterized cases)
中文说明

已处理的审查反馈

[rc:3663994649] inferWorkspaceArtifactKind 各 kind 分组缺少测试 — 已解决

反馈内容: 此前只有 .html/.htm'html' 有测试覆盖;.ipynb'notebook'.pdf'pdf' 以及图片扩展名 → 'image' 均无测试。

决定: 实施。这是一条有效且在范围内的建议——kind 映射是 artifact 面板渲染器的契约,一旦映射错误将会静默上线。

变更:packages/core/src/tools/write-file.test.ts 中新增了一个参数化 it.each 测试,覆盖全部七个此前未测试的扩展名→kind 映射(.ipynb.pdf.png.jpeg.jpg.svg.webp)。每个用例写入一个文件并断言 artifact 的 kindtitlestorageworkspacePath。原有的 .html/.htm 用例仍由既有测试覆盖。

无需解决冲突(--conflict false)。

验证

  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • vitest run src/tools/write-file.test.ts(packages/core,涉及文件)— 65 个测试通过(新增 7 个参数化用例)

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

@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.

⚠️ Downgraded from Approve to Comment: CI still running. Reviewed.

中文说明

⚠️ 已从批准降级为评论:CI still running。 已审查。

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Maintainer local verification — real daemon, not just unit tests

Verified head 866fe07f88 (base b3873571aa) on macOS. The triage bot explicitly left "real-scenario testing: not verified", so this round is a live qwen serve A/B plus a mutation audit of the new tests.

Verdict: the PR does what it claims, deterministically. Recommend merge. One behavioural side effect is worth a line in the PR body or a follow-up — details in §4.


1. Live daemon A/B — the claim under real conditions

A real qwen serve daemon (built dist, real ACP child, real write_file) bound to a temp workspace, with the repo's own integration-tests/fake-openai-server.ts as the model — no API key, no network. The scripted model writes reports/quarterly.html and ends the turn without ever calling record_artifact, which is precisely the failure mode this PR targets.

The two arms are the same daemon build. The only thing that changes is packages/core/src/tools/write-file.ts, recompiled through the identical build_package.js pipeline. Verified single-variable: of the 3409 emitted dist files, only write-file.{js,d.ts,js.map} differ between arms.

live daemon A/B

BASE b3873571 HEAD 866fe07f
GET /session/:id/artifacts after the HTML write [] — file on disk, nothing in the panel 1 artifacttitle quarterly.html, kind html, storage workspace, mimeType text/html, sizeBytes 151, toolName write_file
model-facing tool result "…call record_artifact with workspacePath …" "…automatically recorded … No extra artifact registration step is needed."
GET /file?path=reports/quarterly.html n/a 200, body round-trips

toolName: "write_file" on the stored artifact is the direct proof the entry came from the write itself.

The empty list on BASE is a real absence, not a broken probe. Step 5 of the driver has the model call record_artifact explicitly; on BASE that produces an artifact through the same endpoint. The oracle works in both arms.

raw driver output

2. Live negative controls and idempotency

Run against the same session, so these are not separate-fixture claims:

  • Model writes src/index.tsno new artifact, and no note in the tool result. Ordinary source stays out of the panel.
  • Model rewrites the same HTML → still exactly 1 artifact, deduped on workspacePath (updatedAt bumps, sha256 changes). Repeated report regeneration will not flood the panel.

3. Do the new tests actually pin the behaviour?

Ten mutations against the PR source, each asserted to have textually landed, then the PR's own write-file.test.ts run unchanged. 9 / 10 caught.

mutation matrix

Base-overlay differential (PR tests + BASE source, PR test files kept): 10 failed / 55 passed, so ten of the new assertions genuinely discriminate. With PR source: 65 / 65. packages/cli/.../workspace-file-read.test.ts: 35 / 35 against the built PR core (it imports buildRecordArtifactReminder through @qwen-code/qwen-code-core, so a symlinked node_modules would have silently tested main — it was run against the PR's own build). prettier --check and git diff --check clean.

The one survivor is .htm: it is in ARTIFACT_LIKE_EXTENSIONS but is the only allow-listed extension whose kind mapping no test exercises — flipping it to 'file' leaves all 65 tests green. One extra tuple in the existing it.each closes it. Non-blocking.

4. New finding — an auto-recorded artifact shadows a later curated record_artifact

Measured, same script, both arms (step 5 in the screenshot):

BASE HEAD
after record_artifact({title:'manual-positive-control', description:'Q3 revenue deep-dive, curated title'}) title = manual-positive-control, description present title stays quarterly.html, description undefined
what the tool told the model Recorded artifact "manual-positive-control". Recorded artifact "manual-positive-control".

The cause is pre-existing and not a bug introduced here: mergeArtifact in packages/acp-bridge/src/sessionArtifacts.ts only replaces title/description on a published upgrade, so for two storage: 'workspace' upserts on the same workspacePath the first writer wins. What this PR changes is who writes first — before, write_file never created an entry, so the model's curated title landed; now the filename-titled entry is always there already.

Practical effect: for auto-recorded workspace artifacts the model has no working way to supply a better title or a description, and the attempt silently no-ops while reporting success. The PR body notes titles "default to the filename"; it does not note that they can no longer be improved afterwards.

Not a merge blocker — the tool result now tells the model no follow-up call is needed, so most turns will not attempt it. Worth either a sentence in the Risk & Scope section or a small follow-up (let a same-workspacePath upsert from record_artifact refresh title/description, or have the tool report that the field was ignored).

5. Smaller notes

  • inferWorkspaceArtifactKind's default: return 'file' is unreachable — every member of ARTIFACT_LIKE_EXTENSIONS is handled explicitly. Already spotted in triage; harmless.
  • buildRecordArtifactReminder is still exported from packages/core/src/index.ts under that name, but it no longer builds a record_artifact reminder. The doc comment was updated; the identifier reads stale for a public export.
  • CI on the head: 20 success, 19 skipped, 0 failures.
Harness details (reproducible)
  • Isolated worktree at PR head; a separate APFS clone of a main checkout as the E2E tree, so the daemon runs a real build rather than a symlinked one.
  • Driver: integration-tests/pr7914-driver.tsspawnDaemon-style boot on port 0 with --token, approvalMode: 'yolo', isolated HOME/QWEN_HOME, security.folderTrust.enabled: false, OPENAI_BASE_URL pointed at the fake server. The fake model branches statelessly on a prompt marker plus a content sentinel, so retries cannot double-fire and a later turn in the same session is not confused by an earlier turn's role:"tool" message.
  • Two harness-side traps that would have produced a false negative, both fixed before the numbers above were taken: macOS /var/private/var (an unresolved workspace path falls outside the bound workspace and suppresses artifact metadata for reasons that have nothing to do with the PR), and a stale mime@4 at the repo root where the lockfile wants 2.6.0 under superagent (breaks supertest collection, unrelated to this change). The tsc --build type errors seen locally are the pre-existing MCP-SDK/zod mismatch; dist still emits.
中文说明

维护者本地验证 —— 真实 daemon,而不只是单元测试

在 macOS 上验证 head 866fe07f88(base b3873571aa)。triage bot 明确写了"real-scenario testing: not verified",所以这一轮做的是 真实 qwen serve 的 A/B,外加对新增测试的变异审计。

结论:PR 确实做到了它声称的事,而且是确定性的。建议合并。 有一个行为副作用值得在 PR 描述里补一句或者留个 follow-up,见第 4 节。

1. 真实 daemon A/B —— 在真实条件下检验主张

真实的 qwen serve(构建产物 dist、真实 ACP 子进程、真实 write_file)绑定到临时 workspace,模型侧用仓库自带的 integration-tests/fake-openai-server.ts——不需要 API key,不走网络。脚本化的模型写出 reports/quarterly.html 之后直接结束回合,从不调用 record_artifact,这正是本 PR 针对的失败模式。

两个 arm 用的是同一份 daemon 构建,唯一变化的是 packages/core/src/tools/write-file.ts,并且经过完全相同的 build_package.js 流程重新编译。单变量已验证:3409 个 dist 产物文件中,只有 write-file.{js,d.ts,js.map} 不同。

BASE b3873571 HEAD 866fe07f
写完 HTML 后的 GET /session/:id/artifacts [] —— 文件在磁盘上,面板里什么都没有 1 个 artifact —— title quarterly.htmlkind htmlstorage workspacemimeType text/htmlsizeBytes 151toolName write_file
模型看到的工具结果 "…call record_artifact with workspacePath …" "…automatically recorded … No extra artifact registration step is needed."
GET /file?path=reports/quarterly.html 不适用 200,内容可往返

存储下来的 artifact 上的 toolName: "write_file" 直接证明这条记录来自写入本身。

BASE 上的空列表是真实的"没有",不是探针坏了。 driver 的第 5 步让模型显式调用 record_artifact;在 BASE 上这会通过同一个端点产出 artifact。也就是说这个 oracle 在两个 arm 里都是工作的。

2. 真实的负向对照与幂等性

以下都在同一个 session 内跑,不是各自独立的 fixture:

  • 模型写 src/index.ts没有新增 artifact,工具结果里也没有那句提示。普通源码不会进面板。
  • 模型重写同一个 HTML → 仍然只有 1 个 artifact,按 workspacePath 去重(updatedAt 更新,sha256 变化)。反复重新生成报告不会把面板刷爆。

3. 新增的测试真的钉住行为了吗?

对 PR 源码做了十次变异,每次都断言变异确实落地,然后原封不动跑 PR 自己的 write-file.test.ts9 / 10 被抓到。

Base-overlay 差分(PR 测试 + BASE 源码,保留 PR 的测试文件):10 失败 / 55 通过,说明其中十条新断言确实有区分力。用 PR 源码:65 / 65packages/cli/.../workspace-file-read.test.ts:对构建后的 PR core 跑出 35 / 35(它通过 @qwen-code/qwen-code-core 导入 buildRecordArtifactReminder,所以如果用 symlink 的 node_modules,测的其实是 main——这里是对 PR 自己的构建产物跑的)。prettier --checkgit diff --check 均干净。

唯一存活的变异是 .htm:它在 ARTIFACT_LIKE_EXTENSIONS 里,却是唯一一个 kind 映射没有任何测试覆盖的后缀——把它改成 'file',65 个测试依然全绿。在现有 it.each 里加一条元组即可。不阻塞合并。

4. 新发现 —— 自动记录的 artifact 会屏蔽后续精心命名的 record_artifact

同一脚本、两个 arm 的实测结果(截图中的第 5 步):

BASE HEAD
调用 record_artifact({title:'manual-positive-control', description:'Q3 revenue deep-dive, curated title'}) 之后 title = manual-positive-control,description 存在 title 仍是 quarterly.html,description 为 undefined
工具告诉模型的结果 Recorded artifact "manual-positive-control". Recorded artifact "manual-positive-control".

根因是既有逻辑,并非本 PR 引入:packages/acp-bridge/src/sessionArtifacts.ts 里的 mergeArtifact 只在 published 升级时替换 title/description,所以对同一 workspacePath 的两次 storage: 'workspace' upsert,先写入者胜出。本 PR 改变的是谁先写入——以前 write_file 从不建条目,模型精心取的标题就能落地;现在以文件名为标题的条目总是已经在那里了。

实际影响:对自动记录的 workspace artifact,模型没有任何可用途径去提供更好的标题或描述,而且这次尝试会静默失效、却仍然报告成功。PR 描述提到标题"默认使用文件名",但没有提到之后也无法再改。

不阻塞合并——工具结果现在已经告诉模型不需要后续调用,所以大多数回合不会去尝试。建议要么在 Risk & Scope 里补一句,要么做个小的 follow-up(让来自 record_artifactworkspacePath 相同的 upsert 能刷新 title/description,或者让工具明确告知该字段被忽略了)。

5. 其他小点

  • inferWorkspaceArtifactKinddefault: return 'file' 不可达——ARTIFACT_LIKE_EXTENSIONS 的每个成员都被显式处理了。triage 已经指出,无害。
  • buildRecordArtifactReminder 仍以该名字从 packages/core/src/index.ts 导出,但它已经不再构建 record_artifact 提示了。文档注释更新了,但作为公开导出,这个标识符名字读起来已经过时。
  • head 上的 CI:20 成功、19 跳过、0 失败
验证环境细节(可复现)
  • 在 PR head 上开独立 worktree;另外用 APFS clone 出一个 main 检出作为 E2E 树,让 daemon 跑真实构建而不是 symlink 拼出来的树。
  • Driver:integration-tests/pr7914-driver.ts —— 仿 spawnDaemon 的 port 0 + --token 启动,approvalMode: 'yolo',隔离的 HOME/QWEN_HOMEsecurity.folderTrust.enabled: falseOPENAI_BASE_URL 指向 fake server。fake 模型按 prompt marker 加内容 sentinel 做无状态分支,所以重试不会重复触发,同一 session 里后续回合也不会被前一回合的 role:"tool" 消息干扰。
  • 有两个会造成假阴性的 harness 陷阱,在取上述数据之前已经修掉:macOS 的 /var/private/var(未解析的 workspace 路径会落在绑定 workspace 之外,从而以与本 PR 无关的原因抑制 artifact metadata),以及仓库根部残留的 mime@4(lockfile 要求 superagent 下用 2.6.0,否则 supertest 收集阶段就失败,与本改动无关)。本地看到的 tsc --build 类型错误是既有的 MCP-SDK/zod 不匹配问题;dist 仍能正常产出。

@chiga0
chiga0 requested a review from qwen-code-ci-bot July 28, 2026 11:24

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Review — LGTM (no blockers)

Reviewed at HEAD 866fe07. This is a self-PR, so posting as COMMENT rather than APPROVE. No Critical or Major issues found. The change correctly makes artifact recording deterministic for artifact-like workspace writes by reusing the existing ToolResult.artifacts pipeline that record_artifact already depends on.

What I verified

Artifact consumption chain is real, not aspirational: The artifacts: [artifact] returned from the write success path flows through the same infrastructure as an explicit record_artifact call — coreToolScheduler collects toolResult.artifacts into the response (lines 4595-4596 / 4757-4758), Session.ts forwards them via toolCallEmitter.emitResult({ artifacts }) (line 8193), and the daemon/session surfaces consume them as metadata (per the ToolResult.artifacts doc contract). So the new "automatically recorded … no extra artifact registration step is needed" message is accurate, and telling the model to skip the second call prevents a redundant record_artifact round-trip.

Type and gate consistency: The constructed object matches ToolArtifact exactly (title/kind/storage:'workspace'/workspacePath/mimeType/sizeBytes, all valid union members). ARTIFACT_LIKE_EXTENSIONS and inferWorkspaceArtifactKind agree on the extension set — every gated extension maps to a specific kind. The artifact and the reminder are co-gated through getRecordArtifactWorkspacePath, so they can never diverge (reminder present ⇔ artifact present).

Contract preservation: The worktree-anchoring logic moved into getRecordArtifactWorkspacePath unchanged, and the cross-package workspacePath contract with the daemon's GET /file route remains pinned by workspace-file-read.test.ts. I confirmed the test's emittedWorkspacePath regex (/workspacePath "([^"]+)"/) still matches the new message format, so the round-trip pin is intact despite the wording change. buildRecordArtifactReminder's public export status is unchanged; its contract is the workspacePath, not the message text.

Edge cases: postWriteSizeBytes is best-effort (undefined on stat failure, and sizeBytes is optional); files outside the workspace, ordinary source files, and artifact-disabled sessions all correctly yield no artifact and no reminder; case-insensitive extensions (.HTML) are handled via toLowerCase().

Cross-Validation

Finding Other Reviewer My Assessment
Kind-group test coverage for inferWorkspaceArtifactKind (write-file.ts:734) ci-bot (Suggestion) OBSOLETE — commit 866fe07 adds an it.each covering all kind groups (.ipynb→notebook, .pdf→pdf, .png/.jpeg/.jpg/.svg/.webp→image); verified in the test patch

The ci-bot's earlier APPROVE (4795357028, since dismissed on the new commit) and the follow-up "CI still running" comment are consistent with a clean review; the single inline suggestion was addressed by the test commit.

Additional Audit Coverage

  • Double-recording adversarial check: if a model ignored the "no extra step needed" note and still called record_artifact with the same workspacePath, the panel would see the path twice — but the message is explicit, the pre-PR behavior already relied on model cooperation, and this change is strictly more deterministic. Not a blocker.
  • getRecordArtifactWorkspacePath is invoked twice (once for metadata, once for the reminder). Deterministic and cheap (string ops + a config flag); the current separation keeps each builder self-contained. Nit at most.
  • inferWorkspaceArtifactKind's default: 'file' is unreachable through the write path (the gate only admits extensions with specific kinds) but makes the function total — harmless and sensible if reused.
  • Failure path: artifact emission lives inside the success try block after the write completes; a failed write takes the catch branch and emits nothing. Correct.

@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.

Reviewed — no blockers. Suggestions are inline.

中文说明

已审查——无阻断问题。 建议见行内评论。

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/tools/write-file.ts Outdated
doudouOUC
doudouOUC previously approved these changes Jul 28, 2026

@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.

No issues found. LGTM! ✅

中文说明

未发现问题。LGTM!✅

— qwen3.7-max via Qwen Code /review

yiliang114
yiliang114 previously approved these changes Jul 28, 2026

@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.

LGTM

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 2/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 2/100 轮)。改动内容与我反驳保留之处如下:

Round summary

One test-only change addressing the maintainer's mutation-audit finding. No production code modified.

Feedback triage

# Source Finding Decision Action
1 CI bot review Downgraded from Approve to Comment (CI still running) Informational None — CI has since completed (20 success, 0 failures per maintainer)
2 @wenshao §3 .htm is the only allow-listed extension whose kind mapping no test exercises — mutation survivor Suggestion — implemented Added ['page.htm', 'html'] to the it.each in write-file.test.ts (1 line). Test count 65 → 66, all pass
3 @wenshao §4 Auto-recorded artifact shadows a later curated record_artifact (first-writer-wins on same workspacePath) Acknowledged — no code change Maintainer explicitly states "Not a merge blocker" and "Recommend merge." Root cause is pre-existing mergeArtifact semantics in sessionArtifacts.ts, not introduced by this PR. Deferred to a follow-up per maintainer's suggestion
4 @wenshao §5 inferWorkspaceArtifactKind's default: return 'file' is unreachable Informational None — maintainer confirms "harmless"; every ARTIFACT_LIKE_EXTENSIONS member is handled explicitly
5 @wenshao §5 buildRecordArtifactReminder export name reads stale Suggestion — declined Renaming a public API export is a breaking change for downstream consumers (packages/cli imports it). Out of scope for this bugfix PR; should be a separate rename PR if desired

Conflict

No conflict (--conflict false). No merge performed.

Verification

  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • vitest packages/core src/tools/write-file.test.ts — 66 passed (was 65; +1 for .htm)
  • vitest packages/cli src/serve/routes/workspace-file-read.test.ts — 35 passed
中文说明

本轮总结

仅一处测试改动,回应维护者变异审计中发现的覆盖缺口。未修改任何生产代码。

反馈分类

# 来源 发现 决定 操作
1 CI bot 评审 因 CI 仍在运行,从批准降级为评论 仅供参考 无需操作——CI 已完成(维护者确认 20 成功、0 失败)
2 @wenshao §3 .htm 是唯一一个 kind 映射没有任何测试覆盖的允许后缀——变异存活 建议——已实现 write-file.test.tsit.each 中添加 ['page.htm', 'html'](1 行)。测试数 65 → 66,全部通过
3 @wenshao §4 自动记录的 artifact 会屏蔽后续精心命名的 record_artifact(同一 workspacePath 先写入者胜出) 已确认——不做代码改动 维护者明确表示"不阻塞合并"并"建议合并"。根因是 sessionArtifacts.tsmergeArtifact 的既有语义,并非本 PR 引入。按维护者建议留待后续处理
4 @wenshao §5 inferWorkspaceArtifactKinddefault: return 'file' 不可达 仅供参考 无需操作——维护者确认"无害";ARTIFACT_LIKE_EXTENSIONS 的每个成员都已被显式处理
5 @wenshao §5 buildRecordArtifactReminder 导出名称已过时 建议——已拒绝 重命名公开 API 导出对下游消费者(packages/cli 有导入)是破坏性变更,超出本 bugfix PR 的范围;如有需要应另开重命名 PR

冲突

无冲突(--conflict false)。未执行合并。

验证

  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • vitest packages/core src/tools/write-file.test.ts — 66 通过(原 65;+1 为 .htm
  • vitest packages/cli src/serve/routes/workspace-file-read.test.ts — 35 通过

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

@chiga0

chiga0 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed the Serve A/B CI build failure in 16af7b8. The failure was a TypeScript-only issue from the review cleanup: ToolArtifact.workspacePath is optional in the public type, while write_file only builds workspace artifacts here. I narrowed the local helper return type to a workspace artifact with a required workspacePath, without changing runtime behavior. Validation: npm run build --workspace=packages/core; cd packages/core && npx vitest run src/tools/write-file.test.ts src/tools/record-artifact.test.ts; npx prettier --check packages/core/src/tools/write-file.ts; git diff --check.

yiliang114
yiliang114 previously approved these changes Jul 28, 2026
@wenshao

wenshao commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Local verification of #7914 — real daemon, real Web Shell, before/after

I built this branch (16af7b82f0) and its merge base (b3873571aa) locally and ran the change end-to-end against a real qwen serve daemon driven by a scripted OpenAI-compatible model, then inspected the result both over HTTP and in the actual Web Shell UI. Summary: the change does what it says, the A/B is clean, and nothing regressed. Four follow-up notes below, one of which I'd fix before merge.

Harness

  • node scripts/dev.js serve --port 0 --token … --workspace <tmp> — the CLI runs from TypeScript source via tsx, so packages/core/src/tools/write-file.ts is the code under test with no bundling step.
  • A ~100-line fake OpenAI SSE server scripted per turn. Turn 1 emits one write_file call; turn 2 emits plain text and ends the turn — record_artifact is never called. That is exactly the failure mode the PR targets.
  • Ground truth is GET /session/:id/artifacts (the daemon's SessionArtifactStore), plus GET /file?path=… for the preview round trip, plus headless Chromium against the Web Shell that qwen serve already serves from packages/web-shell/dist.
  • Both arms use an identical workspace, identical scripted model, identical prompt. Only the checkout differs.

Result — the core claim holds

BEFORE (b3873571aa) AFTER (#7914 16af7b82f0)
File written to disk reports/weather.html reports/weather.html
GET /session/:id/artifacts [] 1 artifact
record_artifact calls by the model 0 0
Artifact card in Web Shell ✅ absent ✅ present
GET /file?path=reports/weather.html 200, real content

The artifact the daemon actually stored on the "after" run:

{
  "id": "fd5ba0c40a7853b9",
  "kind": "html",
  "storage": "workspace",
  "source": "tool",
  "status": "available",
  "title": "weather.html",
  "workspacePath": "reports/weather.html",
  "mimeType": "text/html",
  "sizeBytes": 112,
  "toolCallId": "call_ez5fk6bc",
  "toolName": "write_file"
}

Clicking Open on that card renders the generated HTML in the sandboxed artifact preview — the full path from a write_file result to a user-visible artifact works:

One thing that makes the motivation stronger than the PR description states. RecordArtifactTool is constructed with shouldDefer: true, so it is hidden from the initial function-declaration list. In the session I drove, the model was offered exactly 16 tools and record_artifact was not among them — the model would first have had to spend a tool_search round trip to discover a tool it cannot see, then call it. "Probabilistic" undersells it; for a model that ends its turn after the write, the artifact was essentially never going to be recorded.

Negative controls — all hold

Same harness, one multi-write turn:

Write Artifact emitted Note in llmContent
src/index.ts ✅ none ✅ none
<outside workspace>/outside.html ✅ none — (the write itself is refused: path escapes workspace)
reports/dashboard.HTML kind: html
assets/logo.svg kind: image
notes/analysis.ipynb kind: notebook
same run with QWEN_CODE_DISABLE_ARTIFACT=1 ✅ 0 artifacts ✅ none

Backwards compatibility: a model that still calls record_artifact for the same workspacePath after the write produces exactly one artifact, not two — the store's identityKey (storage + workspacePath) coalesces them. No duplication risk for older prompts or cached tool descriptions.

Tests, lint, typecheck

Check Result
packages/core src/tools/write-file.test.ts 66/66
packages/core full src/tools/ suite 2696/2696 (85 files)
packages/acp-bridge sessionArtifacts.test.ts 103/103
packages/cli workspace-file-read.test.ts 35/35
Prettier on all four changed files clean
packages/cli tsc --noEmit identical error count on the merge base and on this branch (pre-existing @types/express noise in my checkout, not caused by this PR — this matches what you saw locally)

RED/GREEN: reverting only packages/core/src/tools/write-file.ts to the merge base while keeping this PR's tests flips exactly 11 of 66 tests red (the artifact-recording and kind-inference cases); restoring it returns 66/66. The tests are not vacuous.


Follow-ups

🟠 1. The note is phrased as a fact, but the daemon store can still reject the artifact

SessionArtifactStore.normalizeInput re-validates title (and workspacePath) with hasUnsafeDisplayPayload. buildWorkspaceArtifactMetadata sets title: path.basename(filePath) with no such check, while the record_artifact tool path does check. Reproduced on this branch — one turn writing two files:

tool result: Successfully created and wrote to new file: …/reports/<img src=x onerror=alert(1)>.html.
             This file was automatically recorded as a workspace artifact with workspacePath
             "reports/<img src=x onerror=alert(1)>.html". No extra artifact registration step is needed.

daemon log:  [artifacts] session=d030a85f… action=dropped reason="title contains unsafe markup"

GET /session/:id/artifacts  →  1 artifact  (only the control file plain.html)

The store correctly refuses it, so this is not a security problem — it's a truthfulness problem. The model is told the file was recorded, and the new record_artifact description now instructs it not to record the same workspacePath again, so there is no recovery path. Before this PR the model's explicit call would have failed loudly with a validation message it could act on.

A markup-bearing filename is rare, so this is not a blocker — but the fix is cheap: mirror the store's guard in buildWorkspaceArtifactMetadata and either skip the note when the title would be rejected, or emit a sanitized title.

🟡 2. The filename title is now permanent, not just the default

mergeArtifact only overwrites title/description on a publishedUpdate; for workspace artifacts it keeps ...existing. Since write_file now always registers first, a later record_artifact supplying a better title is silently dropped. Verified: write_filerecord_artifact({title: "Weather report", …}) on the same path leaves the panel showing weather.html, with updatedAt === createdAt.

The PR already lists "titles default to the filename" as a tradeoff. Worth stating the stronger form: the model can no longer supply a better title or description for a file it wrote, at all. If richer titles matter, one option is to let a subsequent record_artifact on the same path refine title/description.

🟡 3. Scope: this records every artifact-extension write, not only user-facing reports

In my negative-control run, assets/logo.svg — an ordinary build asset — became a session artifact. The old text asked the model to make the call ("If this file is a reusable user-facing artifact…"); the new path removes that judgement. With maxArtifacts: 200 and oldest-first eviction in the store, a session that emits many icons or intermediate images can push real reports out of the panel. Not a blocker, and arguably the right trade for determinism — flagging it so it's a deliberate choice rather than a side effect.

🟡 4. Subagent writes now get a message that isn't true

SubAgentTracker.createToolResultHandler does not forward artifacts (AgentToolResultEvent has no artifacts field), and record_artifact is deliberately excluded from subagents ("V1 session artifacts are owned by the parent daemon session"). So a subagent writing report.html is told "This file was automatically recorded … No extra artifact registration step is needed" while nothing is recorded. No regression versus main — it could not record before either — but the message changed from a suggestion into a false statement.

🔵 5. Nits

  • inferWorkspaceArtifactKind's ?? 'file' fallback is unreachable: getRecordArtifactWorkspacePath already returns null for any extension outside ARTIFACT_KIND_BY_EXTENSION, so the map lookup can never miss.
  • buildRecordArtifactReminder is now production-dead — only workspace-file-read.test.ts calls it. The contract is still genuinely covered because both paths share getRecordArtifactWorkspacePath, but the exported name no longer describes anything the product does. Exporting (a renamed) getRecordArtifactWorkspacePath and pinning that would say what it means.
  • .ipynb lands with no mimeType (mime.getType returns null for it). application/x-ipynb+json would round out the metadata.
  • The packages/cli change is comment/title-only, so that file gains no coverage for the payload that now actually ships. One extra assertion on result.artifacts?.[0].workspacePath in the round-trip test would pin producer→route on the field that matters now.

Verdict: LGTM. The mechanism works exactly as described, the before/after is unambiguous, the negative controls and the disable path hold, record_artifact still coalesces instead of duplicating, and 2900+ existing tests stay green. Only 🟠1 is worth a small change before merge; the rest are follow-ups.

中文说明

#7914 本地验证 —— 真实 daemon、真实 Web Shell、before/after 对比

我在本地分别构建了这个分支(16af7b82f0)和它的 merge base(b3873571aa),用一个脚本化的 OpenAI 兼容模型驱动真实的 qwen serve daemon 做了端到端验证,并同时通过 HTTP 接口和真实 Web Shell 界面检查了结果。结论:**改动的效果与描述完全一致,A/B 对比干净,没有回归。**下面有四条后续建议,其中一条建议合并前修一下。

验证环境

  • node scripts/dev.js serve --port 0 --token … --workspace <tmp> —— CLI 通过 tsx 直接跑 TypeScript 源码,所以被测代码就是 packages/core/src/tools/write-file.ts 本身,不经过打包。
  • 一个约 100 行的假 OpenAI SSE 服务,按轮次脚本化:**第 1 轮只发一个 write_file 调用;第 2 轮发纯文本并结束这一轮 —— 全程不调用 record_artifact。**这正是这个 PR 要解决的失效场景。
  • 判定依据是 GET /session/:id/artifacts(daemon 的 SessionArtifactStore),加上 GET /file?path=… 验证预览链路,再加上 headless Chromium 访问 qwen serve 自带的 Web Shell(packages/web-shell/dist)。
  • 两侧使用完全相同的 workspace、相同的脚本模型、相同的 prompt,唯一差异就是 checkout。

结果 —— 核心结论成立

BEFORE(b3873571aa AFTER(#7914 16af7b82f0
文件是否写入磁盘 reports/weather.html reports/weather.html
GET /session/:id/artifacts [] 1 个 artifact
模型调用 record_artifact 次数 0 0
Web Shell 中的 artifact 卡片 ✅ 不存在 ✅ 存在
GET /file?path=reports/weather.html 200,返回真实内容

"after" 那一侧 daemon 实际存下来的 artifact:

{
  "id": "fd5ba0c40a7853b9",
  "kind": "html",
  "storage": "workspace",
  "source": "tool",
  "status": "available",
  "title": "weather.html",
  "workspacePath": "reports/weather.html",
  "mimeType": "text/html",
  "sizeBytes": 112,
  "toolCallId": "call_ez5fk6bc",
  "toolName": "write_file"
}

点击卡片上的 Open 会在沙箱化的 artifact 预览里渲染出生成的 HTML —— 从 write_file 的结果到用户可见的 artifact,整条链路是通的:

有一点让这个改动的必要性比 PR 描述里说的更强。RecordArtifactTool 构造时传的是 shouldDefer: true,也就是说它不在初始的 function-declaration 列表里。在我驱动的这个会话中,模型总共只拿到 16 个工具,record_artifact 不在其中 —— 模型得先花一次 tool_search 往返去发现一个它根本看不见的工具,然后才能调用。所以"概率性"这个说法其实还偏保守:对于写完文件就结束这一轮的模型来说,artifact 基本上不可能被记录下来。

反向对照 —— 全部符合预期

同一套环境,一轮里连续写多个文件:

写入 是否产生 artifact llmContent 里是否有提示
src/index.ts ✅ 无 ✅ 无
<workspace 之外>/outside.html ✅ 无 —(写入本身就被拒绝:path escapes workspace
reports/dashboard.HTML kind: html ✅ 有
assets/logo.svg kind: image ✅ 有
notes/analysis.ipynb kind: notebook ✅ 有
同一场景加 QWEN_CODE_DISABLE_ARTIFACT=1 ✅ 0 个 artifact ✅ 无

向后兼容:如果模型在写入之后仍然对同一个 workspacePath 调用 record_artifact,最终只会有一个 artifact 而不是两个 —— store 的 identityKey(storage + workspacePath)会把它们合并。老的 prompt 或者被缓存的工具描述不会造成重复。

测试、lint、typecheck

检查项 结果
packages/core src/tools/write-file.test.ts 66/66
packages/core 全量 src/tools/ 2696/2696(85 个文件)
packages/acp-bridge sessionArtifacts.test.ts 103/103
packages/cli workspace-file-read.test.ts 35/35
四个改动文件的 Prettier 通过
packages/cli tsc --noEmit merge base 和本分支报错数量完全一致(是我这边 @types/express 造成的既有噪声,与本 PR 无关 —— 和你本地看到的情况一致)

**RED/GREEN:**只把 packages/core/src/tools/write-file.ts 回退到 merge base、保留本 PR 的测试,恰好有 11/66 个用例变红(artifact 记录与 kind 推断相关的用例);恢复后回到 66/66。测试不是空跑。


后续建议

🟠 1. 提示语是以"事实"陈述的,但 daemon store 仍然可能把这个 artifact 丢掉

SessionArtifactStore.normalizeInput 会用 hasUnsafeDisplayPayload 重新校验 title(以及 workspacePath)。buildWorkspaceArtifactMetadata 直接把 title 设为 path.basename(filePath),没有做这个校验,而 record_artifact 那条路径是校验的。在这个分支上复现了一下 —— 一轮里写两个文件:

tool result: Successfully created and wrote to new file: …/reports/<img src=x onerror=alert(1)>.html.
             This file was automatically recorded as a workspace artifact with workspacePath
             "reports/<img src=x onerror=alert(1)>.html". No extra artifact registration step is needed.

daemon log:  [artifacts] session=d030a85f… action=dropped reason="title contains unsafe markup"

GET /session/:id/artifacts  →  1 个 artifact(只有对照组的 plain.html)

store 正确地拒绝了它,所以这不是安全问题,而是"说了不实的话"的问题。模型被告知文件已经被记录,而新的 record_artifact 描述又明确要求它不要对同一个 workspacePath 再记录一次,于是就没有任何补救路径了。在这个 PR 之前,模型显式调用会得到一条它能看到、能据此行动的校验错误。

文件名里带标记字符的情况很少见,所以这不是阻塞项 —— 但修起来很便宜:在 buildWorkspaceArtifactMetadata 里对齐 store 的校验,标题会被拒绝时要么不输出这条提示,要么输出一个净化过的 title。

🟡 2. 文件名标题现在是"永久"的,而不只是默认值

mergeArtifact 只有在 publishedUpdate 的情况下才覆盖 title/description;对 workspace artifact 它保留 ...existing。既然 write_file 现在总是第一个注册,后续 record_artifact 给出的更好标题就会被静默丢弃。已验证:write_filerecord_artifact({title: "Weather report", …}) 打在同一路径上,面板里仍然显示 weather.html,并且 updatedAt === createdAt

PR 里已经把"标题默认用文件名"列为取舍了。这里想补充一个更强的表述:模型现在完全无法为自己写出的文件提供更好的标题或描述。如果更丰富的标题有价值,一个可选做法是允许后续对同一路径的 record_artifact 去细化 title/description

🟡 3. 影响范围:现在是"所有 artifact 扩展名的写入"都会被记录,而不只是面向用户的报告

在我的反向对照运行里,assets/logo.svg —— 一个普通的构建资源 —— 也变成了 session artifact。旧的提示语是让模型自己判断("If this file is a reusable user-facing artifact…"),新路径把这个判断去掉了。store 有 maxArtifacts: 200,淘汰时优先丢最早创建的,所以一个产出大量图标或中间图片的会话,可能把真正的报告挤出面板。这不是阻塞项,为了确定性做这个取舍也说得通 —— 只是想让它成为一个有意识的选择,而不是副作用。

🟡 4. 子 agent 的写入现在会收到一条不成立的消息

SubAgentTracker.createToolResultHandler 不会转发 artifacts(AgentToolResultEvent 里就没有 artifacts 字段),而 record_artifact 又是被刻意排除在子 agent 之外的("V1 session artifacts are owned by the parent daemon session")。所以子 agent 写出 report.html 时会被告知 "This file was automatically recorded … No extra artifact registration step is needed",但实际上什么都没记录。相对 main 没有回归 —— 之前它也记录不了 —— 但这条消息从"建议"变成了"不实陈述"。

🔵 5. 小问题

  • inferWorkspaceArtifactKind 里的 ?? 'file' 兜底分支不可达:getRecordArtifactWorkspacePath 已经对不在 ARTIFACT_KIND_BY_EXTENSION 里的扩展名返回 null,所以这个 map 查找不可能落空。
  • buildRecordArtifactReminder 现在在生产代码里已经没人调用了 —— 只有 workspace-file-read.test.ts 在用。契约本身仍然被真实覆盖,因为两条路径共享 getRecordArtifactWorkspacePath;但这个导出名已经不再对应产品里的任何行为了。把(重命名后的)getRecordArtifactWorkspacePath 导出并直接钉住它,会更名副其实。
  • .ipynb 落库时没有 mimeTypemime.getType 对它返回 null)。补上 application/x-ipynb+json 会让元数据更完整。
  • packages/cli 的改动只是注释和标题,所以那个文件对新增的 payload 没有增加任何覆盖。在往返测试里对 result.artifacts?.[0].workspacePath 多加一条断言,就能把 producer→route 钉在真正会上线的那个字段上。

**结论:LGTM。**机制与描述完全一致,before/after 对比毫无歧义,反向对照和关闭开关都成立,record_artifact 仍然是合并而非重复,2900+ 个既有测试保持绿色。只有 🟠1 值得在合并前做一个小修改,其余都可以作为后续跟进。


🤖 Generated with Claude Code — Claude Opus 5 (1M context)

@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.

⚠️ Downgraded from Approve to Comment: CI still running. Reviewed.

中文说明

⚠️ 已从批准降级为评论:CI still running。 已审查。

— qwen3.7-max via Qwen Code /review

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 3/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 3/100 轮)。改动内容与我反驳保留之处如下:

Review feedback addressed

Inline findings

ID Reviewer Finding Decision
rc:3665306344 @qwen-code-ci-bot getRecordArtifactWorkspacePath called twice Resolved (re-verified: commit ac377675a refactored the execute path to call buildWorkspaceArtifactMetadata once and derive the reminder from artifact.workspacePath)
rc:3665636350 @doudouOUC ARTIFACT_LIKE_EXTENSIONS and inferWorkspaceArtifactKind maintained separately Resolved (re-verified: commit ac377675a replaced both with a single ARTIFACT_KIND_BY_EXTENSION map)

Issue-level findings (@wenshao's local verification, round 2 + round 3)

# Finding Decision Detail
🟠1 Unsafe markup in filename → model told artifact was recorded but daemon store drops it Implemented buildWorkspaceArtifactMetadata now checks hasUnsafeDisplayPayload(title) and returns null when the title would be rejected, so no artifact and no reminder are emitted. The file write itself still succeeds. Exported hasUnsafeDisplayPayload from record-artifact.ts for reuse. Added a regression test with <img src=x onerror=alert(1)>.html.
🟡2 Filename title is permanent, not just the default Declined (follow-up) Maintainer explicitly marked as follow-up. The mergeArtifact first-writer-wins behavior predates this PR; changing it would expand scope into the daemon store.
🟡3 Records every artifact-extension write, not only user-facing reports Declined (follow-up) Maintainer flagged as a deliberate-choice note, not a blocker. Deterministic recording is the PR's stated tradeoff.
🟡4 Subagent writes get a message that isn't true Declined (follow-up) No regression versus main (subagents could not record before either). The false statement is bounded and the fix requires subagent artifact forwarding, which is out of scope.
🔵5a ?? 'file' fallback unreachable Declined Makes inferWorkspaceArtifactKind total; harmless and sensible if reused (per @chiga0's earlier audit).
🔵5b buildRecordArtifactReminder production-dead Declined Still used by the cross-package contract test; the contract is genuine. Renaming or restructuring would grow the diff for no behavioral gain.
🔵5c .ipynb lands with no mimeType Implemented Added application/x-ipynb+json fallback in buildWorkspaceArtifactMetadata when getSpecificMimeType returns undefined for .ipynb. Added a dedicated test.
🔵5d CLI round-trip test doesn't pin the artifact metadata Implemented emittedWorkspacePath now also calls buildWorkspaceArtifactMetadata and asserts its workspacePath matches the reminder's, pinning the producer→route contract on the actual ToolArtifact object. Exported buildWorkspaceArtifactMetadata from the core package for this.

Changes in this commit

  • packages/core/src/tools/record-artifact.ts — exported hasUnsafeDisplayPayload
  • packages/core/src/tools/write-file.ts — title safety guard, .ipynb mimeType fallback, exported buildWorkspaceArtifactMetadata
  • packages/core/src/index.ts — added buildWorkspaceArtifactMetadata to the cross-package export
  • packages/core/src/tools/write-file.test.ts — 2 new tests (unsafe markup filename, ipynb mimeType)
  • packages/cli/src/serve/routes/workspace-file-read.test.ts — artifact metadata workspacePath assertion in the contract helper

Verification

  • npm run build --workspace=packages/core — passed
  • npm run build --workspace=packages/cli — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • cd packages/core && npx vitest run src/tools/write-file.test.ts src/tools/record-artifact.test.ts — 83 passed (68 write-file + 15 record-artifact)
  • cd packages/cli && npx vitest run src/serve/routes/workspace-file-read.test.ts — 35 passed
中文说明

审查反馈处理

行内发现

ID 审查者 发现 决定
rc:3665306344 @qwen-code-ci-bot getRecordArtifactWorkspacePath 被调用两次 已解决(重新验证:提交 ac377675a 已将执行路径重构为只调用一次 buildWorkspaceArtifactMetadata,提示文本从 artifact.workspacePath 派生)
rc:3665636350 @doudouOUC ARTIFACT_LIKE_EXTENSIONSinferWorkspaceArtifactKind 分开维护 已解决(重新验证:提交 ac377675a 已将两者合并为单一的 ARTIFACT_KIND_BY_EXTENSION map)

Issue 级发现(@wenshao 的本地验证第 2 轮 + 第 3 轮)

# 发现 决定 说明
🟠1 文件名含不安全标记 → 模型被告知 artifact 已记录,但 daemon store 会丢弃 已实现 buildWorkspaceArtifactMetadata 现在检查 hasUnsafeDisplayPayload(title),标题会被拒绝时返回 null,不产出 artifact 也不输出提示。文件写入本身仍然成功。从 record-artifact.ts 导出了 hasUnsafeDisplayPayload 以供复用。新增了以 <img src=x onerror=alert(1)>.html 为文件名的回归测试。
🟡2 文件名标题现在是永久性的,而非仅默认值 拒绝(后续跟进) 维护者明确标记为后续跟进。mergeArtifact 的先写者胜出行为早于本 PR;修改它会将范围扩展到 daemon store。
🟡3 记录所有 artifact 扩展名的写入,而非仅面向用户的报告 拒绝(后续跟进) 维护者将此标记为"有意识的选择"提醒,非阻塞项。确定性记录是本 PR 声明的取舍。
🟡4 子 agent 写入会收到不成立的消息 拒绝(后续跟进) 相对 main 无回归(子 agent 之前也无法记录)。不实陈述影响有界,修复需要子 agent artifact 转发,超出范围。
🔵5a ?? 'file' 兜底不可达 拒绝 使 inferWorkspaceArtifactKind 成为全函数;无害且在被复用时合理(@chiga0 此前审计结论)。
🔵5b buildRecordArtifactReminder 在生产代码中已无调用 拒绝 仍被跨包契约测试使用;契约本身是真实的。重命名或重构会增加 diff 而无行为收益。
🔵5c .ipynb 落库时没有 mimeType 已实现 buildWorkspaceArtifactMetadata 中,当 getSpecificMimeType.ipynb 返回 undefined 时,添加了 application/x-ipynb+json 兜底。新增了专门的测试。
🔵5d CLI 往返测试未钉住 artifact 元数据 已实现 emittedWorkspacePath 现在也调用 buildWorkspaceArtifactMetadata 并断言其 workspacePath 与提示文本中的一致,将 producer→route 契约钉在实际的 ToolArtifact 对象上。从 core 包导出了 buildWorkspaceArtifactMetadata

本次提交的改动

  • packages/core/src/tools/record-artifact.ts — 导出 hasUnsafeDisplayPayload
  • packages/core/src/tools/write-file.ts — 标题安全性守卫、.ipynb mimeType 兜底、导出 buildWorkspaceArtifactMetadata
  • packages/core/src/index.ts — 在跨包导出中添加 buildWorkspaceArtifactMetadata
  • packages/core/src/tools/write-file.test.ts — 2 个新测试(不安全标记文件名、ipynb mimeType)
  • packages/cli/src/serve/routes/workspace-file-read.test.ts — 契约 helper 中添加 artifact 元数据 workspacePath 断言

验证

  • npm run build --workspace=packages/core — 通过
  • npm run build --workspace=packages/cli — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • cd packages/core && npx vitest run src/tools/write-file.test.ts src/tools/record-artifact.test.ts — 83 通过(68 write-file + 15 record-artifact)
  • cd packages/cli && npx vitest run src/serve/routes/workspace-file-read.test.ts — 35 通过

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

@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.

⚠️ Downgraded from Approve to Comment: CI still running. Reviewed.

中文说明

⚠️ 已从批准降级为评论:CI still running。 已审查。

— qwen3.7-max via Qwen Code /review

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下:

No changes were made in this round.

The only feedback was an automated review-bot comment (@qwen-code-ci-bot) that downgraded its Approve to Comment because CI was still running at review time. It contained no findings, no inline comments, and no suggestions. There are no failed checks and no still-red checks.

No code changes are warranted.

中文说明

本轮未做任何更改。

唯一的反馈来自自动审查机器人(@qwen-code-ci-bot),它因审查时 CI 仍在运行而将"批准"降级为"评论"。该反馈不包含任何问题发现、行内评论或建议。没有失败的检查,也没有持续红色的检查。

无需进行代码更改。

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

@wenshao

wenshao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Maintainer local verification, round 4 — head 2674aa4a1 on Linux

My round-3 verification covered head 16af7b82f0. One commit has landed since — 2674aa4a1 "guard artifact title safety and fill ipynb mimeType" — which addresses 🟠1 and one of the 🔵5 nits from that round. This re-verifies the current head end to end, on Linux (the PR body still marks Linux as untested).

Both arms built from scratch (npm run build && npm run bundle, no symlinked dist) and driven through a real qwen serve daemon plus the daemon-served Web Shell in real headless Chromium, with a scripted mock OpenAI endpoint on the model side. Nothing stubbed on the path under test.

Verdict: 🟠1 is genuinely fixed and I'm comfortable merging. One residual slice of 🟠1 is still open — non-blocking, §3.


1. Before / after, real Web Shell

Same prompt, same mock model, same script. The only variable is which checkout the daemon was built from.

before/after turn outputs

The model calls record_artifact zero times in both arms — that is the point. Clicking Open resolves workspacePath through the daemon's GET /file route and renders the file, so the producer↔route contract the doc comment guards holds at runtime on this head too:

artifact panel preview

2. Behaviour matrix — measured against the running daemon

write GET /session/:id/artifacts note in the tool result
reports/q3-weather.html kind:html, storage:workspace, mimeType:text/html, sizeBytes:181 "automatically recorded"
notes/analysis.ipynb kind:notebook, mimeType:application/x-ipynb+json "automatically recorded"
reports/chart.svg kind:image, mimeType:image/svg+xml "automatically recorded"
src/index.ts (absent) none
/tmp/…/outside.html (absent) write itself refused: path escapes workspace
reports/<img src=x onerror=alert(1)>.html (absent) none — 🟠1 fixed
same reports/dupe.html written twice 1 artifact, same id, sizeBytes refreshed to the second write's 288 B, updatedAt bumped dedupe holds
QWEN_CODE_DISABLE_ARTIFACT=1 [] none

The two behaviours added by 2674aa4a1 — the .ipynb mimeType and the markup-filename suppression — are both confirmed live.

3. Open: the 🟠1 fix closes 1 of the 5 rules the store enforces

buildWorkspaceArtifactMetadata (write-file.ts:702) mirrors hasUnsafeDisplayPayload(title). SessionArtifactStore.normalizeInput validates the same payload against five rules:

rule store mirrored in write_file?
markup in title sessionArtifacts.ts:1267isDisplayField ✅ this commit
markup in workspacePath sessionArtifacts.ts:2534isDisplayField
title ≤ 200 chars sessionArtifacts.ts:1267
workspacePath ≤ 500 chars sessionArtifacts.ts:2534
control characters normalizeString

When any of the four unmirrored rules fires, the exact 🟠1 symptom returns: the model is told the file was recorded and that no follow-up is needed, while the store drops it. Reproduced twice against a live daemon on this head:

gap evidence

# 203-character filename
tool result → …This file was automatically recorded as a workspace artifact… No extra artifact registration step is needed.
daemon.log  → [artifacts] session=b990a7a6… action=dropped reason="title exceeds 200 characters"

# directory component carrying an HTML entity  (ws/Q&amp;A/summary.html)
tool result → …automatically recorded as a workspace artifact with workspacePath "Q&amp;A/summary.html"…
daemon.log  → [artifacts] session=0f537d78… action=dropped reason="workspacePath contains unsafe markup"

Both triggers are edge cases — a >200-char basename, or a directory name containing &entity; / <tag — so this is not a blocker, and it is strictly narrower than what 🟠1 covered. Cheapest fix keeps the shape of the guard already there:

const title = path.basename(filePath);
if (
  hasUnsafeDisplayPayload(title) ||
  hasUnsafeDisplayPayload(workspacePath) ||
  title.length > 200 ||
  workspacePath.length > 500
) {
  return null;
}

Or soften the sentence — "submitted for artifact recording" rather than "was automatically recorded … no extra artifact registration step is needed" — so it stays true whenever the store declines. Either works as a follow-up; I would not hold the merge for it.

4. Status of the other round-3 follow-ups

Re-checked on this head, no new findings:

  • 🟡2 filename title is permanent — unchanged. mergeArtifact still keeps ...existing for two storage:'workspace' upserts, so a later record_artifact cannot refine title/description. Still not mentioned in Risk & Scope.
  • 🟡3 every artifact-extension write is recorded — unchanged, and re-confirmed: src/assets/icon-a.svg and icon-b.svg, ordinary repo assets, both became session artifacts in this run.
  • 🟡4 subagent writes get a message that isn't true — unchanged. AgentToolResultEvent (agents/runtime/agent-events.ts:128) still has no artifacts field, so a subagent's write_file is told "automatically recorded … no extra artifact registration step is needed" while nothing reaches the parent session's store.
  • 🔵 .ipynb had no mimeTypefixed in this commit, verified live above.
  • 🔵 buildRecordArtifactReminder is production-dead / misnamed — unchanged. Still exported from packages/core/src/index.ts under a name that no longer describes what it builds.

5. Gates

check result
packages/core write-file.test.ts 68 / 68
packages/core record-artifact.test.ts 15 / 15
packages/cli workspace-file-read.test.ts (against the PR's own build) 35 / 35
packages/acp-bridge sessionArtifacts + bridgeClient 185 / 185
prettier --check on the 5 changed files clean
eslint on the 5 changed files clean
npm run typecheck --workspace=packages/core clean
git diff --check clean
CI on 2674aa4a1 19 pass, 19 skipped, 0 fail

Mutation matrix — six mutations applied to the PR source, each asserted to have textually landed, then the PR's own unchanged write-file.test.ts run. Baseline 68/68.

mutation result
M1 remove the hasUnsafeDisplayPayload(title) guard 1 failed
M2 remove the .ipynb mimeType fallback 1 failed
M3 map .htm'file' 1 failed
M4 map .svg'file' 1 failed
M5 stop emitting artifacts on the tool result 12 failed
M6 remove the worktree anchoring in getRecordArtifactWorkspacePath 1 failed

6 / 6 caught. M1 and M2 pin the two behaviours this commit adds. M3 is the .htm gap I reported in round 1 as the one mutation that survived — the it.each tuple added since closes it. Reverting only write-file.ts to the merge base while keeping the PR's tests flips 12 of 68 red, so the suite is not vacuous.

Harness (reproducible)
  • Worktrees: PR head 2674aa4a1 and merge base b3873571a, each fully built and bundled, so packages/cli really loads its own arm's core rather than a symlinked one.
  • Mock model is an SSE OpenAI-compatible server; step selection is stateless (emit step N once the request already carries N role:"tool" messages), so a session opened fresh in the browser replays the script from the start and a retry cannot double-fire.
  • Daemon: node dist/cli.js serve --port N --hostname 127.0.0.1 --workspace <ws>, isolated HOME wiped between runs, approvalMode: 'yolo', loopback (auth-free).
  • Browser: Playwright Chromium 1440×900 / DPR 2 against the daemon-served Web Shell; the prompt is typed into the real composer, not injected.
  • The §3 gap check also runs producer → consumer directly: buildWorkspaceArtifactMetadata from the built core fed into a real SessionArtifactStore from the built acp-bridge, so the disagreement is between the two shipped modules rather than a hand-written mock.
中文说明

维护者本地验证 · 第 4 轮 —— head 2674aa4a1,Linux

我的第 3 轮验证针对的是 head 16af7b82f0。此后新增了一个提交 2674aa4a1(guard artifact title safety and fill ipynb mimeType),处理的正是那一轮的 🟠1 以及 🔵5 中的一条 nit。本轮在 Linux 上重新对当前 head 做了完整端到端验证(PR 描述里 Linux 仍标记为未测试)。

两侧都从零构建(npm run build && npm run bundle,没有软链 dist),通过真实 qwen serve daemon 和 daemon 自带的 Web Shell、真实 headless Chromium 驱动,模型侧接脚本化的 mock OpenAI 端点。被测路径上没有任何桩。

结论:🟠1 确实修好了,我认为可以合并。 🟠1 还残留一小块,非阻塞,见第 3 节。

1. 前后对比(真实 Web Shell)

相同 prompt、相同 mock 模型、相同脚本,唯一变量是 daemon 由哪个 checkout 构建。两侧模型都零次调用 record_artifact —— 这正是重点。点击 Open 会通过 daemon 的 GET /file 路由解析 workspacePath 并渲染文件,说明注释里强调的「生产者↔路由」契约在这个 head 上运行时同样成立。

2. 行为矩阵(在运行中的 daemon 上实测)

写入 GET /session/:id/artifacts 工具结果中的提示
reports/q3-weather.html kind:htmlstorage:workspacemimeType:text/htmlsizeBytes:181 “automatically recorded”
notes/analysis.ipynb kind:notebookmimeType:application/x-ipynb+json “automatically recorded”
reports/chart.svg kind:imagemimeType:image/svg+xml “automatically recorded”
src/index.ts
/tmp/…/outside.html 写入本身被拒:path escapes workspace
reports/<img src=x onerror=alert(1)>.html 无 —— 🟠1 已修复
同一个 reports/dupe.html 写两次 1 个 artifact,id 相同,sizeBytes 更新为第二次写入的 288 B,updatedAt 更新 去重成立
QWEN_CODE_DISABLE_ARTIFACT=1 []

2674aa4a1 新增的两个行为(.ipynb 的 mimeType、带危险标记文件名的抑制)都已实测确认。

3. 遗留:🟠1 的修复只覆盖了 store 五条规则中的一条

buildWorkspaceArtifactMetadatawrite-file.ts:702)镜像了 hasUnsafeDisplayPayload(title)。而 SessionArtifactStore.normalizeInput 对同一份 payload 有五条校验:

规则 store 位置 write_file 是否镜像
title 含危险标记 sessionArtifacts.ts:1267isDisplayField ✅ 本次提交
workspacePath 含危险标记 sessionArtifacts.ts:2534isDisplayField
title ≤ 200 字符 sessionArtifacts.ts:1267
workspacePath ≤ 500 字符 sessionArtifacts.ts:2534
控制字符 normalizeString

未镜像的四条中任意一条触发时,🟠1 的症状就会原样重现:模型被告知文件「已经记录、无需后续步骤」,而 store 实际把它丢弃了。在当前 head 的真实 daemon 上复现了两次:

# 203 字符文件名
工具结果 → …This file was automatically recorded as a workspace artifact… No extra artifact registration step is needed.
daemon.log → [artifacts] session=b990a7a6… action=dropped reason="title exceeds 200 characters"

# 目录名含 HTML 实体(ws/Q&amp;A/summary.html)
工具结果 → …automatically recorded as a workspace artifact with workspacePath "Q&amp;A/summary.html"…
daemon.log → [artifacts] session=0f537d78… action=dropped reason="workspacePath contains unsafe markup"

两个触发条件都属于边缘场景(basename 超过 200 字符,或目录名里含 &entity; / <tag),所以不是阻塞项,范围也严格小于 🟠1。最小改法保持现有 guard 的形状:

const title = path.basename(filePath);
if (
  hasUnsafeDisplayPayload(title) ||
  hasUnsafeDisplayPayload(workspacePath) ||
  title.length > 200 ||
  workspacePath.length > 500
) {
  return null;
}

或者把措辞放软 —— 用「submitted for artifact recording」替代「was automatically recorded … no extra artifact registration step is needed」—— 这样 store 拒绝时这句话依然成立。两种都可以作为 follow-up,我不会因此卡住合并。

4. 第 3 轮其他 follow-up 的现状

在当前 head 上逐条复查,没有新增问题:

  • 🟡2 文件名标题不可改 —— 未变。对同一 workspacePath 的两次 storage:'workspace' upsert,mergeArtifact 仍保留 ...existing,后续 record_artifact 无法改进 title/description。仍未写入 Risk & Scope
  • 🟡3 所有 artifact 扩展名的写入都会被记录 —— 未变,并再次确认:src/assets/icon-a.svgicon-b.svg 这类普通仓库资源在本轮中都变成了会话 artifact。
  • 🟡4 子代理写入会收到不真实的提示 —— 未变。AgentToolResultEventagents/runtime/agent-events.ts:128)仍然没有 artifacts 字段,因此子代理的 write_file 会被告知「automatically recorded … 无需额外注册」,而实际上什么都没有进入父会话的 store。
  • 🔵 .ipynb 缺 mimeType —— 已修复,上面已实测确认。
  • 🔵 buildRecordArtifactReminder 已是生产环境死代码且命名不符 —— 未变,仍以这个名字从 packages/core/src/index.ts 导出。

5. 各项检查

检查 结果
packages/core write-file.test.ts 68 / 68
packages/core record-artifact.test.ts 15 / 15
packages/cli workspace-file-read.test.ts(针对 PR 自身构建) 35 / 35
packages/acp-bridge sessionArtifacts + bridgeClient 185 / 185
5 个改动文件的 prettier --check 通过
5 个改动文件的 eslint 通过
npm run typecheck --workspace=packages/core 通过
git diff --check 通过
2674aa4a1 上的 CI 19 通过,19 跳过,0 失败

变异测试 —— 对 PR 源码施加 6 个变异,每个都确认文本已落地,再运行 PR 自带、未修改的 write-file.test.ts。基线 68/68。

变异 结果
M1 移除 hasUnsafeDisplayPayload(title) 保护 1 failed
M2 移除 .ipynb mimeType 兜底 1 failed
M3 把 .htm 映射成 'file' 1 failed
M4 把 .svg 映射成 'file' 1 failed
M5 工具结果不再输出 artifacts 12 failed
M6 移除 getRecordArtifactWorkspacePath 里的 worktree 锚定 1 failed

6 / 6 全部被捕获。M1、M2 正好覆盖本次提交新增的两个行为。M3 是我第 1 轮报告中唯一漏网的 .htm 变异,此后补上的 it.each 元组已经把它堵住。只把 write-file.ts 回退到 merge base、保留 PR 自带测试时,68 个中有 12 个变红,说明测试并非空转。

复现环境

  • worktree:PR head 2674aa4a1 与 merge base b3873571a,各自完整构建并打包,因此 packages/cli 确实加载各自分支的 core,而不是软链过来的。
  • mock 模型是 SSE 的 OpenAI 兼容服务;步骤选择无状态(请求中已有 N 条 role:"tool" 消息时返回第 N 步),因此浏览器里新开的会话会从头重放脚本,重试也不会重复触发。
  • daemon:node dist/cli.js serve --port N --hostname 127.0.0.1 --workspace <ws>,每轮之间清空隔离的 HOMEapprovalMode: 'yolo',走 loopback(免鉴权)。
  • 浏览器:Playwright Chromium,1440×900 / DPR 2,访问 daemon 提供的 Web Shell;prompt 是敲进真实输入框的,不是注入的。
  • 第 3 节的 gap 检查还直接串联生产者与消费者:从构建产物取 buildWorkspaceArtifactMetadata,喂给构建产物中真实的 SessionArtifactStore,因此这个分歧发生在两个实际发布的模块之间,而不是手写的 mock。

@wenshao wenshao 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.

Requesting changes on one blocker: the new unsafe-markup test uses a Windows-reserved filename, so it fails in the merge queue — where this PR's CI does not run it. Everything else is inline and non-blocking.

中文说明

请求修改,阻断问题一处:新增的 unsafe-markup 测试使用了 Windows 保留字符文件名,会在合并队列中失败——而本 PR 的 CI 并不运行该任务。其余均为行内的非阻断建议。

— claude-opus-5[1m] via Claude Code /review

Comment thread packages/core/src/tools/write-file.test.ts Outdated
Comment thread packages/core/src/tools/write-file.ts
return null;
}
return {
title,

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] Making write_file the artifact's creator means this basename title becomes permanent, and record_artifact turns into a silent no-op for the same path.

Artifact identity is workspace:${workspacePath} (sessionArtifacts.ts:2393), and mergeArtifact builds next from {...existing} and only reassigns next.title / next.description inside the if (publishedUpdate) branch (:1752). A file must exist before it can be recorded, so after this change write_file is structurally always the first writer for every workspace artifact.

Verified against the real store — upsert exactly what this function emits, then upsert a curated record for the same path:

upsert {title:'q3.html',           workspacePath:'reports/q3.html'}
upsert {title:'Q3 Revenue Report', description:'Quarterly revenue breakdown…',
        workspacePath:'reports/q3.html'}

changes = []
store   = [{"title":"q3.html"}]     // no description, no artifact_changed event

…while record_artifact still returns Recorded artifact "Q3 Revenue Report". to the model and Recorded artifact **Q3 Revenue Report**. to the user. It fails silently in both directions.

The Risk section says titles "default to the filename". In practice they lock to it, and description becomes unreachable for every workspace artifact — the panel shows report.html / index.html for everything. That is a user-visible regression in the feature this PR is improving, and it is the half not stated in the description.

The same {...existing} merge freezes toolCallId/toolNamemergeArtifact never reassigns either. Verified with two upserts on identity workspace:report.html carrying call-1 then call-5: sizeBytes updates, toolCallId stays call-1. Since getArtifactsByTurn only collects per-turn references from record_artifact calls and otherwise falls back to artifact.toolCallId, a later turn that regenerates the same report shows no artifact chip — the chip stays pinned to the first turn. "Regenerate the report" is exactly the flow this PR makes the default.

Options, roughly in order of preference:

  1. Let a same-identity upsert refresh title/description when the existing record's title still equals path.basename(workspacePath) — i.e. treat the auto title as provisional.
  2. Skip the auto-record when the store already holds that workspacePath.
  3. At minimum, stop record_artifact reporting success when the store discarded its title, and say so in its description.

— claude-opus-5[1m] via Claude Code /review

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.

Declined for this PR. Changing mergeArtifact to let a same-identity upsert refresh title/description requires modifying packages/acp-bridge/src/sessionArtifacts.ts — a different package and a deeper design decision about store merge semantics. Worth a follow-up issue.

中文说明

本 PR 中拒绝处理。要让同 identity 的 upsert 能刷新 title/description,需要修改 packages/acp-bridge/src/sessionArtifacts.ts 中的 mergeArtifact —— 属于不同包、更深层的 store 合并语义设计决策。建议单独开 issue 跟进。

Comment on lines +682 to +688
function formatRecordArtifactReminder(workspacePath: string): string {
return (
`This file was automatically recorded as a workspace artifact with ` +
`workspacePath "${workspacePath}". No extra artifact registration step ` +
`is needed.`
);
}

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] This is an unconditional statement of completed fact, but only the daemon/ACP surface actually consumes ToolResult.artifacts — on the others the sentence is false and the model will relay it to the user verbatim.

  • Subagents. write_file is not in EXCLUDED_TOOLS_FOR_SUBAGENTS, so a subagent writing report.html gets both the artifacts array and this sentence. But AgentToolResultEvent has no artifacts field, and SubAgentTracker calls toolCallEmitter.emitResult({...}) without one, so _meta.artifacts is never stamped and extractSessionUpdateArtifacts sees nothing. There is no action=dropped line either — nothing ever reaches the store, so the drop is completely silent. Recovery is closed as well: record_artifact is deliberately excluded from subagents ("V1 session artifacts are owned by the parent daemon session"), and the new record_artifact description tells the parent not to re-record the path.
  • TUI and headless --prompt. isRecordArtifactEnabled() is true on both (it only excludes SDK mode), but the sole reader of ToolCallResponseInfo.artifacts is packages/cli/src/acp-integration/session/Session.ts. There is no artifacts consumer under packages/cli/src/ui.

The previous wording was a conditional imperative — "If this file is a reusable user-facing artifact, call record_artifact…" — which was harmless where nothing consumed it. Asserting a completed action is not.

Worth either gating the sentence on a session that has an artifact consumer, or phrasing it as a capability rather than a finished action, e.g. "recorded for clients that display an artifacts panel".

— claude-opus-5[1m] via Claude Code /review

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.

Declined. Gating the sentence on a session that has an artifact consumer would require plumbing surface awareness into the write path; softening the wording would churn ~6 test assertions for a non-blocking preference. The maintainer's round-4 verification confirmed comfort with merging as-is.

中文说明

拒绝。按会话类型门控需要在写入路径中引入场景感知;软化措辞会导致约 6 个测试断言的变动,且属于非阻断偏好。维护者第 4 轮验证已确认可按现状合并。

Comment on lines +719 to +726
function getRecordArtifactWorkspacePath(
config: Config,
filePath: string,
): string | null {
if (!config.isRecordArtifactEnabled()) {
return null;
}
if (!ARTIFACT_LIKE_EXTENSIONS.has(path.extname(filePath).toLowerCase())) {
if (!ARTIFACT_KIND_BY_EXTENSION.has(path.extname(filePath).toLowerCase())) {

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] This helper anchors the emitted path at config.getTargetDir() and special-cases exactly one divergence — the .qwen/worktrees/<slug> shape. But getTargetDir() is mutable, and the store it feeds is not.

POST /session/:id/cdbridge.changeSessionCwdconfig.relocateWorkingDirectory(..., { skipArtifactMigration: true }) moves targetDir to any trusted absolute directory. changeSessionCwd only publishes a session_cwd_changed event — it never rebuilds entry.artifacts, and SessionArtifactStore is constructed once with workspaceCwd (bridge.ts:3843-3847), which is also GET /file's resolution root. So after a cd into a subdirectory, the emitted workspacePath is relative to the new cwd while both consumers resolve it against the original root.

Reproduced with this PR's own workspace-file-read.test.ts harness, session cwd = <workspace>/packages/core:

emitted  workspacePath = "report.html"
GET /file             -> 200, "<!doctype html><h1>UNRELATED ROOT FILE</h1>"   // wrong file
GET /file (weather)   -> 404 path_not_found

That 200-with-the-wrong-file is verbatim what the contract test's comment calls "worse than a 404".

To be clear about attribution: this arithmetic is not introduced here — the pre-existing helper computed the same path. What changes is the consequence. Before, it was a hint the model could ignore; now emission is unconditional and write_file asserts to the model that the file "was automatically recorded … No extra artifact registration step is needed". A defect that used to be probabilistic becomes deterministic for every artifact-like write in a relocated session.

Worth anchoring at the session's bound workspace root rather than getTargetDir() plus a path-shape regex — config.storage.getProjectRoot() is that value on the ACP path, and using it would let the .qwen/worktrees regex be deleted rather than extended. (Check the CLI --worktree startup path first, where Config is constructed with targetDir already inside the worktree.) A smaller alternative: return null — no artifact, no claim — when getTargetDir() is neither the bound root nor <root>/.qwen/worktrees/<slug>.

— claude-opus-5[1m] via Claude Code /review

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.

Declined. The reviewer confirmed this path arithmetic is pre-existing, not introduced by this PR. Anchoring at the bound workspace root and auditing the CLI --worktree startup path is a separate concern best handled in its own change.

中文说明

拒绝。评审者确认此路径计算是既有逻辑,非本 PR 引入。锚定到绑定的工作区根目录并审计 CLI --worktree 启动路径属于独立关注点,最好单独处理。

'.png',
'.svg',
'.webp',
const ARTIFACT_KIND_BY_EXTENSION = new Map<string, ToolArtifactKind>([

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] Auto-recording changes the rate at which the store's 200-artifact cap is reached, and eviction gives no protection to the artifacts the user actually cares about.

maxArtifacts defaults to 200 and is never overridden (bridge.ts constructs the store with only sessionId/workspaceCwd/persistence), SOURCE_RESERVATIONS.tool = 100, and selectEvictionCandidate (sessionArtifacts.ts:1934) ranks on status === 'missing' → source over-reservation → !clientRetained → oldest createdAt. It never consults storage or retention, and clientRetained is only ever true for source === 'client' — so a published artifact with a live URL is fully evictable. Auto-recorded writes land in source: 'tool', the same bucket as record_artifact and Artifact-publish entries.

Reaching 100 tool artifacts used to require 100 deliberate registration calls. It now requires 100 .html/.svg/.ipynb file writes.

Measured on the real store: seed one published artifact and one record_artifact artifact, then ingest 220 auto-recorded writes → stored=200 publishedSurvived=false recordArtifactSurvived=false. Evictions carry durableTombstoneRequired and are persisted, and rebuild applies removed, so the loss is permanent rather than live-list only. The only trace is a stderr line naming an opaque artifactId.

Two things keep this from being worse, and are why I've filed it as a Suggestion: write_file writes model-generated text, so the image/PDF extensions here are practically unreachable, and rewrites of the same path merge by identity — you need >200 distinct artifact-like paths in one session. An SVG icon set, a generated docs tree, or a long session that accumulates across resumes will get there.

Worth either narrowing this map to the motivating kinds (.html, .htm, .ipynb, .pdf) and leaving bulk .svg to explicit registration, or giving auto-emitted artifacts a lower-priority eviction class so they cannot displace explicitly published ones.

— claude-opus-5[1m] via Claude Code /review

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.

Declined. Eviction policy lives in packages/acp-bridge/src/sessionArtifacts.ts. Narrowing the extension map or adding a lower-priority eviction class are both out of scope for this PR — worth a follow-up issue.

中文说明

拒绝。淘汰策略位于 packages/acp-bridge/src/sessionArtifacts.ts。缩窄扩展名映射或添加低优先级淘汰类别均超出本 PR 范围,建议单独开 issue 跟进。

Comment thread packages/core/src/tools/record-artifact.ts Outdated
Comment thread packages/core/src/tools/write-file.ts
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 4/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 4/100 轮)。改动内容与我反驳保留之处如下:

Round 5 — Review feedback addressed

Critical

  • [rc:3671586257] Windows-reserved filename in unsafe-markup test — Fixed. Replaced <img src=x onerror=alert(1)>.html with chart onerror=alert(1).html. The new filename contains no Win32-reserved characters (<, >) but still triggers hasUnsafeDisplayPayload via the on…= branch, so the test remains meaningful and passes on Windows.

Suggestions implemented

  • [rc:3671586267] Guard mirrors only 1 of 5 store rules — Fixed. buildWorkspaceArtifactMetadata now checks all five rules the daemon store enforces: title length ≤ 200, workspacePath length ≤ 500, control characters in either, and unsafe markup in either. Exported hasControlCharacter from record-artifact.ts for reuse. Added two focused tests: title > 200 chars and workspacePath with &amp; entity in a directory component.

  • [rc:3671586299] record_artifact description too vague — Fixed. The description now names the exact covered set ("HTML, image, PDF, and notebook files") and explicitly tells the model to still call record_artifact for "other formats such as Markdown, CSV, JSON, and plain text, and for files produced outside write_file."

  • [rc:3671586311] buildRecordArtifactReminder is dead / misnamed / diverges — Fixed. buildRecordArtifactReminder now delegates to buildWorkspaceArtifactMetadata, so the two agree by construction. The duplicate getRecordArtifactWorkspacePath call is removed. Doc comment updated to reflect its role as a contract-test helper.

Suggestions declined

  • [rc:3671586274] Filename title becomes permanent — Declined. This requires changing mergeArtifact semantics in packages/acp-bridge/src/sessionArtifacts.ts — a different package and a deeper design decision about how the store merges same-identity upserts. Out of scope for this PR; worth a follow-up issue.

  • [rc:3671586277] "automatically recorded" is false on non-daemon surfaces — Declined. Gating on session type would require plumbing surface awareness into the write path; softening the wording would churn ~6 test assertions for a non-blocking preference. The maintainer's round-4 verification confirmed comfort with merging as-is.

  • [rc:3671586284] workspacePath anchored at mutable getTargetDir() — Declined. The reviewer confirmed this arithmetic is pre-existing, not introduced by this PR. Fixing it requires anchoring at the bound workspace root and auditing the CLI --worktree startup path — a separate concern best handled in its own change.

  • [rc:3671586288] Auto-recording changes eviction rate — Declined. Eviction policy lives in packages/acp-bridge/src/sessionArtifacts.ts. Narrowing the extension map or adding a lower-priority eviction class are both out of scope for this PR and worth a follow-up issue.

Verification

  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • vitest packages/core src/tools/write-file.test.ts — 70 passed
  • vitest packages/core src/tools/record-artifact.test.ts — 15 passed
  • vitest packages/cli src/serve/routes/workspace-file-read.test.ts — 35 passed
中文说明

第 5 轮 —— 评审反馈处理

阻断项(Critical)

  • [rc:3671586257] unsafe-markup 测试使用了 Windows 保留字符文件名 — 已修复。将 <img src=x onerror=alert(1)>.html 替换为 chart onerror=alert(1).html。新文件名不含 Win32 保留字符(<>),但仍通过 on…= 分支触发 hasUnsafeDisplayPayload,测试在 Windows 上可正常通过且依然有效。

已实施的建议

  • [rc:3671586267] 保护逻辑只镜像了 store 五条规则中的一条 — 已修复。buildWorkspaceArtifactMetadata 现在检查 daemon store 的全部五条规则:title 长度 ≤ 200、workspacePath 长度 ≤ 500、两者中的控制字符、两者中的不安全标记。从 record-artifact.ts 导出了 hasControlCharacter 以供复用。新增两个聚焦测试:title 超过 200 字符、workspacePath 目录组件中含 &amp; 实体。

  • [rc:3671586299] record_artifact 描述过于模糊 — 已修复。描述现在明确列出覆盖的文件类型("HTML、image、PDF 和 notebook 文件"),并明确告知模型对"其他格式如 Markdown、CSV、JSON 和纯文本,以及非 write_file 产生的文件"仍需调用 record_artifact

  • [rc:3671586311] buildRecordArtifactReminder 已是死代码 / 命名不符 / 与主逻辑分歧 — 已修复。buildRecordArtifactReminder 现在委托给 buildWorkspaceArtifactMetadata,两者从构造上保持一致。移除了重复的 getRecordArtifactWorkspacePath 调用。文档注释已更新为契约测试辅助函数的定位。

已拒绝的建议

  • [rc:3671586274] 文件名标题不可更改 — 拒绝。需要修改 packages/acp-bridge/src/sessionArtifacts.ts 中的 mergeArtifact 语义 —— 属于不同包、更深层的设计决策。超出本 PR 范围,建议单独开 issue 跟进。

  • [rc:3671586277] "automatically recorded" 在非 daemon 场景下不真实 — 拒绝。按会话类型门控需要在写入路径中引入场景感知;软化措辞会导致约 6 个测试断言的变动,且属于非阻断偏好。维护者第 4 轮验证已确认可按现状合并。

  • [rc:3671586284] workspacePath 锚定在可变的 getTargetDir() — 拒绝。评审者确认此路径计算是既有逻辑,非本 PR 引入。修复需要锚定到绑定的工作区根目录并审计 CLI --worktree 启动路径 —— 属于独立关注点,最好单独处理。

  • [rc:3671586288] 自动记录改变了淘汰速率 — 拒绝。淘汰策略位于 packages/acp-bridge/src/sessionArtifacts.ts。缩窄扩展名映射或添加低优先级淘汰类别均超出本 PR 范围,建议单独开 issue 跟进。

验证

  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • vitest packages/core src/tools/write-file.test.ts — 70 通过
  • vitest packages/core src/tools/record-artifact.test.ts — 15 通过
  • vitest packages/cli src/serve/routes/workspace-file-read.test.ts — 35 通过

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

@wenshao

wenshao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Maintainer local verification — round 3 (blocker re-check + live daemon A/B)

Verified head a96c93bdc2 against origin/main on macOS, in an isolated build tree. This round re-tests the blocker I filed this morning and the three suggestions the bot implemented alongside it, then re-runs the end-to-end evidence at the new head.

Verdict: merge-ready. The blocker is fixed, the guard now genuinely matches what the store enforces, and the feature works live. What's left is three untested guard clauses (§4) and one narrow behaviour change worth a follow-up (§5) — none of them reasons to hold the merge.


1. Blocker resolved — chart onerror=alert(1).html

The replacement filename is Win32-legal and still exercises the guard, so the merge-queue Windows job will no longer break on it:

"<img src=x onerror=alert(1)>.html"   win32Reserved=true    triggersGuard=true    (old)
"chart onerror=alert(1).html"         win32Reserved=false   triggersGuard=true    (new)

Checked against [<>:"/\|?*\x00-\x1f] — the reserved set for a Win32 path component — and against the live hasUnsafeDisplayPayload regex, which still matches via the on…= branch. Limit: I verified the character set and the guard behaviour, I did not execute the suite on Windows.

2. The guard now matches the store — this is the real fix

My review said the guard mirrored one of the five rules SessionArtifactStore.normalizeInput enforces, so the "automatically recorded" note could still be a lie. I re-ran the same rig: the real producer feeding the real SessionArtifactStore.upsertMany, non-strict, exactly as bridgeClient.upsertAndPublishArtifacts calls it.

guard vs store

All four cases that previously told the model "recorded" and were then silently dropped are now correctly skipped:

case store's reason (before) reviewed 2674aa4a12 head a96c93bd
206-char basename title exceeds 200 characters note shown, dropped no artifact, no note
U+200B in filename title contains control characters note shown, dropped no artifact, no note
out&amp;dir/weather.html workspacePath contains unsafe markup note shown, dropped no artifact, no note
524-char path workspacePath exceeds 500 characters note shown, dropped no artifact, no note

I also confirmed the guard is not over-strict in the direction that matters: all nine extensions in ARTIFACT_KIND_BY_EXTENSION round-trip into the store with the right kind, and the new .ipynb fallback is load-bearing and accepted — getSpecificMimeType('a.ipynb') really does return undefined, and the store takes application/x-ipynb+json (it is a display field capped at 120 chars, so this was worth checking rather than assuming).

I checked the store's remaining rules for anything the guard still misses. normalizeString trims before measuring, so the untrimmed check here is stricter, never looser; normalizeWorkspacePath additionally rejects absolute and escaping paths, which getRecordArtifactWorkspacePath already handles. I found no remaining case where the note is emitted and the store discards it.

3. Live qwen serve A/B at the current head

Real daemon, real write_file, fake model. The model writes the file and ends the turn — it never calls record_artifact, in either arm. Whatever appears in GET /session/:id/artifacts got there from the write alone.

live daemon A/B

Single-variable by construction: one tree, one daemon, only packages/core/src/tools/write-file.ts swapped between arms and rebuilt through the same scripts/build_package.js. Hashing the output — 3607 files per arm, only src/tools/write-file.{js,d.ts,js.map} and tsconfig.tsbuildinfo differ. acp-bridge, the CLI, the store and the routes are byte-identical.

BASE (origin/main)   reports/quarterly.html  -> file on disk, 0 artifacts
HEAD (a96c93bd)      reports/quarterly.html  -> file on disk, 1 artifact
                     {"title":"quarterly.html","kind":"html","storage":"workspace",
                      "workspacePath":"reports/quarterly.html","mimeType":"text/html",
                      "sizeBytes":59,"toolName":"write_file"}

Negative controls in the same run, both arms: the guard-rejected filename and src/index.ts each produce a file on disk and zero artifacts, so the HEAD result is not "everything becomes an artifact".

4. Mutation matrix — three clauses no test would notice losing

mutation matrix

Nine mutations, each proved to land before its run counted. Four caught, five survived — but only three of those are gaps:

M2 / M3 are not gaps. title is path.basename(filePath) and workspacePath always ends with that same basename, so any control character or markup in the title is also in the path. The title-side clauses can never be the sole reason to reject — they are redundant with M5/M6 by construction, and no test can catch removing them. Worth knowing before someone "adds coverage" for them.

These three are real — each is independently reachable and would regress in silence:

  • M4 workspacePath.length > 500 — reachable with a short filename in deep directories (my case: 524-char path, 6-char title).
  • M5 hasControlCharacter(workspacePath) — reachable with a control character in a directory segment only.
  • M9 the delegation. buildRecordArtifactReminder now delegating to buildWorkspaceArtifactMetadata is exactly the fix I asked for, but nothing pins it: I reverted it to compute the path independently and all 70 core tests plus all 40 workspace-file-read.test.ts tests still passed. The new equality assertion in emittedWorkspacePath only ever runs on paths that pass the guard, which is where the two agree anyway.

M9 is the one I'd most like to see pinned, because the whole point of the delegation is that a future caller can't reinstate the false claim. One assertion on a rejected filename does it — it can't go in emittedWorkspacePath, which throws when the reminder is null. I wrote it and checked both directions: 71/71 on the unmutated source, and it fails on M9 (1 failed / 70 passed).

// packages/core/src/tools/write-file.test.ts
import {
  WriteFileTool,
  buildRecordArtifactReminder,
  buildWorkspaceArtifactMetadata,
} from './write-file.js';

it('keeps the reminder and the artifact in lockstep when the guard rejects', () => {
  const config = {
    isRecordArtifactEnabled: () => true,
    getTargetDir: () => path.resolve('/ws'),
  } as unknown as Config;
  const rejected = path.resolve('/ws', 'reports', 'chart onerror=alert(1).html');
  expect(buildWorkspaceArtifactMetadata(config, rejected)).toBeNull();
  expect(buildRecordArtifactReminder(config, rejected)).toBeNull();
});

(path.resolve rather than a literal so the assertion holds on Windows too.)

5. One consequence of the guard worth a follow-up

Choosing drop over fall back to the old reminder is right in three of the four cases — but not the fourth. On main all five rejected filenames still got If this file is a reusable user-facing artifact, call record_artifact with workspacePath "…". At head they get no note at all, and record_artifact's new description tells the model write_file already handles HTML files.

I asked the real store whether a model-chosen title could have survived for each:

case record_artifact with a clean model title
206-char basename RECORDED — recoverable on main, lost at head
U+200B in filename dropped too (workspacePath contains control characters)
markup in directory dropped too (workspacePath contains unsafe markup)
524-char path dropped too (workspacePath exceeds 500 characters)

So the guard costs nothing for three of them — it converts a false claim into honest silence, which is a clear improvement. Only the long-filename case loses something real: main had a (probabilistic) path where the model registered it under its own short title, and head has none. Emitting the old reminder text instead of returning null would close that, and is a small change. Narrow enough that I would not hold the merge for it.

6. Carried-over items you declined — re-verified, and I agree they're follow-ups

sessionArtifacts.ts is byte-identical to when I reviewed, so these still stand. I re-ran the most consequential one against the current producer output rather than a hand-written artifact:

write_file emits : {"title":"q3.html"}
record_artifact  : {"title":"Q3 Revenue Report"}

changes emitted by the curated upsert : []
stored after both                     : [{"title":"q3.html"}]

control — different path  : change      control — external_url : change

write_file is now structurally the first writer for every workspace artifact, and mergeArtifact only reassigns title/description inside if (publishedUpdate). changes: [] means publishArtifactChanges emits nothing, so no client is notified — while record_artifact still answers Recorded artifact "Q3 Revenue Report". The controls confirm the empty change set is real and not a broken probe.

I accept all four as out of scope for this PR — they live in packages/acp-bridge and are design decisions, not defects introduced here. They are worth an issue: title/description lock-in (above), eviction pressure (auto-records share the 100-slot tool reservation with published artifacts), the "automatically recorded" claim on non-daemon surfaces (subagents, TUI, headless --prompt have no ToolResult.artifacts consumer), and getTargetDir() drift after POST /session/:id/cd. Happy to file them.

7. What I ran

packages/core   src/tools/write-file.test.ts        70 passed
packages/core   src/tools/record-artifact.test.ts   15 passed
packages/cli    src/serve/routes/workspace-file-read.test.ts   40 passed
packages/core   src/tools/  (full)                  2709 passed | 1 failed
prettier --check  (all five changed files)          clean

The single failure is client-mcp-registrar.test.ts, from an ajv / ajv-formats version split in my local node_modules (addFormats throws inside the MCP SDK's validator). Both that test and its subject are byte-identical to main on this branch, so it is not attributable to this PR.

Harness sources, raw output and the dist hash manifests for both arms are on pr-assets/pr-7914-verify under r3-*.


Recommendation: merge. The four items the bot implemented are all genuinely fixed, and the live A/B shows the feature doing what the PR claims at the current head. I'd like M9 pinned by a test — either in this PR or immediately after — since it's the assertion that keeps the two exports from drifting apart again. Everything else is follow-up work in another package.

中文说明

维护者本地验证 —— 第 3 轮(阻断项复检 + 真实守护进程 A/B)

在隔离的构建树中,于 macOS 上基于 origin/main 验证了 head a96c93bdc2。本轮复检了我今早提交的阻断项以及 bot 同批实现的三条建议,并在新 head 上重跑了端到端证据。

结论:可以合并。 阻断项已修复,guard 现在确实与 store 的校验规则一致,功能在真实环境下工作正常。剩下的是三条未被测试覆盖的 guard 子句(§4)和一处值得后续跟进的窄范围行为变化(§5),都不构成阻止合并的理由。

1. 阻断项已解决 —— chart onerror=alert(1).html

新文件名在 Win32 下合法,且仍能触发 guard,合并队列的 Windows 任务不会再因此失败:

"<img src=x onerror=alert(1)>.html"   win32Reserved=true    triggersGuard=true    (旧)
"chart onerror=alert(1).html"         win32Reserved=false   triggersGuard=true    (新)

对照 Win32 路径组件的保留字符集 [<>:"/\|?*\x00-\x1f] 以及实际的 hasUnsafeDisplayPayload 正则做了校验,后者仍通过 on…= 分支匹配。局限:我验证的是字符集与 guard 行为,并未在 Windows 上实际执行测试套件。

2. guard 现在与 store 一致 —— 这是本次真正的修复

我的评审指出 guard 只镜像了 SessionArtifactStore.normalizeInput 五条规则中的一条,因此"已自动记录"的提示仍可能是假的。我用同一套装置重跑:真实 producer 输入 真实 SessionArtifactStore.upsertMany,非 strict 模式,与 bridgeClient.upsertAndPublishArtifacts 的调用方式完全一致。

此前四种"告诉模型已记录、随后被静默丢弃"的情况现在都被正确跳过:

用例 store 丢弃原因(修复前) 评审时 2674aa4a12 head a96c93bd
206 字符文件名 title exceeds 200 characters 显示提示,被丢弃 无 artifact,无提示
文件名含 U+200B title contains control characters 显示提示,被丢弃 无 artifact,无提示
out&amp;dir/weather.html workspacePath contains unsafe markup 显示提示,被丢弃 无 artifact,无提示
524 字符路径 workspacePath exceeds 500 characters 显示提示,被丢弃 无 artifact,无提示

同时确认 guard 没有在关键方向上过严ARTIFACT_KIND_BY_EXTENSION 中全部九种扩展名都能以正确的 kind 进入 store;新增的 .ipynb 回退确实起作用且被接受 —— getSpecificMimeType('a.ipynb') 的确返回 undefined,store 也接受 application/x-ipynb+json(它是受 120 字符限制的 display field,所以值得实测而非想当然)。

我还核对了 store 其余规则是否仍有 guard 未覆盖之处:normalizeString 先 trim 再判断长度,因此这里的未 trim 检查只会更严、不会更松;normalizeWorkspacePath 额外拒绝绝对路径和越界路径,而 getRecordArtifactWorkspacePath 已处理。未发现仍会"发出提示但被 store 丢弃"的情况。

3. 当前 head 上的真实 qwen serve A/B

真实守护进程、真实 write_file、伪造模型。模型写完文件后直接结束回合 —— 两个分支中都从未调用 record_artifact 因此 GET /session/:id/artifacts 中出现的任何内容都只可能来自写入本身。

单变量由构造保证:同一棵树、同一个守护进程,两个分支之间只替换 packages/core/src/tools/write-file.ts,并通过同一个 scripts/build_package.js 重新构建。对产物做哈希 —— 每个分支 3607 个文件,仅 src/tools/write-file.{js,d.ts,js.map}tsconfig.tsbuildinfo 不同。 acp-bridge、CLI、store 和路由逐字节相同。

BASE (origin/main)   reports/quarterly.html  -> 文件已落盘,0 个 artifact
HEAD (a96c93bd)      reports/quarterly.html  -> 文件已落盘,1 个 artifact

同一次运行中的反向对照(两个分支都成立):被 guard 拒绝的文件名与 src/index.ts 都会落盘且产生个 artifact,因此 HEAD 的结果并非"什么都变成 artifact"。

4. 变异矩阵 —— 三条子句被删掉后没有任何测试会发现

九个变异,每个都先证明确实生效才计入结果。四个被捕获,五个存活 —— 但其中只有三个是真正的缺口:

M2 / M3 不是缺口。 title 就是 path.basename(filePath),而 workspacePath 必然以同一个 basename 结尾,所以 title 中的任何控制字符或标记也必然在 path 中。title 侧的两条子句永远不可能成为唯一的拒绝原因 —— 它们与 M5/M6 在构造上冗余,任何测试都无法捕获其删除。在有人试图"补测试覆盖"之前值得先知道这一点。

这三个是真的,每个都可独立触发,且失效时不会有任何声响:

  • M4 workspacePath.length > 500 —— 深层目录 + 短文件名即可触发(我的用例:524 字符路径,6 字符标题)。
  • M5 hasControlCharacter(workspacePath) —— 仅在目录段含控制字符时触发。
  • M9 委托关系。buildRecordArtifactReminder 委托给 buildWorkspaceArtifactMetadata 正是我要求的修复,但没有任何测试固定它:我把它改回独立计算路径后,core 的 70 个测试与 workspace-file-read.test.ts 的 40 个测试全部依然通过。 emittedWorkspacePath 中新增的相等断言只会在通过 guard 的路径上运行,而那正是两者本就一致的地方。

M9 是我最希望被固定的一个,因为委托的全部意义就在于让未来的调用方无法重新引入那个虚假声明。一个针对被拒绝文件名的断言即可 —— 它不能放进 emittedWorkspacePath,因为提示为 null 时该函数会抛错。我已写好并双向验证:未变异源码下 71/71 通过,且在 M9 下失败(1 失败 / 70 通过)。示例代码见英文部分。

5. guard 带来的一个后果,值得后续跟进

选择丢弃而非回退到旧提示,在四种情况中的三种是正确的,但第四种不是。在 main 上,这五个被拒绝的文件名仍会得到 If this file is a reusable user-facing artifact, call record_artifact with workspacePath "…"。而在 head 上它们完全没有提示,同时 record_artifact 的新描述又告诉模型 write_file 已经处理了 HTML 文件。

我向真实 store 询问了每种情况下"模型自选标题"能否存活:

用例 以干净的模型标题调用 record_artifact
206 字符文件名 成功记录 —— 在 main 上可挽救,在 head 上丢失
文件名含 U+200B 同样被丢弃(workspacePath contains control characters
目录含标记 同样被丢弃(workspacePath contains unsafe markup
524 字符路径 同样被丢弃(workspacePath exceeds 500 characters

所以 guard 在其中三种情况下没有任何代价 —— 它把虚假声明变成了诚实的沉默,这是明确的改进。只有超长文件名这一种确实有损失:main 上存在一条(概率性的)路径,模型可以用自己的短标题注册它,而 head 上没有了。把返回 null 改成发出旧的提示文本即可闭合,改动很小。范围足够窄,我不会为此阻止合并。

6. 你们拒绝的遗留项 —— 已复验,我同意作为后续处理

sessionArtifacts.ts 与我评审时逐字节相同,因此这些结论依然成立。我用当前的 producer 输出(而非手写 artifact)重跑了其中最关键的一项:

write_file 产出 : {"title":"q3.html"}
record_artifact : {"title":"Q3 Revenue Report"}

curated upsert 产生的 changes : []
两次写入后 store 内容          : [{"title":"q3.html"}]

对照 —— 不同路径 : change      对照 —— external_url : change

write_file 现在在结构上成为每个 workspace artifact 的首个写入方,而 mergeArtifact 只在 if (publishedUpdate) 分支内重新赋值 title/descriptionchanges: [] 意味着 publishArtifactChanges 不会发出任何事件,因此没有任何客户端会收到通知 —— 与此同时 record_artifact 仍然回复 Recorded artifact "Q3 Revenue Report".。两个对照用例证明这个空 changes 集是真实的,而不是探针失效。

我认可这四项都超出本 PR 范围 —— 它们位于 packages/acp-bridge,属于设计决策而非本次引入的缺陷。但值得开 issue 跟进:title/description 锁定(见上)、淘汰压力(自动记录与已发布 artifact 共享 tool 的 100 个配额)、非 daemon 场景下的"已自动记录"声明(subagent、TUI、headless --prompt 都没有 ToolResult.artifacts 的消费方)、以及 POST /session/:id/cd 之后 getTargetDir() 漂移。我可以负责提这些 issue。

7. 执行的验证

packages/core   src/tools/write-file.test.ts        70 通过
packages/core   src/tools/record-artifact.test.ts   15 通过
packages/cli    src/serve/routes/workspace-file-read.test.ts   40 通过
packages/core   src/tools/  (全量)                 2709 通过 | 1 失败
prettier --check(全部五个改动文件)                  无问题

唯一的失败是 client-mcp-registrar.test.ts,源于我本地 node_modulesajv / ajv-formats 的版本分裂(addFormats 在 MCP SDK 的校验器内抛错)。该测试及其被测文件在本分支上与 main 逐字节相同,因此与本 PR 无关。

装置源码、原始输出以及两个分支的 dist 哈希清单都在 pr-assets/pr-7914-verifyr3-* 下。

建议:合并。 bot 实现的四项都确实修复了,真实 A/B 也证明功能在当前 head 上达成了 PR 所声称的效果。我希望 M9 能被一个测试固定住 —— 本 PR 内或紧随其后都可以 —— 因为正是这个断言能防止两个导出再次分叉。其余都是另一个 package 中的后续工作。

@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.

Reviewed — no blockers. Suggestions are inline.

中文说明

已审查——无阻断问题。 建议见行内评论。

— qwen3.8-max-preview via Qwen Code /review

Comment thread packages/core/src/tools/write-file.test.ts
Comment thread packages/core/src/tools/write-file.ts
Comment thread packages/core/src/tools/write-file.ts Outdated
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 5/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 5/100 轮)。改动内容与我反驳保留之处如下:

Round summary

Addressed all three inline suggestions from the automated reviewer plus the maintainer's request to pin the reminder/artifact delegation (M9). No conflict (--conflict false), so no merge was performed.

Feedback points

  • [rc:3673000472] Shared length constants (Suggestion) — implemented. Exported ARTIFACT_TITLE_MAX_LENGTH (200) and ARTIFACT_WORKSPACE_PATH_MAX_LENGTH (500) from record-artifact.ts and referenced them at the validateString call sites for title and workspacePath and in the write-file.ts guard, so the two sides can no longer drift apart silently. validateManagedId's separate 200 literal was left untouched (different field). The daemon store's normalizeString lives in packages/acp-bridge and stays deferred per the maintainer's §6.

  • [rc:3673000457] Debug log on silent rejection (Suggestion) — implemented. Added debugLogger.debug('workspace artifact skipped (safety checks)', { path }) before the guard's return null, matching the module's existing object-payload debugLogger style. (This logger does not perform %s interpolation, so the suggested printf-style snippet would have printed a literal %s; the object form matches the TOCTOU rejections in the same file.) It fires only for artifact-like files that fail the safety guard, so it is not noisy.

  • [rc:3673000448] Guard-clause test coverage (Suggestion) — implemented. Added a workspace artifact metadata guard describe block with three tests that call the pure functions directly: an overlong workspace path (>500 chars, short title) and a control character in a directory segment. Per the maintainer's mutation matrix, a control character in the filename is redundant with the path check (the title is the basename), so the directory-segment case is the genuinely load-bearing one. The tests call the functions directly rather than writing real files, so they stay cross-platform safe — a control-character or 500+ char filename written through the tool would break the Windows merge-queue job (the §1 blocker).

  • Maintainer round-3 comment (@wenshao) — M9 pinned; §5/§6 acknowledged as follow-ups. Added the lockstep test the maintainer provided: for a guard-rejected filename, both buildWorkspaceArtifactMetadata and buildRecordArtifactReminder must return null, so reverting the reminder to compute the path independently fails the test. §5 (fall back to the old reminder text for the long-filename case) and the §6 items (title/description lock-in, eviction pressure, the non-daemon "automatically recorded" claim, and getTargetDir() drift) are left as follow-ups: the maintainer explicitly framed them as out of scope for this PR (§6 lives in packages/acp-bridge; §5 is a narrow behavior change they would not hold the merge for).

Mutation proof

Temporarily removed the two path-side guard clauses and reverted the delegation; all three new tests failed (3 failed | 70 skipped), then the source was restored and re-verified green. This confirms the tests pin M4, M5, and M9 rather than passing for an unrelated reason.

Verification

  • npm run build — passed (exit 0)
  • npm run typecheck — passed (exit 0)
  • npm run lint — passed (exit 0)
  • npx prettier --write on the three changed files — clean
  • vitest run src/tools/write-file.test.ts src/tools/record-artifact.test.ts (packages/core) — 88 passed (write-file 73, record-artifact 15)
  • vitest run src/serve/routes/workspace-file-read.test.ts (packages/cli, cross-package contract) — 40 passed
  • Mutation check on the three new tests — 3 failed under mutation, then restored and re-verified green
  • Integration tests after npm run bundle — not run: the touched behavior (constant refactor, best-effort debug log, new unit tests) is fully exercised by the unit and cross-package contract tests above; nothing is reachable only through the bundled CLI.
中文说明

本轮小结

处理了自动评审器提出的全部三条行内建议,以及维护者要求固定 reminder/artifact 委托关系(M9)的请求。无冲突(--conflict false),因此未执行 merge。

反馈点

  • [rc:3673000472] 共享长度常量(建议)——已实现。record-artifact.ts 导出 ARTIFACT_TITLE_MAX_LENGTH(200)与 ARTIFACT_WORKSPACE_PATH_MAX_LENGTH(500),并在 titleworkspacePathvalidateString 调用点以及 write-file.ts 的 guard 中引用,使两侧不再可能悄悄分叉。validateManagedId 中独立的 200 字面量保持不变(属于不同字段)。守护进程 store 的 normalizeString 位于 packages/acp-bridge,按维护者 §6 的结论继续作为后续处理。

  • [rc:3673000457] 静默拒绝时打 debug 日志(建议)——已实现。 在 guard 的 return null 之前加入 debugLogger.debug('workspace artifact skipped (safety checks)', { path }),与本模块现有的对象载荷 debugLogger 风格一致。(该 logger 不做 %s 插值,因此建议中 printf 风格的写法会原样打印出 %s;对象形式与同文件中 TOCTOU 拒绝的写法保持一致。)它只在“类似 artifact 的文件未通过安全校验”时触发,因此不会造成噪声。

  • [rc:3673000448] guard 子句的测试覆盖(建议)——已实现。 新增了一个 workspace artifact metadata guard describe 块,包含三个直接调用纯函数的测试:超长 workspace path(>500 字符、短标题)以及仅在目录段含控制字符的用例。按维护者的变异矩阵,控制字符放在文件名中与 path 侧检查冗余(title 就是 basename),因此放在目录段才是真正起作用的用例。这些测试直接调用函数而非真实写文件,从而保持跨平台安全——若通过工具写入带控制字符或超过 500 字符的文件名,会破坏 Windows 合并队列任务(即 §1 的阻断项)。

  • 维护者第 3 轮评论(@wenshao)——已固定 M9;§5/§6 作为后续跟进。 加入了维护者提供的 lockstep 测试:对于被 guard 拒绝的文件名,buildWorkspaceArtifactMetadatabuildRecordArtifactReminder 都必须返回 null,因此若把 reminder 改回独立计算路径,该测试就会失败。§5(超长文件名情况下回退到旧的提示文本)以及 §6 各项(title/description 锁定、淘汰压力、非 daemon 场景下的“已自动记录”声明、getTargetDir() 漂移)均作为后续跟进:维护者已明确将它们界定为本 PR 范围之外(§6 位于 packages/acp-bridge;§5 是一处窄范围行为变化,他们不会为此阻止合并)。

变异验证

临时删除两条 path 侧 guard 子句并回退委托关系后,三个新测试全部失败(3 失败 | 70 跳过);随后恢复源码并重新验证为全绿。这证明这些测试固定的是 M4、M5、M9,而非因无关原因恰好通过。

验证

  • npm run build —— 通过(exit 0)
  • npm run typecheck —— 通过(exit 0)
  • npm run lint —— 通过(exit 0)
  • 对三个改动文件执行 npx prettier --write —— 无问题
  • vitest run src/tools/write-file.test.ts src/tools/record-artifact.test.ts(packages/core)—— 88 通过(write-file 73,record-artifact 15)
  • vitest run src/serve/routes/workspace-file-read.test.ts(packages/cli,跨包契约)—— 40 通过
  • 对三个新测试做变异检查 —— 变异下 3 个失败,恢复后重新验证为全绿
  • npm run bundle 之后的集成测试 —— 未运行:本次改动的行为(常量重构、尽力而为的 debug 日志、新增单测)已完全被上面的单测与跨包契约测试覆盖;没有任何行为只能通过打包后的 CLI 触达。

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

@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.

No issues found. LGTM! ✅

中文说明

未发现问题。LGTM!✅

— qwen3.8-max-preview via Qwen Code /review

@chiga0
chiga0 added this pull request to the merge queue Jul 29, 2026
Merged via the queue into QwenLM:main with commit 97ed1da Jul 29, 2026
47 checks passed
pull Bot pushed a commit to Little-Star888/qwen-code that referenced this pull request Jul 30, 2026
…unds (QwenLM#8010)

* feat(verify-pr): add four techniques from maintainer verification rounds

Two hand-written maintainer rounds contained methods the skill could not
have produced. Checked each against the current text before adding it;
these four had no coverage at all.

From QwenLM#7914 (live daemon A/B on the artifact-recording change):

- Run every control on BOTH arms. That round's sharpest finding came
  from a control whose only job was to validate the BASE probe — "the
  empty list is a real absence, so have the model call record_artifact
  and watch an entry appear". Run on head as well, it showed the
  curated title being silently discarded. The control was not hunting
  for a bug; running it symmetrically is what found one.

- A new writer into a shared store is an ordering change. The PR added
  write_file as a second writer into the artifact list; the bug was not
  in the new writer but in the collision, where a pre-existing
  first-writer-wins merge began discarding record_artifact's curated
  title and description while still reporting success. Enumerate the
  other writers, exercise the collision in both orders, and check what
  the loser is told — and separate the pre-existing cause from the PR's
  contribution so the author is not blamed for the policy.

From QwenLM#7998 (ink cursor fix, real-terminal A/B):

- When the oracle is an instrument, corroborate it with a mechanism
  that does not use that instrument. The hardware cursor row came from
  `tmux display-message -p '#{cursor_y}'`, then from a marker printed
  after the TUI exits — which lands wherever the cursor actually was.
  Two agreeing instruments turn a measurement into evidence; one tool's
  report about the system is not the system.

- Re-run the generator on committed generated artifacts and diff. That
  round re-ran `npx patch-package ink` and found byte-different hunk
  headers, proving the .d.ts hunks were hand-written rather than
  regenerated as the description claimed.

Also strengthens Not covered: proving a limitation is environmental
requires an A/A control (boot base and head identically, show both fail
the same way), because "seems environmental" and a real regression look
identical in a report.

Mutation-verified 4/4, each with landing proof. Two initially reported
`landed: False` — the assertions match the whitespace-normalised text
while the rules wrap across lines in the source, so the replace never
fired and the green result proved nothing. Re-run against line-accurate
anchors, both kill.

89/89 tests; prettier and eslint clean.

* feat(verify-pr): teach the timing-race and scenario-arrival checks

Third maintainer round mined for method (QwenLM#7934 R4). The blocker it found
had zero coverage in the skill — `timer`, `wall-clock`, `flake`,
`retry`, `duration`, `deterministic` all returned 0, and the one `race`
hit was a substring of "trace".

- **Timing-triggered assertions have a threshold — measure it, do not
  sample it.** A new guard (`expect(false).toBe(true)` after an abort
  loop) turned a vacuous pass into a deterministic failure, because the
  case triggers its abort from `setTimeout(..., 1000)` while the query's
  duration is set by CLI startup rather than the server. Natural
  completion measured 730-2151 ms, so every box on the fast side of
  1000 ms fails. The rule says to measure the operation's natural
  duration with the trigger disabled and compare it to the timer,
  because a green run only proves this box was slow enough.

- **A speed-correlated failure is not flake, and a retry budget does not
  absorb it.** Random flake becomes a pass under `retry: 2`; this failed
  5/5 runs on all three attempts. The two get opposite verdicts, so the
  kind has to be established before the verdict is written.

  Stated plainly in the skill: the verify job runs on a shared, loaded
  runner — the regime where such a test PASSES. Repetition cannot
  reproduce a fast-machine failure there; only computing the margin can.
  A rule that said "run it more times" would be useless in this lane.

- **The failure one level before vacuity: the scenario never reached the
  code under test.** The vacuity check asks whether an assertion can
  fail; this asks whether the code ever ran. Four abort cases fired
  during CLI process startup, so the fake server saw zero requests and
  a suite named for mid-stream aborts never streamed — with every
  assertion green. Instrument the seam and assert the count is
  non-zero.

Mutation-verified 5/5, each with landing proof against line-accurate
anchors.

89/89 tests; prettier and eslint clean. Skill is 472 lines, up from 392.

* feat(verify-pr): six more techniques, from three maintainer rounds

Mined QwenLM#7836 R2, QwenLM#7885 and QwenLM#7899 for method. Checked each candidate
against the current text first; six had zero coverage, the rest were
already there (harness teeth-checks, pin dereferencing, boundary
probing, and the follow-up round's "re-measure, never diff").

The one that corrects the skill's own core method, from QwenLM#7836:

- **Before calling a survivor vacuous, escalate to a finer mutation.**
  A whole-file revert is blunt enough to remove the PRECONDITION a test
  depends on, so a good test goes green because its scenario no longer
  occurs — from the outside, identical to a test that asserts nothing.
  A `finally`-cleanup test survived reverting four production files and
  died to deleting one line. Coarse survived + fine killed ⇒ the test
  is fine and the mutation was wrong. A false "your test is vacuous"
  costs the author more than a missed survivor does.

From QwenLM#7836, the root cause shared by both of its blockers:

- **When the same predicate is checked in two places, verify they see
  the same state.** A guard duplicated across a process boundary is two
  implementations of one question that diverge when their INPUTS differ.
  One settings key made a route ask sessionExistsInAnyState() with an
  unpinned runtime dir while the child asked with a pinned one, turning
  a clean 409 into a 500 plus a process.exit(1) that killed every
  session on the channel. Includes the temporal half: lazily-created
  backing files leave a window where a just-created entity is invisible
  to any on-disk existence check.

- **Measure the blast radius on bystanders.** The caller's own error
  code understates a shared-state failure; the number that matters is
  an unrelated session going 200 -> 404 and a workspace list going
  2 -> 0.

From QwenLM#7885, which took a performance claim apart:

- **Isolate the slice the mechanism can actually affect.** A speedup
  claim is two claims: the mechanism works, and the thing it speeds up
  matters. `--ignore-scripts` isolated what an npm download cache can
  touch — 36s of a 226s install — so the ceiling was 20s and the real
  saving 15%, not the claimed 75%. Then check it against the whole job:
  33s off 14m37s.

- **A mechanism that persists something has a cost — price it.** 219 MB
  per lockfile hash into a pool at 9.98 GB of a 10 GB cap, with 39
  distinct lockfile states in 30 days: at the cap every entry evicts by
  LRU, including entries other jobs need and its own.

- **Test the scarier consequences and report which do NOT hold.** The
  write-path finding was real; code injection was disproved (npm
  integrity-checks a tampered cache and refetches) and privilege
  escalation was disproved (chown -R does not follow symlinks). A
  finding that names what it is not is harder to wave away.

- **Verify third-party actions from their own manifest.** The PR said
  the cache dir was discarded after the job; `action.yml` declares
  `post: dist/save/index.js` with `post-if: success()`, which uploads
  it as root with credentials intact — the opposite of the claim, and
  the whole finding.

From QwenLM#7899:

- **To exercise real production data safely, interpose a refusing proxy
  on the write path.** Wrap the client so every mutating call hard-
  fails, then run the shipped script verbatim: real counts, mechanical
  guarantee of no side effects.

Mutation-verified 9/9, each with landing proof against line-accurate
anchors. 89/89 tests; prettier and eslint clean. The skill is now 546
lines, up from 392 — the growth is deliberate and called out in the PR
body's risk section.

* feat(verify-pr): decomposed fixes, contextual limits, destination counts

From QwenLM#7862 R4. Three additions, and a deliberate stop.

- **When one fix bundles two changes, build the intermediate variants.**
  An A/B against base proves the pair works and says nothing about what
  each half does. That round compiled a third build with only the
  ordering change reverted, and the three-row table showed the halves
  do different jobs: moving `initialized = true` after the fallible work
  converts a 2,999-and-climbing backlog flood into a fail-safe retry,
  while `reduce()` restores liveness. Either alone leaves a channel that
  floods or wedges — a conclusion the two-cell A/B cannot reach.

- **A limit measured in isolation does not transfer to the real call
  site.** The same `Math.max` spread threw between 110k and 130k
  elements inside a deep async stack, well below a standalone
  micro-benchmark. Bisect thresholds through the real code path and
  quote the harness; a limit taken from documentation or a toy loop is a
  guess about the system under test.

- **Count at the destination, not at the component boundary.** The
  mirror of the scenario-arrival rule added earlier: envelopes the
  adapter emitted and prompts that reached the agent are different
  numbers, and every gate lives between them. A count taken at the seam
  can be right while the feature is silently dropped downstream.

Declined from the same report, to protect prompt budget rather than
because they are wrong: siblings-as-convention-oracle (the lockfile
version table across five channels), degenerate fixtures that cannot
distinguish two sort keys, and naming the condition under which a
cosmetic finding becomes real. Each is a good technique; none is worth
another rule competing for attention with the ones already here.

The skill is now 578 lines, up from 392 on main (+47%) across this
branch. That growth is the main risk on this PR and further additions
should wait until a live round shows the current set changes behaviour.

Mutation-verified 3/3 with landing proof. One mutation initially
SURVIVED — it deleted text sitting AFTER the asserted phrase, so the
assertion still matched and the green proved nothing; re-run against
the phrase itself, it kills.

89/89 tests; prettier and eslint clean.

* test(scripts): drop stale technique count from verify test name (QwenLM#8010)

* fix(triage): correct verify-skill worked examples and verdict path (QwenLM#8010)

Address review feedback on the verification-techniques skill:

- Make the npm-cache worked example's numbers close: separate the 20 s
  download-slice ceiling (36 s to 16 s) from the 15% end-to-end saving
  (226 s to 193 s) rather than conflating them.
- Stop overstating the tarball experiment: one tarball was poisoned, and
  the 2262-entry integrity coverage is a separate static fact.
- Give the speed-correlated-failure rule a contract-legal verdict path by
  encoding the margin as a scripted assertion, and mark the load/idle
  sweep as the local-mode variant.
- Fix the one bullet that broke its 2-space list continuation.
- Pin the new contract-encoding clause in the workflow test.

---------

Co-authored-by: wenshao <wenshao@example.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.2.

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

Labels

autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants