Skip to content

fix(dingtalk): attach media from quoted messages - #9347

Merged
qqqys merged 7 commits into
QwenLM:mainfrom
qqqys:agent/dingtalk-quoted-media
Aug 20, 2026
Merged

fix(dingtalk): attach media from quoted messages#9347
qqqys merged 7 commits into
QwenLM:mainfrom
qqqys:agent/dingtalk-quoted-media

Conversation

@qqqys

@qqqys qqqys commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Allow the DingTalk channel to download media from a message being replied to and attach it to the current agent prompt. Quoted pictures are provided as image data, while quoted files, audio, and video use the existing temporary-file attachment path. The user's outer question and the readable quote placeholder remain intact.

Why it's needed

DingTalk includes a downloadCode when a user replies to a picture or file, but the channel previously kept only a textual placeholder such as [image] or [file: report.pdf]. The agent could tell that media had been quoted but could not inspect the referenced content. This change routes the quoted media through the channel's existing authenticated download and attachment flow.

Reviewer Test Plan

How to verify

Reply to a picture and ask the bot to inspect it. Confirm the outer question remains the prompt, the quote remains visible as [image], the quoted download code reaches DingTalk's media API, and an image attachment is delivered. Repeat with a file and confirm its name, MIME type, bytes, and temporary path are preserved. Audio and video should follow the same file-backed path. Finally, omit the download code or make token/media retrieval fail and confirm the prompt is still delivered once with its quote placeholder and no attachment.

Evidence (Before & After)

Before: real reply callbacks contained repliedMsg.content.downloadCode for quoted pictures and files, but the channel discarded that field and delivered only a placeholder to the agent.

After: adapter-level regressions cover successful picture, file, audio, and video downloads; missing download codes; access-token failure; media-API failure; prompt preservation; attachment metadata; file contents; and failure-safe temporary cleanup.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Isolated DingTalk Stream callback capture and local Node.js workspace with independently installed dependencies.

Risk & Scope

  • Main risk or tradeoff: Quoted media adds the same network and temporary-file work already used for top-level DingTalk media; existing download timeouts, size limits, MIME handling, filename sanitization, and best-effort failure behavior are reused.
  • Not validated / out of scope: Quoted rich-text pictures and undocumented unknownMsgType file variants are not handled without a verified callback shape. Media embedded inside combined-forward records is also out of scope.
  • Breaking changes / migration notes: None.

Linked Issues

N/A.

中文说明

本 PR 做了什么

让钉钉 Channel 能下载用户正在回复的消息媒体,并把它附加到当前 Agent prompt。引用图片以内嵌图片数据提供,引用文件、音频和视频复用现有临时文件附件链路。用户在外层提出的问题和可读的引用占位符都会保留。

为什么需要

用户回复图片或文件时,钉钉会提供 downloadCode,但 Channel 之前只保留 [image][file: report.pdf] 这类文本占位符。Agent 能知道用户引用了媒体,却无法检查实际内容。本改动将引用媒体接入 Channel 现有的鉴权下载与附件流程。

Reviewer Test Plan

如何验证

回复一张图片并让机器人检查它。确认外层问题仍作为 prompt,引用仍显示为 [image],引用消息的下载码被传给钉钉媒体接口,并且 Agent 收到图片附件。再回复一个文件,确认文件名、MIME 类型、字节内容和临时路径均被保留。音频和视频应走相同的文件附件链路。最后省略下载码,或者让 token/媒体下载失败,确认 prompt 仍只投递一次,保留引用占位符且不产生附件。

证据(修改前后)

修改前:真实引用图片和文件回调的 repliedMsg.content.downloadCode 中包含下载码,但 Channel 会丢弃该字段,只把占位符交给 Agent。

修改后:适配器级回归测试覆盖图片、文件、音频和视频下载成功,缺少下载码,访问 token 失败,媒体接口失败,prompt 保留,附件元数据,文件内容以及失败安全的临时目录清理。

测试平台

OS 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

使用隔离的钉钉 Stream 回调捕获和独立安装依赖的本地 Node.js 工作区。

风险与范围

  • 主要风险或取舍:引用媒体会产生与顶层钉钉媒体相同的网络请求和临时文件操作;本改动复用现有下载超时、大小限制、MIME 处理、文件名清理和尽力降级行为。
  • 未验证或不在范围内:在没有真实回调结构前,不处理引用富文本图片和未文档化的 unknownMsgType 文件变体。合并转发记录内部的媒体同样不在范围内。
  • 破坏性变更或迁移说明:无。

关联 Issue

无。

@qqqys

qqqys commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

E2E Test Report

Environment

  • macOS
  • Isolated DingTalk Stream test application and channel process
  • Branch based directly on upstream/main; no chat-record commits
  • Existing dws-local process left running and unchanged throughout the test

Live payload verification

  1. Replied to a picture while mentioning the isolated test bot.
  2. DingTalk delivered an outer text callback whose text.repliedMsg.msgType was picture and whose quoted content carried a downloadCode.
  3. Repeated with a file. DingTalk delivered msgType: file with fileName and downloadCode.
  4. These observed fields match the new attachment path; no undocumented lookup or message-history API is required.

Post-change verification

  • Focused adapter tests: 130/130 passed
  • DingTalk package: 10/10 files, 305/305 tests passed
  • Focused ESLint: passed
  • Focused Prettier check: passed
  • Repository build and bundle: passed during the independent dependency install
  • Repository typecheck: passed after the build
  • Read-only test-engineer review: clean; confirmed the branch contains no chatRecord code or dependency

The regressions cover successful picture, file, audio, and video attachments; outer-question and placeholder preservation; exact file bytes and metadata; missing download codes; token failure; media-API failure; and failure-safe cleanup of test-created temporary directories.

Known boundaries

  • Quoted rich-text pictures and undocumented unknownMsgType file variants are not parsed without a verified payload.
  • Media embedded inside combined-forward records remains outside this PR.

@qqqys
qqqys marked this pull request as ready for review August 18, 2026 02:24
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

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

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ — all sections filled in, bilingual, with a concrete reviewer test plan.

Problem: this is an observed gap, not a theoretical one. DingTalk callbacks for replies to pictures/files carry repliedMsg.content.downloadCode, and the adapter currently drops it, delivering only a [image] / [file: …] placeholder to the agent. The PR description and the E2E report in this thread document real callback payloads carrying that field.

Direction: aligned. The DingTalk channel already downloads top-level media through an authenticated flow (with the 50 MB cap and 30 s timeout added in #7361); quoted media is the same capability applied to the message being replied to. CHANGELOG shows this area is actively maintained (#7361, #7698, #8639).

Size: not a core-module path (packages/channels/dingtalk/), and small either way — 41 production lines (+40/−1 in DingtalkAdapter.ts) plus 295 lines of tests.

Approach: scope feels right. The change reuses the existing attachMedia path instead of adding a parallel download flow: extractQuotedContext now surfaces { downloadCode, mediaType, fileName } when the quoted message is a picture/file/audio/video with a download code, and processMessage attaches it before handleInbound. Placeholder behavior, failure fallback, and temp-file handling are all inherited rather than re-implemented. No unrelated edits in the diff. The explicit out-of-scope list (richText pictures, unknownMsgType variants, combined-forward media) is reasonable without verified callback shapes.

Risk: no elevated risk signals — no high-risk paths matched the revert-history patterns.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ —— 各部分齐全、中英双语,且提供了具体的 reviewer 测试计划。

问题:这是已观测到的缺口,不是理论性问题。用户回复图片/文件时,钉钉回调会携带 repliedMsg.content.downloadCode,而适配器目前丢弃了该字段,只把 [image] / [file: …] 占位符交给 Agent。PR 描述和本帖中的 E2E 报告都给出了携带该字段的真实回调结构。

方向:对齐。钉钉 Channel 已经通过鉴权流程下载顶层媒体(#7361 加入了 50 MB 上限和 30 秒超时);引用媒体只是把同样的能力应用到被回复的消息上。CHANGELOG 显示该模块一直在活跃维护(#7361#7698#8639)。

规模:不属于核心模块路径(packages/channels/dingtalk/),且本身很小 —— 生产代码 41 行(DingtalkAdapter.ts +40/−1),测试 295 行。

方案:范围合理。改动复用了现有 attachMedia 链路而不是新增一条并行的下载流程:extractQuotedContext 在引用消息是 picture/file/audio/video 且带下载码时返回 { downloadCode, mediaType, fileName }processMessagehandleInbound 之前完成附加。占位符行为、失败降级和临时文件处理全部继承现有实现,没有重复实现。diff 中没有无关改动。明确列出的范围外内容(富文本图片、unknownMsgType 变体、合并转发内的媒体)在没有真实回调结构前不处理,是合理的。

风险:无升级风险信号 —— 未命中 revert 历史高风险路径。

进入代码审查 🔍

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

Clean, minimal change — exactly the approach a reviewer would hope for. extractQuotedContext now surfaces the quoted message's { downloadCode, mediaType, fileName } when the reply target is a picture/file/audio/video carrying a download code, and processMessage runs it through the existing attachMedia path before handleInbound. No parallel download logic, no duplication.

Things I verified while reading:

  • The failure path is genuinely failure-safe: downloadMedia never throws (whole body wrapped, returns null; 30 s timeout and streaming 50 MB cap inherited from fix(dingtalk): cap media download size to 50MB to match feishu #7361), and token failures return early inside attachMedia. A failed quoted-media download degrades to placeholder-only delivery and the prompt still goes out exactly once — the new tests pin each of these branches.
  • DingTalkRepliedMsg.content already declares downloadCode/fileName, so no type gymnastics were needed; the switch correctly ignores message types without a media mapping (richText pictures, unknownMsgType — called out as out of scope in the description).
  • The placeholder-stripping inside attachMedia only fires on exact matches of the top-level placeholders ((file: …), (audio), (video)), so the user's outer question is never cleared by a quoted-media attach.
  • Filename handling reuses basename() sanitization and a per-download randomUUID() temp dir; the download code only ever goes back to DingTalk's own media API, so this adds no attack surface beyond the existing top-level media path.
  • The legacy quoteMessage format carries no media fields and is correctly left unchanged; when the outer message is itself media, both attachments land (top-level first) without interfering.

No blockers, no convention issues.

Testing evidence (the PR's own CI, read via API — PR code not executed locally)

The Qwen Code CI workflow run for this commit completed success. The macOS/Windows unit jobs and the CLI integration job were skipped by the workflow's Classify PR gating (the run still concluded green), so the unit signal comes from the ubuntu job. Its log shows the adapter suite including this PR's new tests actually ran: ✓ src/DingtalkAdapter.test.ts (130 tests) 925ms. Not verified: real DingTalk callback behavior — see the sandboxed-verification note below.

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

Check Conclusion
Classify PR ✅ success
Dependency CVE audit ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
route ✅ success
Secret scan (TruffleHog) ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

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

Sandboxed verification would settle the remaining gap: @qwen-code /verify — that the quoted-media attach genuinely changes the delivered envelope (and the new tests pin that change against the base build) is currently backed by the PR's own suite plus the author's E2E report, not independently re-run; the live-callback shape (repliedMsg.content.downloadCode on quoted pictures/files) ultimately rests on the author's captured payloads.

中文说明

代码审查

改动干净、最小化,正是 reviewer 期望的做法:extractQuotedContext 在引用消息是 picture/file/audio/video 且携带下载码时返回 { downloadCode, mediaType, fileName }processMessagehandleInbound 之前复用现有 attachMedia 链路完成附加。没有并行的下载逻辑,也没有重复实现。

阅读时核实过的点:

  • 失败路径确实安全降级:downloadMedia 从不抛错(整体包裹、返回 null,继承 fix(dingtalk): cap media download size to 50MB to match feishu #7361 的 30 秒超时与流式 50 MB 上限),token 失败在 attachMedia 内提前返回。引用媒体下载失败时降级为仅占位符投递,prompt 仍只投递一次——新增测试逐一钉住了这些分支。
  • DingTalkRepliedMsg.content 本就声明了 downloadCode/fileName,无需类型上的额外处理;switch 正确忽略无媒体映射的消息类型(富文本图片、unknownMsgType——描述中已列为范围外)。
  • attachMedia 内的占位符清理只在精确匹配顶层占位符((file: …)(audio)(video))时触发,因此用户的外层问题不会被引用媒体附加过程清空。
  • 文件名复用 basename() 清理与按次 randomUUID() 临时目录;下载码只会回传给钉钉自己的媒体接口,相比现有顶层媒体链路没有新增攻击面。
  • 旧版 quoteMessage 格式不携带媒体字段,正确地未做改动;当外层消息本身是媒体时,两个附件依次落地(先顶层后引用),互不干扰。

无阻塞问题,无规范问题。

测试证据(通过 API 读取 PR 自身 CI —— 未在本地执行 PR 代码)

该 commit 的 Qwen Code CI 工作流运行结果为 success。macOS/Windows 单测与 CLI 集成任务被工作流的 Classify PR 分类跳过(整个运行仍为绿),单测信号来自 ubuntu 任务。其日志显示包含本 PR 新测试的适配器套件确实执行过:✓ src/DingtalkAdapter.test.ts (130 tests) 925ms。未验证:真实钉钉回调行为——见下方沙箱验证说明。

(CI 明细见上方表格。)

沙箱验证可以补齐剩余缺口:@qwen-code /verify —— 引用媒体附加确实改变了投递的 envelope(且新测试相对基线构建钉住了该变化)目前只有 PR 自身套件和作者 E2E 报告背书,未经独立复跑;真实回调结构(引用图片/文件上的 repliedMsg.content.downloadCode)最终依赖于作者捕获的载荷。

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — clean, minimal change that reuses the existing media path end to end; the only reservation is that live DingTalk behavior rests on the author's E2E capture rather than something CI can reproduce.

This is what a good channel fix looks like. The problem is real and observed — DingTalk hands the adapter a downloadCode for the message being replied to, and today it's dropped on the floor, leaving the agent staring at an [image] placeholder it can't act on. The fix doesn't invent anything: quoted media rides the exact same attachMedia flow as top-level media, so the timeouts, size caps, filename sanitization, temp-file handling, and failure-safe degradation all come along for free — and the test suite pins each of those branches, including "download fails, prompt still delivered once."

My independent read of how this should be built matched what the PR does; I didn't find a simpler path, and there's nothing in the diff beyond what the goal needs. CI is green for this commit (the skipped platform jobs are the workflow's PR-classification gating, not failures). The two things a maintainer may still want before merge are the ones CI can't provide here: a @qwen-code /verify run to independently prove the change is load-bearing against the base build, and ideally a live reply-to-a-picture smoke test with real DingTalk credentials. Neither blocks — the code and its mocked-path evidence are solid.

Approving. ✅

中文说明

置信度:4/5 —— 改动干净、最小,端到端复用了现有媒体链路;唯一的保留意见是真实钉钉行为依赖作者的 E2E 捕获,CI 无法复现。

这是一个规范的 channel 修复。问题真实且已被观测到:钉钉回调会把被回复消息的 downloadCode 交给适配器,而目前该字段被丢弃,Agent 只能看到一个无法处理的 [image] 占位符。修复没有发明新东西:引用媒体走的是与顶层媒体完全相同的 attachMedia 链路,超时、大小上限、文件名清理、临时文件处理和失败安全降级全部自然继承——测试套件逐一钉住了这些分支,包括"下载失败时 prompt 仍只投递一次"。

我独立设想的实现方式与 PR 一致,没有找到更简的路径,diff 中也没有超出目标所需的改动。该 commit 的 CI 为绿(被跳过的平台任务是工作流的 PR 分类机制,不是失败)。合并前维护者可能还想要两样 CI 无法提供的东西:一次 @qwen-code /verify 运行,独立证明该改动相对基线构建确实有效;以及最好用真实钉钉凭据做一次"回复图片"的实测。两者都不构成阻塞——代码及其 mock 路径证据是扎实的。

批准。✅

Qwen Code · qwen3.8-max

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

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

Comment on lines +1781 to +1784
if (quoted.media) {
await this.attachMedia(
envelope,
quoted.media.downloadCode,

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.

[Critical] R1-1: The quoted-media path routes the user's typed text through attachMedia's placeholder-cleanup — a reply whose entire text is exactly (audio), (video), or (file: <quoted fileName>) is silently erased before handleInbound. That cleanup (lines 1611-1618) was written for the direct-media path, where extractContent itself generates those round-bracket placeholders; on this new path envelope.text is the user's actual reply text, which this diff makes newly reachable by the comparison. — Failure scenario: a user replies to a quoted audio/video/file message that carries a downloadCode with the exact text (audio) / (video) / (file: report.pdf) (report.pdf matching the quoted file's name; e.g. group reply @Bot (audio) — the mention is stripped earlier, leaving the exact match). The quoted media attaches successfully, the cleanup matches, envelope.text becomes '', and the agent receives the attachment with an empty prompt — the user's instruction is silently lost. Secondary variant: the own file download fails (placeholder (file: a.pdf) kept) while a quoted file named a.pdf attaches successfully — the quoted call then erases the direct file's leftover placeholder.

Witness (probe through the real adapter):

PR code:                                   PROBE-F1a: text="" attachments=["audio"]   <- user text blanked
fix-flip (cleanup skipped for quoted call): PROBE-F1a: text="(audio)" attachments=["audio"]

(the (file: report.pdf) variant flips the same way; the file's suite is 134/134 green with the fix applied)

Suggested fix (spans two sites, so no one-click suggestion): give attachMedia a parameter such as cleanPlaceholderText?: string, pass it only from the direct-media call site (the placeholder extractContent produced), and erase only when it is provided and equals envelope.text. The quoted call passes nothing.

中文说明

[Critical] R1-1:引用媒体路径会把用户输入的文本送进 attachMedia 的占位符清理逻辑 —— 若回复的完整文本恰好是 (audio)(video)(file: <被引用文件名>),会在 handleInbound 之前被静默清空。该清理逻辑(1611-1618 行)原本是为直发媒体路径编写的 —— 那里 extractContent 自己生成这些圆括号占位符;而在这条新路径上,envelope.text 是用户真实输入的文本,本 diff 使其首次能被该比较命中。 — 失败场景:用户回复一条带 downloadCode 的音频/视频/文件引用消息,文本恰好是 (audio) / (video) / (file: report.pdf)(report.pdf 与被引用文件名一致;例如群聊中 @Bot (audio) —— @ 提及被去除后剩下精确匹配)。引用媒体下载并附加成功后,清理逻辑命中,envelope.text 被置为 '',Agent 只收到附件和空 prompt —— 用户的指令被静默丢弃。次要变体:自身文件下载失败(保留占位符 (file: a.pdf))而引用的同名文件 a.pdf 附加成功时,引用调用会清掉直发文件遗留的占位符。

见证(通过真实适配器的探针):

PR 代码:                                  PROBE-F1a: text="" attachments=["audio"]   <- 用户文本被清空
修复翻转(引用调用跳过清理):              PROBE-F1a: text="(audio)" attachments=["audio"]

(file: report.pdf) 变体同样翻转;应用修复后该文件测试 134/134 全绿)

建议修复(跨两处,故不提供一键 suggestion):给 attachMedia 增加 cleanPlaceholderText?: string 参数,仅由直发媒体调用点传入(即 extractContent 生成的占位符),且仅当该参数提供且与 envelope.text 相等时才清空;引用调用不传。

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

Comment on lines +1781 to +1788
if (quoted.media) {
await this.attachMedia(
envelope,
quoted.media.downloadCode,
quoted.media.mediaType,
quoted.media.fileName,
);
}

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.

[Critical] R1-2: The new quoted-media attach makes text-only replies reach attachMedia's unguarded sync throw sites (mkdirSync/writeFileSync at ~1604-1608 and basename(fileName || '')). Any throw rejects processMessage, whose .catch sends the generic error reply and never calls handleInbound — the user's reply text is dropped, and the msgId was already recorded in seenMessages, so a DingTalk retry is deduped and the prompt is permanently lost. This contradicts the graceful-degradation contract this PR's own tests assert for download failures ("keeps processing the prompt when a quoted-media download fails"). Before this diff, a text-only reply quoting media had zero filesystem dependency and always dispatched. — Failure scenario: a user replies with plain text to a quoted file/audio/video message and the temp write fails — disk quota/ENOSPC on the up-to-50 MB write, a quoted fileName longer than 255 bytes (basename() does not truncate → ENAMETOOLONG), or a truthy non-string fileName (basename TypeError) → the user's text never reaches the agent and they get "Sorry, something went wrong processing your message."

Witness (probe through the real adapter, download mocked to succeed):

fileName='a'.repeat(300) -> stderr "ENAMETOOLONG: name too long, open '/tmp/channel-files/...'"
                            handleInbound calls: 0, generic error sent
fileName=12345            -> stderr "TypeError [ERR_INVALID_ARG_TYPE]", handleInbound calls: 0
control (normal fileName) -> handleInbound calls: 1
fix-flip (try/catch around the fs block) -> both failure classes: handleInbound calls: 1, no error reply

The one-click suggestion below contains the throw at the new call site so the text is always delivered (the disk write still fails — the attachment is just skipped, matching the download-failure degradation). Alternatively, guard the mkdirSync/writeFileSync/basename sites inside attachMedia, which also protects the pre-existing direct-media path.

Suggested change
if (quoted.media) {
await this.attachMedia(
envelope,
quoted.media.downloadCode,
quoted.media.mediaType,
quoted.media.fileName,
);
}
if (quoted.media) {
try {
await this.attachMedia(
envelope,
quoted.media.downloadCode,
quoted.media.mediaType,
quoted.media.fileName,
);
} catch (error) {
process.stderr.write(
`[DingTalk] Cannot attach quoted media: ${String(error)}\n`,
);
}
}
中文说明

[Critical] R1-2:新增的引用媒体附加使纯文本回复也能到达 attachMedia 中未加保护的同步抛错点(约 1604-1608 行的 mkdirSync/writeFileSync 以及 basename(fileName || ''))。任何抛错都会使 processMessage reject,而其 .catch 只发送通用错误回复、从不调用 handleInbound —— 用户的回复文本被丢弃,且 msgId 已先写入 seenMessages,钉钉重试会被去重,prompt 永久丢失。这与本 PR 自己的测试所断言的下载失败优雅降级契约("keeps processing the prompt when a quoted-media download fails")相矛盾。此 diff 之前,引用媒体的纯文本回复没有任何文件系统依赖,总是能正常投递。 — 失败场景:用户用纯文本回复一条引用的文件/音频/视频消息,而临时文件写入失败 —— 最大 50 MB 写入遇到磁盘配额/ENOSPC、被引用文件名超过 255 字节(basename() 不截断 → ENAMETOOLONG)、或 fileName 为真值非字符串(basename TypeError)→ 用户的文本永远到不了 Agent,只收到 "Sorry, something went wrong processing your message."。

见证(通过真实适配器的探针,下载 mock 为成功):

fileName='a'.repeat(300) -> stderr "ENAMETOOLONG: name too long, open '/tmp/channel-files/...'"
                            handleInbound 调用次数:0,发送了通用错误
fileName=12345            -> stderr "TypeError [ERR_INVALID_ARG_TYPE]",handleInbound 调用次数:0
对照(正常 fileName)      -> handleInbound 调用次数:1
修复翻转(fs 块外加 try/catch)-> 两类失败均变为:handleInbound 调用次数:1,无错误回复

下方一键 suggestion 在新调用点捕获抛错,保证文本始终投递(磁盘写入仍会失败 —— 只是跳过附件,与下载失败的降级行为一致)。另一种做法是在 attachMedia 内部保护 mkdirSync/writeFileSync/basename 各处,这样同时保护了既有的直发媒体路径。

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

Comment on lines +1395 to +1396
let mediaType: 'image' | 'file' | 'audio' | 'video' | undefined;
switch (replied.msgType) {

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] R1-3: This switch re-implements the DingTalk msgType→mediaType mapping (pictureimage; file/audio/video passthrough) that already exists in extractContent (~1516-1551) of the same file; summarizeRepliedContent carries a third enumeration of the same four types. — Concrete cost: any future change to DingTalk's downloadable types (a new type, a rename, an altered content shape) must be applied in lockstep in both places; missing one silently disables download for that type on only one of the two paths (direct media vs quoted media) — a drift that is hard to notice because both paths behave identically today. Note: reusing extractContent wholesale is a near-miss — it also collects image download codes from richText parts, which this switch deliberately excludes for quotes; the shareable unit is the type mapping itself.

Suggested fix:

private mediaTypeFromMsgType(
  msgType: string | undefined,
): 'image' | 'file' | 'audio' | 'video' | undefined {
  if (msgType === 'picture') return 'image';
  if (msgType === 'file' || msgType === 'audio' || msgType === 'video')
    return msgType;
  return undefined;
}

called from both extractContent (replacing the per-branch mediaType: literals) and this switch.

中文说明

[Suggestion] R1-3:这个 switch 重新实现了钉钉 msgType→mediaType 的映射(pictureimagefile/audio/video 直通),而同一文件的 extractContent(约 1516-1551 行)中已存在同样的映射;summarizeRepliedContent 还有第三处对相同四种类型的枚举。 — 具体代价:未来钉钉可下载类型的任何变化(新增类型、改名、content 结构调整)都必须在两处同步修改;漏改其一会让该类型在其中一条路径(直发媒体 vs 引用媒体)上静默停止下载 —— 由于两条路径目前行为完全一致,这种漂移很难被发现。注意:整体复用 extractContent 并不可行 —— 它还会收集 richText 各 part 中的图片下载码,而新 switch 对引用消息刻意排除了这种情况;可共享的部分是类型映射本身。

建议修复:

private mediaTypeFromMsgType(
  msgType: string | undefined,
): 'image' | 'file' | 'audio' | 'video' | undefined {
  if (msgType === 'picture') return 'image';
  if (msgType === 'file' || msgType === 'audio' || msgType === 'video')
    return msgType;
  return undefined;
}

extractContent(替换各分支的 mediaType: 字面量)和这个 switch 共同调用。

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

return {
referencedText: text || undefined,
isReplyToBot,
...(downloadCode && mediaType

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] R1-4: The mediaType half of this downloadCode && mediaType guard has no test — the mutant dropping it (...(downloadCode && mediaType...(downloadCode) survives the PR's entire quoted-media suite. — Failure scenario: under that mutant, a quoted { msgType: 'richText', content: { downloadCode: 'x' } } payload (a real shape in this file's domain — summarizeRepliedContent handles richText) calls attachMedia with mediaType === undefined, taking the file branch and emitting a dingtalk_undefined_<ts> temp file plus an attachment with type: undefined into the prompt.

Witness (mutation executed):

mutant: all 8 original quoted-media tests pass (only the added probe fails, 11/12)
probe under mutant: attachments=[{type: undefined, fileName: "dingtalk_undefined_1787023451837", ...}] + download fetches
correct code:       fetchCalls=0, attachments=undefined

(The mutant is also caught by tscmedia.mediaType is a required member of the return type — since vitest transpiles without type-checking; the proposed test is still the runtime-layer guard.)

Suggested fix — add one case (e.g. beside the missing-code it.each):

it('does not download a quoted message with an unmapped msgType even when it carries a downloadCode', async () => {
  const fetchSpy = vi
    .spyOn(globalThis, 'fetch')
    .mockRejectedValue(new Error('unexpected download'));
  const channel = createChannel();

  replyToMedia(channel, 'richText', { downloadCode: 'quoted-rt-code' });

  await vi.waitFor(() => {
    expect(channel.handleInbound).toHaveBeenCalledOnce();
  });
  expect(fetchSpy).not.toHaveBeenCalled();
  expect(
    vi.mocked(channel.handleInbound).mock.calls[0]![0],
  ).not.toHaveProperty('attachments');
});
中文说明

[Suggestion] R1-4:downloadCode && mediaType 这个守卫中 mediaType 的一半没有测试覆盖 —— 去掉它的变异体(...(downloadCode && mediaType...(downloadCode)能在 PR 的全部引用媒体测试中存活。 — 失败场景:在该变异体下,引用消息为 { msgType: 'richText', content: { downloadCode: 'x' } } 的载荷(这是本文件领域内的真实形态 —— summarizeRepliedContent 就处理 richText)会以 mediaType === undefined 调用 attachMedia,走入 file 分支,生成 dingtalk_undefined_<ts> 临时文件,并把 type: undefined 的附件塞进 prompt。

见证(实际执行的变异):

变异体:原有 8 个引用媒体测试全部通过(仅新增探针失败,11/12)
变异体下探针:attachments=[{type: undefined, fileName: "dingtalk_undefined_1787023451837", ...}] + 下载请求
正确代码:    fetchCalls=0, attachments=undefined

(该变异体也会被 tsc 捕获 —— media.mediaType 是返回类型的必需成员 —— 因为 vitest 转译时不做类型检查;但建议的测试仍是运行时层面的守卫。)

建议修复 —— 增加一个用例(例如放在缺码 it.each 旁):见上方英文侧代码块。

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

Comment on lines +1781 to +1783
if (quoted.media) {
await this.attachMedia(
envelope,

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] R1-5: No test exercises a message that carries its OWN media (content.downloadCodes) AND quotes media (quoted.media) — the only combination where both attachMedia calls run in sequence on one envelope. — Failure scenario: a user sends a picture while replying to a file message; expected effect is handleInbound receiving both attachments (direct image base64 plus quoted file filePath). This works today only because attachMedia appends via [...(envelope.attachments || []), …]. If a future edit gates one block on the other (else if) or replaces the append with an assignment, one media item is silently dropped — and every current test stays green, since each half is only ever tested alone.

Suggested fix — add one test: downstream message with msgtype: 'picture' + own content.downloadCode, whose repliedMsg is a 'file' with a downloadCode; assert handleInbound receives attachments of length 2 in order (image entry first, then the quoted file entry). Note: the same compound shape is also the entry point for the image-drop behavior flagged separately in the terminal report.

中文说明

[Suggestion] R1-5:没有测试覆盖消息同时携带自身媒体(content.downloadCodes)与引用媒体(quoted.media)的场景 —— 这是唯一会让两次 attachMedia 调用先后作用于同一个 envelope 的组合。 — 失败场景:用户发送一张图片的同时回复一条文件消息;预期 handleInbound 收到两个附件(直发图片的 base64 加引用文件的 filePath)。目前之所以正常,仅因为 attachMedia 通过 [...(envelope.attachments || []), …] 追加。若未来某次修改把一个块变成以另一个为条件(else if),或把追加换成赋值,就会静默丢弃其中一项媒体 —— 而所有现有测试仍为绿色,因为两半从未被一起测试过。

建议修复 —— 增加一个测试:下游消息 msgtype: 'picture' 且自带 content.downloadCode,其 repliedMsg 为带 downloadCode'file';断言 handleInbound 收到长度为 2 的 attachments 且顺序正确(先是图片项,后是引用文件项)。注意:该组合同样是终端报告中单独指出的图片丢弃行为的入口。

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

@qqqys

qqqys commented Aug 18, 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 Aug 18, 2026
@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. This is a fork PR, so the first round comes from the next scheduled scan (usually within minutes). Remove the autofix/takeover label (or comment @qwen-code /takeover stop) to release.

中文说明

🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。本 PR 来自 fork,首轮处理将由下一次定时扫描执行(通常几分钟内)。移除 autofix/takeover 标签(或评论 @qwen-code /takeover stop)即可释放。

@qwen-code-dev-bot

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

Copy link
Copy Markdown
Collaborator

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

中文说明

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

@qqqys

qqqys commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /takeover stop

@qwen-code-dev-bot qwen-code-dev-bot removed the autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) label Aug 18, 2026
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

👋 Takeover released: the autofix loop will no longer engage this PR (an in-flight round, if any, completes its bounded work). Re-apply autofix/takeover (or comment @qwen-code /takeover) to re-engage.

中文说明

👋 已释放:autofix 循环不再介入此 PR(在飞的一轮如有,将完成其有界工作)。重新打上 autofix/takeover 标签(或评论 @qwen-code /takeover)即可再次接管。

R1-1: `attachMedia`'s placeholder cleanup was written for the direct-media
path, where `extractContent` generates `(audio)` / `(video)` /
`(file: name)` itself. This PR made the quoted-media path reach it, and there
`envelope.text` is the user's own reply — so a reply reading exactly like one
of those placeholders was blanked and the agent got an attachment with no
prompt. A group `@Bot (audio)` arrives here as exactly `(audio)`, the mention
having been stripped upstream. `attachMedia` now takes the placeholder to
erase as a parameter; only the direct-media call site passes one.

R1-2: the same path newly routes text-only replies through the unguarded
`mkdirSync`/`writeFileSync`/`basename` block. Those are synchronous throw
sites — ENOSPC on a write of up to 50 MB, ENAMETOOLONG from a quoted fileName
over 255 bytes (`basename` does not truncate), a TypeError from a truthy
non-string fileName. An escape rejects `processMessage`, whose catch sends
the generic error reply and never calls `handleInbound`; the msgId is already
in `seenMessages`, so DingTalk's retry is deduped and the prompt is lost for
good. The block now degrades the way a failed download already does: log,
skip the attachment, deliver the text. This also covers the pre-existing
direct-media path.

Verified: dingtalk 310/310. Both mutation-checked — restoring the caller-blind
cleanup fails 3 tests, letting the fs block throw fails 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@qqqys

qqqys commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /review

@github-actions

Copy link
Copy Markdown
Contributor

Qwen Code review request accepted. Review is queued in workflow run.

@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.8-max via Qwen Code /review (v0.21.13)

Comment on lines +1396 to +1399
switch (replied.msgType) {
case 'picture':
mediaType = 'image';
break;

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] R1-3 (round 1, still stands): This switch still re-implements the DingTalk msgType→mediaType mapping (pictureimage; file/audio/video passthrough) that already exists in extractContent (~1486-1557) of the same file; summarizeRepliedContent carries a third enumeration of the same four types. The round-1 suggested fix (a shared helper) was not applied in the follow-up round. — Failure scenario: any future change to DingTalk's downloadable types (a new type, a rename, an altered content shape) must be applied in lockstep in both places; missing one silently disables download for that type on only one of the two paths (direct vs quoted media) — a drift that is hard to notice because both paths behave identically today. Note: reusing extractContent wholesale is a near-miss — it also collects richText part codes, which this switch deliberately excludes for quotes; the shareable unit is the type mapping itself.

Suggested fix:

private mediaTypeFromMsgType(
  msgType: string | undefined,
): 'image' | 'file' | 'audio' | 'video' | undefined {
  if (msgType === 'picture') return 'image';
  if (msgType === 'file' || msgType === 'audio' || msgType === 'video')
    return msgType;
  return undefined;
}

called from both extractContent (replacing the per-branch mediaType: literals) and this switch.

中文说明

[Suggestion] R1-3(第 1 轮发现,仍然存在):这个 switch 仍然重复实现了钉钉 msgType→mediaType 的映射(pictureimagefile/audio/video 直通),同一文件的 extractContent(约 1486-1557 行)中已有同样的映射;summarizeRepliedContent 还有第三处对相同四种类型的枚举。第 1 轮建议的修复(共享辅助函数)未在后续轮次中落实。 — 失败场景:未来钉钉可下载类型的任何变化(新增类型、改名、content 结构调整)都必须在两处同步修改;漏改其一会让该类型在其中一条路径(直发 vs 引用媒体)上静默停止下载 —— 由于两条路径目前行为完全一致,这种漂移很难被发现。注意:整体复用 extractContent 并不可行 —— 它还会收集 richText 各 part 中的下载码,而这个 switch 对引用消息刻意排除了这种情况;可共享的部分是类型映射本身。

建议修复:提取 mediaTypeFromMsgType(msgType) 私有辅助函数(见上方英文侧代码),由 extractContent(替换各分支的 mediaType: 字面量)和这个 switch 共同调用。

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

return {
referencedText: text || undefined,
isReplyToBot,
...(downloadCode && mediaType

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] R1-4 (round 1, still stands): The mediaType half of this downloadCode && mediaType guard still has no test — the mutant dropping it (...(downloadCode && mediaType...(downloadCode) survives the PR's entire quoted-media suite, and no test added since exercises an unmapped msgType carrying a downloadCode. — Failure scenario: under that mutant, a quoted { msgType: 'richText', content: { downloadCode: 'x' } } payload (a real shape in this file's domain — summarizeRepliedContent handles richText) calls attachMedia with mediaType === undefined, taking the file branch and emitting a dingtalk_undefined_<ts> temp file plus an attachment with type: undefined into the prompt.

Witness (mutation executed in round 1):

mutant: all quoted-media tests pass (only the added probe fails, 11/12)
probe under mutant: attachments=[{type: undefined, fileName: "dingtalk_undefined_…"}] + download fetches
correct code:       fetchCalls=0, attachments=undefined

(The mutant is also caught by tscmedia.mediaType is a required member of the return type — but vitest transpiles without type-checking; the proposed test is the runtime-layer guard.)

Suggested fix — add one case beside the missing-code tests: reply to an unmapped msgType (e.g. richText) that carries a downloadCode, assert no fetch happens and handleInbound receives no attachments.

中文说明

[Suggestion] R1-4(第 1 轮发现,仍然存在):downloadCode && mediaType 这个守卫中 mediaType 的一半仍然没有测试覆盖 —— 去掉它的变异体(...(downloadCode && mediaType...(downloadCode)能在 PR 的全部引用媒体测试中存活,且此后新增的测试也没有覆盖携带 downloadCode 的未映射 msgType。 — 失败场景:在该变异体下,引用消息为 { msgType: 'richText', content: { downloadCode: 'x' } } 的载荷(这是本文件领域内的真实形态 —— summarizeRepliedContent 就处理 richText)会以 mediaType === undefined 调用 attachMedia,走入 file 分支,生成 dingtalk_undefined_<ts> 临时文件,并把 type: undefined 的附件塞进 prompt。

见证(第 1 轮实际执行的变异):

变异体:原有引用媒体测试全部通过(仅新增探针失败,11/12)
变异体下探针:attachments=[{type: undefined, fileName: "dingtalk_undefined_…"}] + 下载请求
正确代码:    fetchCalls=0, attachments=undefined

(该变异体也会被 tsc 捕获 —— media.mediaType 是返回类型的必需成员 —— 但 vitest 转译时不做类型检查;建议的测试是运行时层面的守卫。)

建议修复 —— 在缺码测试旁增加一个用例:引用携带 downloadCode 的未映射 msgType(如 richText),断言不发生 fetch 且 handleInbound 不收到 attachments

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

Comment on lines +1818 to +1820
if (quoted.media) {
await this.attachMedia(
envelope,

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] R1-5 (round 1, still stands — re-confirmed by executed mutation this round): No test exercises a message that carries its OWN media (content.downloadCodes) AND quotes media (quoted.media) — the only combination where both attachMedia calls run in sequence on one envelope. — Failure scenario (verified this round): Mutant A — image-branch append changed to plain assignment envelope.attachments = [item] — the full adapter suite stays green (135/135) while a pic+pic message loses the user's own image (the quoted attachment overwrites it). Mutant B — file-branch append changed to assignment — the entire dingtalk package suite stays green (310/310) while a pic+file message drops the direct image when the quoted file attaches second.

Witness (probe executed this round):

unmodified: PROBE pic+pic attachments: [{image own},{image quoted}] / pic+file: [{image},{file filePath}]
mutant A:   expected [...] to have a length of 2 but got 1   (suite 135/135 green)
mutant B:   expected [...] to have a length of 2 but got 1   (suite 310/310 green)

Suggested fix — add one test: downstream message with msgtype: 'picture' + own content.downloadCode, whose repliedMsg is a 'file' with a downloadCode; assert handleInbound receives attachments of length 2 in order (image entry first, then the quoted file entry).

中文说明

[Suggestion] R1-5(第 1 轮发现,仍然存在 —— 本轮通过实际变异重新确认):没有测试覆盖消息同时携带自身媒体(content.downloadCodes)与引用媒体(quoted.media)的场景 —— 这是唯一会让两次 attachMedia 调用先后作用于同一个 envelope 的组合。 — 失败场景(本轮已验证):变异体 A —— 把 image 分支的追加改为直接赋值 envelope.attachments = [item] —— 整个适配器测试套件仍全绿(135/135),但 图+图 消息会丢失用户自己的图片(被引用附件覆盖)。变异体 B —— 把 file 分支的追加改为赋值 —— 整个 dingtalk 包测试套件仍全绿(310/310),但 图+文件 消息会在引用文件附加时丢失直发图片。

见证(本轮实际执行的探针):

未修改:PROBE pic+pic attachments: [{自身图},{引用图}] / pic+file: [{图},{文件 filePath}]
变异体 A:expected [...] to have a length of 2 but got 1(套件 135/135 绿)
变异体 B:expected [...] to have a length of 2 but got 1(套件 310/310 绿)

建议修复 —— 增加一个测试:下游消息 msgtype: 'picture' 且自带 content.downloadCode,其 repliedMsg 为带 downloadCode'file';断言 handleInbound 收到长度为 2 的 attachments 且顺序正确(先是图片项,后是引用文件项)。

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

Comment on lines +1813 to +1815
content.mediaType === 'file'
? `(file: ${content.fileName || 'file'})`
: `(${content.mediaType})`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The placeholder format now has two sources of truth: extractContent (~1486-1557) generates it into envelope.text, and this call site independently re-derives the same format string to feed the new cleanPlaceholderText gate — coupled only by the prose JSDoc on attachMedia. — Failure scenario: a future change to extractContent's placeholder output (a reworded (file: …) format, or a new media type whose placeholder doesn't match (${mediaType})) makes the comparison silently stop matching: nothing is logged, and direct file/audio/video messages deliver the literal placeholder text to the agent next to the attachment. No test pins the direct-path erasure (see the companion comment), so the drift ships silently.

Suggested fix: have extractContent return the placeholder it generated (e.g. a placeholder?: string field set for pure-media messages — for audio only when recognition is absent) and pass content.placeholder here instead of re-deriving the literals.

中文说明

[Suggestion] 占位符格式现在有两处定义:extractContent(约 1486-1557 行)把它生成到 envelope.text,而这个调用点又独立地重新推导出相同的格式字符串,作为新的 cleanPlaceholderText 判定依据 —— 两者仅靠 attachMedia 上的文字注释维系。 — 失败场景:未来 extractContent 的占位符输出一旦变化(改写 (file: …) 格式,或新增占位符不符合 (${mediaType}) 的媒体类型),这里的比较会静默失配:没有任何日志,直发的文件/音频/视频消息会把字面占位符文本连同附件一起交给 Agent。由于直发路径的清除行为没有测试固定(见相邻评论),这种漂移会静默上线。

建议修复:让 extractContent 返回它生成的占位符(例如增加 placeholder?: string 字段,仅对纯媒体消息设置 —— 音频仅在无 recognition 时设置),这里改为传入 content.placeholder,不再重新推导字面量。

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

Comment on lines +1813 to +1815
content.mediaType === 'file'
? `(file: ${content.fileName || 'file'})`
: `(${content.mediaType})`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The positive path of the new cleanPlaceholderText gate has zero test coverage: the new tests pin only the negative side (quoted path must not clean), and nothing anywhere sends a direct (non-reply) audio/video/file message through onMessage — the pre-existing inbound-media tests call attachMedia only with 'image', which returns before the cleaning branch. — Failure scenario: the one-line mutant deleting this ternary argument (passing undefined) leaves every test green while every direct voice-note/video/file message delivers (audio) / (video) / (file: name) as literal prompt text to the agent — the exact pollution the cleaning branch exists to prevent.

Witness (probe executed this round):

SHIPPED: probe envelope.text: ""   (placeholder cleaned)
MUTANT (ternary argument removed): suite 135/135 green; probe: AssertionError: expected '(audio)' to be ''

Suggested fix — add one direct-media test mirroring the new quoted-media helpers: send a non-reply message whose own content carries downloadCode + msgType: 'audio' (or 'file'), and assert handleInbound receives text: '' plus the attachment.

中文说明

[Suggestion] 新的 cleanPlaceholderText 判定的正向路径完全没有测试覆盖:新增测试只固定了负向(引用路径不得清除),而整个测试文件中没有任何用例通过 onMessage 发送直发(非回复)的音频/视频/文件消息 —— 既有的 inbound-media 测试只以 'image' 调用 attachMedia,在到达清除分支前就返回了。 — 失败场景:删掉这个三元参数(传 undefined)的单行变异体能让全部测试保持绿色,同时每条直发语音/视频/文件消息都会把 (audio) / (video) / (file: name) 作为字面 prompt 文本交给 Agent —— 正是清除分支要防止的污染。

见证(本轮实际执行的探针):

现有代码:探针 envelope.text: ""(占位符被清除)
变异体(删除三元参数):套件 135/135 绿;探针:AssertionError: expected '(audio)' to be ''

建议修复 —— 仿照新增的引用媒体辅助函数增加一个直发媒体测试:发送自带 downloadCode + msgType: 'audio'(或 'file')的非回复消息,断言 handleInbound 收到 text: '' 和附件。

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

Comment on lines +2638 to +2639
['an over-long file name', 'a'.repeat(300)],
['a non-string file name', 12345 as unknown as string],

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] Both R1-2 regression cases assert only that handleInbound still receives the user text; neither pins the attachment outcome that distinguishes the implementations — in particular the new typeof fileName === 'string' guard inside attachMedia is not pinned by any assertion. — Failure scenario: revert the guard to basename(fileName || '') while keeping the try/catch: for fileName: 12345, basename(12345) throws, the catch runs, handleInbound is still called with the text, and every test passes (135/135) — but shipped code delivers the attachment under a generated dingtalk_file_<ts> name while the mutant silently drops it. Likewise the over-long case never asserts attachments is absent, so a future truncate-instead-of-fail change would also pass.

Witness (probe executed this round):

SHIPPED: attachments=[{fileName: "dingtalk_file_…"}]
MUTANT (basename(fileName || ''), catch kept): attachments: undefined — suite 135/135 green

Suggested fix:

// non-string case: attachment delivered under generated name
expect(envelope.attachments?.[0]?.fileName).toMatch(/^dingtalk_file_/);
// over-long case: no attachment delivered
expect(envelope).not.toHaveProperty('attachments');
中文说明

[Suggestion] R1-2 的两个回归用例都只断言 handleInbound 仍收到用户文本;都没有固定能区分两种实现的附件结果 —— 尤其 attachMedia 内新的 typeof fileName === 'string' 守卫没有任何断言覆盖。 — 失败场景:在保留 try/catch 的同时把守卫还原为 basename(fileName || ''):对 fileName: 12345basename(12345) 抛错,catch 执行,handleInbound 仍带着文本被调用,全部测试通过(135/135)—— 但现有代码会以生成的 dingtalk_file_<ts> 名称投递附件,而变异体静默丢弃了它。同理,超长文件名用例也没有断言 attachments 不存在,未来改成截断而非失败同样能通过。

见证(本轮实际执行的探针):

现有代码:attachments=[{fileName: "dingtalk_file_…"}]
变异体(basename(fileName || ''),保留 catch):attachments: undefined —— 套件 135/135 绿

建议修复:见上方英文侧代码 —— 非字符串用例断言附件以生成名投递;超长用例断言无附件。

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

Comment on lines +1563 to +1564
/**
* `cleanPlaceholderText` is the placeholder `extractContent` generated for

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The new JSDoc block is stacked after attachMedia's existing JSDoc instead of merged into it — the only doubled /** */ block in the file. Editor/TypeScript tooling attaches only the LAST doc comment before a declaration to the symbol, so the original summary ("Download a media file and attach it to the envelope. Images → base64 in envelope; files → saved to temp dir with path in text.") is detached from the function. — Failure scenario: hovering attachMedia (or reading generated API docs) now shows only the R1-1 history note; the next reader loses the image-vs-file contract while the orphaned block still looks like live documentation. Verified via the TypeScript compiler API: symbol.getDocumentationComment() returns only the last block.

Suggested fix: merge both into a single JSDoc block — keep the original summary lines first, then the cleanPlaceholderText/R1-1 explanation.

中文说明

[Suggestion] 新增的 JSDoc 块直接堆叠在 attachMedia 已有的 JSDoc 之后,而不是合并进去 —— 这是全文件唯一的双 /** */ 块。编辑器/TypeScript 工具只会把声明前的最后一个文档注释挂到符号上,因此原始摘要("Download a media file and attach it to the envelope. Images → base64 in envelope; files → saved to temp dir with path in text.")脱离了该函数。 — 失败场景:悬停查看 attachMedia(或阅读生成的 API 文档)现在只会显示 R1-1 的历史说明;下一个读者会丢失 image/file 的行为契约,而被孤立的那个块看起来仍像有效文档。已通过 TypeScript 编译器 API 验证:symbol.getDocumentationComment() 只返回最后一个块。

建议修复:把两者合并为一个 JSDoc 块 —— 先保留原始摘要,再放 cleanPlaceholderText/R1-1 的说明。

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

Comment on lines +1822 to 1824
quoted.media.mediaType,
quoted.media.fileName,
);

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] Quoted messages now trigger media downloads (two-step server download, up to 50 MB, local temp storage, attachment to the prompt), but the user-facing docs are not updated — in docs/users/features/channels/dingtalk.md the "Referenced messages" bullet (~line 188) still only says quoting "includes the quoted text as context", and the "Images and Files" section (~167-173) describes only directly-sent media. — Failure scenario: a user who quotes an image/file/audio/video message now triggers a download and a temp-file write of up to 50 MB that the page documenting DingTalk media behaviour never mentions; users troubleshooting unexpected downloads or checking whether quoting media is supported have no documented behaviour to consult, while the sibling precedent (direct-media download) is documented on the same page.

Suggested fix: extend the "Referenced messages" bullet (or the "Images and Files" section) to state that quoting a media message downloads and attaches it the same way as sending it directly.

中文说明

[Suggestion] 引用消息现在会触发媒体下载(两步服务端下载、最大 50 MB、本地临时存储、附加到 prompt),但面向用户的文档没有更新 —— docs/users/features/channels/dingtalk.md 的 "Referenced messages" 条目(约 188 行)仍只说引用会"includes the quoted text as context",而 "Images and Files" 小节(约 167-173 行)只描述直接发送的媒体。 — 失败场景:用户引用图片/文件/音频/视频消息时,现在会触发一次下载和最大 50 MB 的临时文件写入,而记录钉钉媒体行为的页面对此只字未提;排查意外下载或想确认引用媒体是否受支持的用户没有文档可查 —— 同页已记录了同类行为(直发媒体下载)作为先例。

建议修复:在 "Referenced messages" 条目(或 "Images and Files" 小节)中补充说明:引用媒体消息会像直接发送一样下载并附加该媒体。

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

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

7 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R1-3 msgType→mediaType mapping duplication (still stands) — already re-reported by round-2 review 4960003798 (comment 3803224925 at DingtalkAdapter.ts:1399)
  • R1-4 mediaType guard half untested (still stands) — already re-reported by round-2 review 4960003798 (comment 3803224933 at DingtalkAdapter.ts:1411)
  • R1-5 own-media + quoted-media combination untested (still stands) — already re-reported by round-2 review 4960003798 (comment 3803224938 at DingtalkAdapter.ts:1820)
  • R2-1 placeholder format re-derived at the direct call site — already reported by round-2 review 4960003798 (comment 3803224943 at DingtalkAdapter.ts:1815)
  • R2-2 direct-media placeholder blanking has no test coverage — already reported by round-2 review 4960003798 (comment 3803224946 at DingtalkAdapter.ts:1815)
  • R2-3 orphaned JSDoc block before attachMedia — already reported by round-2 review 4960003798 (comment 3803224957 at DingtalkAdapter.ts:1564)
  • R2-5 R1-2 tests do not pin the degrade shape — already reported by round-2 review 4960003798 (comment 3803224950 at DingtalkAdapter.test.ts:2639)

Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:

  • packages/channels/dingtalk/src/DingtalkAdapter.ts:1818 — [review] channel-files accumulation is never cleaned, and quoted media removes the re-upload cost asymmetry (code-age rule: anchored on the unchanged quoted-call block round 1 already…
  • packages/channels/dingtalk/src/DingtalkAdapter.ts:1818 — [probe] quoted media is downloaded and persisted before any inbound gating runs (code-age rule: anchored on the unchanged quoted-call block round 1 already read)
中文说明

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

本轮确认的 7 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 2 条(原文未翻译,列表见上方英文部分)。

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

Comment on lines +1628 to +1629
const dir = join(tmpdir(), 'channel-files', randomUUID());
mkdirSync(dir, { recursive: true });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R2-4: The new R1-2 catch path returns after writeFileSync throws but never removes the directory mkdirSync already created — an empty directory for the ENAMETOOLONG case this catch was written for, a partial file of up to 50 MB for a mid-write ENOSPC. — Failure scenario: a quoted fileName over 255 bytes (exactly the test's 'a'.repeat(300) case): mkdirSync succeeds, writeFileSync throws, the catch logs and returns, leaving one stray tmpdir()/channel-files/<uuid> directory per occurrence until OS tmp cleanup; repeated quoting of long-named files accumulates them indefinitely. Side effect: the attachment never gets a filePath in that case, so the new tests cannot register the leaked directory in tempDirs either, and every test run leaves a stray directory in the host tmpdir.

Witness (measured):

running just the two R1-2 throw tests: BEFORE=50 AFTER=52 LEAKED=2
(one empty dir from the ENAMETOOLONG case, one dir holding a 3-byte dingtalk_file_<ts> fallback file)

Suggested fix (hoist dir out of the try, clean it up in the catch; import rmSync alongside mkdirSync):

let dir: string;
try {
  dir = join(tmpdir(), 'channel-files', randomUUID());
  mkdirSync(dir, { recursive: true });
  // ...basename/writeFileSync as today...
} catch (error) {
  try {
    rmSync(dir, { recursive: true, force: true });
  } catch {}
  // ...existing stderr log...
  return;
}
中文说明

[Suggestion] R2-4:新的 R1-2 catch 分支在 writeFileSync 抛错后直接 return,但从未删除 mkdirSync 已经创建的目录 —— 对这个 catch 专门为之地编写的 ENAMETOOLONG 场景留下一个空目录,对写入中途 ENOSPC 则可能留下最大 50 MB 的部分文件。 — 失败场景:被引用的 fileName 超过 255 字节(正是测试里 'a'.repeat(300) 的场景):mkdirSync 成功,writeFileSync 抛错,catch 记录日志后返回,每次发生都会遗留一个 tmpdir()/channel-files/<uuid> 孤儿目录,只能等操作系统清理临时目录;反复引用长文件名会无限累积。副作用:此时附件拿不到 filePath,新测试也无法把泄漏的目录登记进 tempDirs,因此每次跑测试都会在宿主机临时目录留下孤儿目录。

见证(实测):只运行两个 R1-2 抛错测试:BEFORE=50 AFTER=52 LEAKED=2(一个空目录来自 ENAMETOOLONG 场景,一个目录内含 3 字节的 dingtalk_file_ 兜底文件)。

建议修复:把 dir 提升到 try 外,在 catch 中先 try { rmSync(dir, { recursive: true, force: true }); } catch {} 再返回(rmSyncmkdirSync 一并导入)。见英文侧代码块。

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

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

本轮审查结果

审查范围

packages/channels/dingtalk/src/DingtalkAdapter.ts(+89/-13 行)、DingtalkAdapter.test.ts(+363/-2 行)

回合 1-2 Critical 修复验证

R1-1(Critical)— 占位符清理误清用户文本 — ✅ 已修复
attachMedia 新增 cleanPlaceholderText?: string 参数,仅由直发媒体调用点传入,引用媒体调用点不传。清理逻辑 if (cleanPlaceholderText !== undefined && envelope.text === cleanPlaceholderText) 正确隔离了引用路径。测试验证了 (audio)(video)(file: report.pdf) 三种占位符文本在引用路径上均不被清空。

R1-2(Critical)— 同步抛错点导致 prompt 永久丢失 — ✅ 已修复
mkdirSync/writeFileSync/basename 外围已包裹 try/catch,basename 前增加了 typeof fileName === 'string' 类型守卫。catch 路径写入 stderr 后 return,不中断 handleInbound 调用。测试覆盖了超长文件名(300 字符)和非字符串文件名两类异常。

行内评论

本轮无新增行内评论。此前回合已报告的 7 条 Suggestion 级发现仍有效,不在本轮重复提出。

总体评价

COMMENT — 两个 Critical 阻断项已修复并通过测试验证。改进方向的 Suggestion 级发现已在之前回合中记录,建议作者在后续迭代中择机处理。

English version

Review Results

Scope

packages/channels/dingtalk/src/DingtalkAdapter.ts (+89/-13 lines), DingtalkAdapter.test.ts (+363/-2 lines)

Round 1-2 Critical Fix Verification

R1-1 (Critical) — Placeholder cleanup blanking user text — ✅ Fixed
attachMedia now accepts cleanPlaceholderText?: string, passed only from the direct-media call site. The guard cleanPlaceholderText !== undefined && envelope.text === cleanPlaceholderText correctly isolates the quoted path. Tests verify that (audio), (video), and (file: report.pdf) survive intact on the quoted path.

R1-2 (Critical) — Sync throw sites losing prompts — ✅ Fixed
mkdirSync/writeFileSync/basename are wrapped in try/catch with a typeof fileName === 'string' guard. The catch path writes to stderr and returns, never blocking handleInbound. Tests cover both over-long (300-char) and non-string file names.

Inline Comments

No new inline comments this round. The 7 Suggestion-level findings from previous rounds still stand and are not re-raised per the convergence posture.

Overall

COMMENT — Both Critical blockers are fixed and verified. Suggestion-level improvement areas are documented in earlier rounds for the author to address in follow-up iterations.

@qqqys

qqqys commented Aug 18, 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 Aug 18, 2026
@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. This is a fork PR, so the first round comes from the next scheduled scan (usually within minutes). Remove the autofix/takeover label (or comment @qwen-code /takeover stop) to release.

中文说明

🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。本 PR 来自 fork,首轮处理将由下一次定时扫描执行(通常几分钟内)。移除 autofix/takeover 标签(或评论 @qwen-code /takeover stop)即可释放。

Unify the msgType->mediaType mapping in a shared helper, make extractContent
the single source of truth for the placeholder text cleaned on attach, and
remove the store directory when a media write fails so failed stores no longer
leak into tmpdir. Merge the stacked attachMedia JSDoc blocks, document quoted
media downloads, and pin the previously uncovered paths: unmapped quoted
msgTypes with a downloadCode, own-media + quoted-media combinations, direct
placeholder cleaning, and the degraded-store attachment shapes.
@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 response — PR #9347 (round 3)

Commit: 4336c798e1 on agent/dingtalk-quoted-media (3 files, +223/−46). No base conflict (--conflict false, nothing merged). All 9 actionable findings are addressed in one batch — each is small (test-only, docs, comments, or a few lines), so deferring any would only add a round of latency.

Feedback points and dispositions

Round 1–2 Criticals (already fixed; re-verified this round)

  • R1-1 [rc:3800932272] — placeholder cleanup blanking quoted-path user text: fixed in 2610ebfcf1 (cleanPlaceholderText is passed only by the direct-media call site; the quoted call passes nothing). Re-verified against current code — the gate is cleanPlaceholderText !== undefined && envelope.text === cleanPlaceholderText, and the three "keeps a quoted-%s reply whose text looks like a placeholder" tests pass. Resolved in code.
  • R1-2 [rc:3800932281] — sync throw sites losing the prompt: fixed in 2610ebfcf1 (try/catch around mkdirSync/writeFileSync/basename plus the typeof fileName === 'string' guard). Re-verified; the split regression tests below now also pin the attachment outcomes. Resolved in code.

Suggestions implemented this round

  • R1-3 [rc:3800932295, rc:3803224925] — duplicated msgType→mediaType mapping: extracted a shared private mediaTypeFromMsgType helper, called from extractQuotedContext (the switch is gone) and from the picture/file/audio/video branches of extractContent (replacing the per-branch literals). The richText branch keeps its 'image' literal: that is part-type mapping (part.type === 'picture'), a different vocabulary than message-level msgType.
  • R1-4 [rc:3800932298, rc:3803224933] — the mediaType half of the downloadCode && mediaType guard was untested: added "does not download a quoted message with an unmapped msgType even when it carries a downloadCode" (quoted richText with a downloadCode → no fetch, no attachments).
  • R1-5 [rc:3800932308, rc:3803224938] — own-media + quoted-media combination untested: added "attaches both the own media and the quoted media of one message" (a picture with its own downloadCode quoting a file) — asserts handleInbound receives 2 attachments in order (own image base64 first, quoted file second) and pins the download order. Kills mutants A/B (append→assignment) reported in round 2.
  • R2-1 [rc:3803224943] — placeholder format had two sources of truth: extractContent now returns the placeholder it generated ((file: name) / (audio) / (video); audio only when recognition is absent), and the direct-media call site passes content.placeholder instead of re-deriving the format string. Behavior is unchanged (image placeholders were never cleaned — the image branch returns first).
  • R2-2 [rc:3803224946] — the positive cleaning path had zero coverage: added "cleans the generated placeholder for a direct file message" and "...direct audio message" through onMessage, asserting text: '' plus the delivered attachment. Kills the ternary-argument-deletion mutant from round 2.
  • R2-3 [rc:3803224950] — the R1-2 regression cases asserted text only: split the it.each into two tests that pin the attachment outcome — over-long name: attachments is absent; non-string name: the attachment is delivered under a generated dingtalk_file_<ts> name (and its temp dir is registered for cleanup, which the old test could not do).
  • R2-4 [rc:3803224957] — stacked JSDoc before attachMedia: merged into a single block (original summary first, then the cleanPlaceholderText rationale; the round-history narrative was trimmed).
  • R2-4 [rc:3803408211] — catch path leaked the store directory: dir is hoisted out of the try, and the catch now removes it via best-effort rmSync(dir, { recursive: true, force: true }) (rmSync added to the node:fs import). Reproduced before fixing: with only the new leak assertion added, the pre-fix code failed the test with leaked = ["55ac93c5-…"] (a stray uuid dir in tmpdir()/channel-files); after the fix the test passes.
  • R2-5 [rc:3803224961] — user docs: the "Referenced messages" bullet in docs/users/features/channels/dingtalk.md now states that quoting a media message downloads and attaches it the same way as when sent directly.

No action

  • [ic:5313738366] — the author's E2E test report: informational, nothing to address.
  • The two items marked "Deferred under the convergence posture" in review rv:4960226634 (channel-files accumulation from successful stores; quoted media downloaded before inbound gating) were explicitly recorded but not requested in this round by that review — acknowledged, no change made.

Verification

  • npx vitest run src/DingtalkAdapter.test.ts -t "quoted media" (before the leak fix) — 16/17 passed, 1 FAILED: the over-long-store test leaked directory 55ac93c5-… → reproduces the R2-4 leak on pre-fix code
  • npx vitest run src/DingtalkAdapter.test.ts — passed, 139/139
  • npx vitest run (packages/channels/dingtalk) — passed, 10/10 files, 314/314 tests
  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • npx prettier --check (3 touched files) — passed
  • Integration tests after npm run bundle — not run: the touched behavior is fully exercised by the package unit tests, not only through the bundled CLI; no settings source changed, so no schema regeneration
中文说明

审查回应 — PR #9347(第 3 轮)

提交:agent/dingtalk-quoted-media 分支上的 4336c798e1(3 个文件,+223/−46)。无基线冲突(--conflict false,未做任何合并)。全部 9 条可操作发现一次性处理完毕 —— 每条都很小(纯测试、文档、注释或几行代码),延后任何一条都只会多浪费一轮。

反馈点及处置

第 1–2 轮 Critical(已修复;本轮重新验证)

  • R1-1 [rc:3800932272] — 占位符清理误清引用路径上的用户文本:已在 2610ebfcf1 修复(cleanPlaceholderText 仅由直发媒体调用点传入,引用调用不传)。已对照当前代码重新验证 —— 判定条件为 cleanPlaceholderText !== undefined && envelope.text === cleanPlaceholderText,三个 "keeps a quoted-%s reply whose text looks like a placeholder" 测试通过。已在代码中解决。
  • R1-2 [rc:3800932281] — 同步抛错点导致 prompt 丢失:已在 2610ebfcf1 修复(mkdirSync/writeFileSync/basename 外包 try/catch,并增加 typeof fileName === 'string' 守卫)。已重新验证;下方拆分后的回归测试现在同时固定了附件结果。已在代码中解决。

本轮落实的 Suggestion

  • R1-3 [rc:3800932295, rc:3803224925] — msgType→mediaType 映射重复:提取共享私有辅助函数 mediaTypeFromMsgType,由 extractQuotedContext(switch 已移除)和 extractContent 的 picture/file/audio/video 分支(替换各分支字面量)共同调用。richText 分支保留 'image' 字面量:那是 part 类型映射(part.type === 'picture'),与消息级 msgType 是不同的词汇层。
  • R1-4 [rc:3800932298, rc:3803224933]downloadCode && mediaType 守卫中 mediaType 一半无测试:新增 "does not download a quoted message with an unmapped msgType even when it carries a downloadCode"(引用携带 downloadCoderichText → 不 fetch、无附件)。
  • R1-5 [rc:3800932308, rc:3803224938] — 自身媒体 + 引用媒体组合无测试:新增 "attaches both the own media and the quoted media of one message"(自带 downloadCode 的图片消息引用一条文件消息)—— 断言 handleInbound 按序收到 2 个附件(先是自身图片 base64,后是引用文件),并固定下载顺序。可杀死第 2 轮报告中的变异体 A/B(追加改赋值)。
  • R2-1 [rc:3803224943] — 占位符格式有两处定义:extractContent 现在返回它生成的 placeholder(file: name) / (audio) / (video);音频仅在无 recognition 时设置),直发媒体调用点改为传入 content.placeholder,不再重新推导格式串。行为不变(图片占位符本来就不清理 —— image 分支提前返回)。
  • R2-2 [rc:3803224946] — 正向清理路径零覆盖:新增通过 onMessage 的 "cleans the generated placeholder for a direct file message" 与 "...direct audio message",断言 text: '' 且附件正常投递。可杀死第 2 轮中删除三元参数的变异体。
  • R2-3 [rc:3803224950] — R1-2 回归用例只断言文本:把 it.each 拆成两个测试并固定附件结果 —— 超长文件名:attachments 不存在;非字符串文件名:附件以生成的 dingtalk_file_<ts> 名称投递(其临时目录也登记进清理列表,旧测试做不到这一点)。
  • R2-4 [rc:3803224957]attachMedia 前堆叠的 JSDoc:合并为单个块(先保留原始摘要,再放 cleanPlaceholderText 的原理说明;轮次历史叙述被精简)。
  • R2-4 [rc:3803408211] — catch 分支泄漏存储目录:把 dir 提升到 try 之外,catch 中先尽力 rmSync(dir, { recursive: true, force: true }) 再返回(node:fs 导入中新增 rmSync)。修复前先复现:仅加入新的泄漏断言时,修复前代码使该测试失败,leaked = ["55ac93c5-…"]tmpdir()/channel-files 下的孤儿 uuid 目录);修复后测试通过。
  • R2-5 [rc:3803224961] — 用户文档:docs/users/features/channels/dingtalk.md 的 "Referenced messages" 条目现在说明:引用媒体消息会像直接发送一样下载并附加该媒体。

无需处理

  • [ic:5313738366] — 作者的 E2E 测试报告:信息性内容,无需处理。
  • 审查 rv:4960226634 中标注 "Deferred under the convergence posture"(收敛姿态下延后)的两条(成功存储的 channel-files 累积;引用媒体在入站门控前下载)被该审查明确记录但本轮不要求修改 —— 已知悉,未做改动。

验证

  • npx vitest run src/DingtalkAdapter.test.ts -t "quoted media"(泄漏修复前)— 16/17 通过,1 条失败:超长文件名存储测试泄漏了目录 55ac93c5-… → 在修复前代码上复现了 R2-4 泄漏
  • npx vitest run src/DingtalkAdapter.test.ts — 通过,139/139
  • npx vitest run(packages/channels/dingtalk)— 通过,10/10 文件,314/314 测试
  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • npx prettier --check(3 个改动文件)— 通过
  • npm run bundle 后的集成测试 — 未运行:改动的行为完全由包的单元测试覆盖,并非只能通过打包 CLI 验证;未改动任何 settings 源,无需重新生成 schema

🧭 Gate advisory — this round modified areas outside the PR footprint (machine-measured, not agent-authored):

  • docs
    Review the expansion deliberately; the footprint gate is in advisory mode. · 本轮改动了 PR 足迹之外的区域(门自动测量,非 agent 文本),当前足迹门为 advisory 模式,请有意识地审阅该扩张。

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

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

Deferred under the convergence posture (round 3, not a blocker) — recorded, not requested in this round:

  • packages/channels/dingtalk/src/DingtalkAdapter.ts:1829 — [probe] own image + quoted image: second image downloaded then silently discarded by ChannelBase
  • packages/channels/dingtalk/src/DingtalkAdapter.ts:1829 — [probe] quoted-media download runs before the preflightInbound sender-policy gate
中文说明

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

收敛姿态下延后(第 3 轮,非阻断)——已记录,本轮不要求修改:共 2 条(原文未翻译,列表见上方英文部分)。

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

- **Use DingTalk markdown-aware instructions** — DingTalk supports headings, bold text, links, code blocks, and tables. Keep tables compact because narrow screens may scroll horizontally.
- **Restrict access** — In an organization context, `senderPolicy: "open"` may be acceptable. For tighter control, use `"allowlist"` or `"pairing"`. See [DM Pairing](./overview#dm-pairing) for details.
- **Referenced messages** — Quoting (replying to) a user message includes the quoted text as context for the agent. Quoting bot responses is not yet supported.
- **Referenced messages** — Quoting (replying to) a user message includes the quoted text as context for the agent. If the quoted message carries an image, file, audio, or video, the bot downloads and attaches it the same way as when sent directly. Quoting bot responses is not yet supported.

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] R3-1: The doc sentence added by this diff is unconditional, but quoted media is only downloaded for picture/file/audio/video msgTypes — mediaTypeFromMsgType fails closed for richText (DingtalkAdapter.ts:1469-1481), so a quoted richText message produces no media field and nothing downloads, while summarizeRepliedContent still renders the [image] placeholder into the prompt. A DIRECT richText message, by contrast, downloads its first image via extractContent's richText branch — so the documented parity ("the same way as when sent directly") is false for quoted richText and legacy quoteMessage shapes. — Failure scenario: a user quotes a richText message containing an image → referencedText shows [image], nothing downloads, and the agent sees the placeholder with no image, contradicting the sentence this line adds. The PR description already declares richText quotes out of scope, so the fix is to qualify the sentence, not to implement the download.

Suggested change
- **Referenced messages** — Quoting (replying to) a user message includes the quoted text as context for the agent. If the quoted message carries an image, file, audio, or video, the bot downloads and attaches it the same way as when sent directly. Quoting bot responses is not yet supported.
- **Referenced messages** — Quoting (replying to) a user message includes the quoted text as context for the agent. If the quoted message is a picture, file, audio, or video message, the bot downloads and attaches it the same way as when sent directly. Quoting bot responses is not yet supported.
中文说明

[Suggestion] R3-1:本 diff 新增的这句文档表述是无条件的,但引用媒体只对 picture/file/audio/video 这些 msgType 下载 —— mediaTypeFromMsgTyperichText 失败关闭(DingtalkAdapter.ts:1469-1481),因此引用 richText 消息不会产生 media 字段、不会触发任何下载,而 summarizeRepliedContent 仍会把 [image] 占位符渲染进 prompt。相比之下,直发的 richText 消息会经 extractContent 的 richText 分支下载其第一张图片 —— 所以"与直接发送相同"的表述对引用 richText 及 legacy quoteMessage 形态不成立。 — 失败场景:用户引用一条包含图片的 richText 消息 → referencedText 显示 [image],但没有任何下载,Agent 看到占位符却收不到图片,与本行新增的表述矛盾。PR 描述已声明 richText 引用不在本次范围内,因此修复方式是限定该句的适用范围,而非实现 richText 下载。

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

Comment on lines +1558 to +1559
mediaType: this.mediaTypeFromMsgType(msgtype),
placeholder: '(video)',

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] R3-2: The direct-video placeholder cleanup has no test. Round 3 added direct-cleanup tests for file and audio only (partially addressing R2-2), leaving the video branch's new placeholder wiring unguarded: the mutant removing placeholder: '(video)' survives the entire suite (measured this round: 139/139 green), because both existing 'video' tests exercise the QUOTED path with a text own-message, which never produces a placeholder. — Failure scenario: a future refactor drops the video placeholder → every test stays green while direct video messages deliver (video) as literal prompt text next to the attachment — the exact pollution the cleaning branch exists to prevent. This is the bounded file/audio/video family's last open sibling (file ✓, audio ✓, video ✗). Suggested fix: add a direct-video sibling of the existing direct-audio test — send a non-reply message with downloadCode + msgType: 'video' (e.g. sendDirectMedia(channel, 'video', { downloadCode: 'direct-video-code' })) and assert handleInbound receives text: '' plus the video attachment.

Witness (measured this round):

mutant (placeholder line removed): Tests 139 passed (139) — survives
probe under mutant: AssertionError: expected '(video)' to be ''
probe after restore: PROBE envelope.text: "" — the added test flips the mutant
中文说明

[Suggestion] R3-2:直发视频的占位符清理没有测试。第 3 轮只为 file 和 audio 补充了直发清理测试(部分解决了 R2-2),video 分支新接入的 placeholder 仍无保护:删除 placeholder: '(video)' 的变异体能让整个测试套件保持全绿(本轮实测 139/139 通过),因为现有的两个 'video' 用例都走的是引用路径且自身消息为纯文本,永远不会产生占位符。 — 失败场景:未来某次重构删掉了 video 的 placeholder → 所有测试依旧绿色,而直发视频消息会把 (video) 作为字面 prompt 文本连同附件一起交给 Agent —— 正是清理分支要防止的污染。这是 file/audio/video 有界家族中最后一个未覆盖的成员(file ✓、audio ✓、video ✗)。建议修复:仿照现有直发 audio 测试增加一个直发 video 用例 —— 发送带 downloadCode + msgType: 'video' 的非回复消息(如 sendDirectMedia(channel, 'video', { downloadCode: 'direct-video-code' })),断言 handleInbound 收到 text: '' 和视频附件。

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

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🔀 Base updated: red check(s) [Test (ubuntu-latest, Node 22.x)] pass on current main — merged current main via update-branch; CI will re-run.

中文说明

🔀 已更新 base:红色检查 [Test (ubuntu-latest, Node 22.x)] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。

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

Deferred under the convergence posture (round 4, not a blocker) — recorded, not requested in this round:

  • packages/channels/dingtalk/src/DingtalkAdapter.ts:1830 — [review] own-media and quoted-media downloads are awaited sequentially — prompt-start latency is the sum, not the max, of the two flows (code-age rule: anchored on the unchanged quote…

[Critical] R4-1 — packages/channels/dingtalk/src/DingtalkAdapter.ts:1828-1835 — A quoted image attached alongside the message's own image is silently discarded by ChannelBase's single-image resolution. Both attachments arrive as {type: 'image', data, mimeType} (data only, no filePath); in ChannelBase.ts (~5253-5282) the first image fills the only imageBase64 slot, and the second fails that guard and also fails else if (att.filePath) — dropped with no log and no prompt line. Before this PR a DingTalk envelope held at most one attachment, so this diff makes the collision reachable. — Failure scenario: a user replies to an image message with their own image and asks about the quoted one; the quoted image is downloaded (download-code POST + up to 50 MB fetch), attached, then silently lost — the agent answers having seen only [Replying to: "[image]"] and the user's own image, and the doc sentence this same PR adds is silently broken. Witness (probe, unmodified PR): resolution of a two-image envelope yields promptText "[Replying to: " image "]\n\ninspect both" and options.imageBase64 = own image only — the quoted image is absent from prompt, options, and logs; file-backing the second image (fix-flip) surfaces it ("User sent an image. It has been saved to: /tmp/channel-files/…"). Suggested fix: file-back the quoted image (attach via filePath like non-image media), at least when the envelope already carries image data, or skip the quoted download with a stderr note when the conflict is detected. (This finding's inline comment was suppressed because its resolved line 1832 overlaps the fixed round-1 R1-1 thread at the same line — a different defect, so its only copy lives in this body.) 中文说明:当消息自带图片且同时附加引用图片时,引用图片被 ChannelBase 的单图片解析逻辑静默丢弃(两个附件均为仅含 data 的图片;第一个占满唯一的 imageBase64 槽位,第二个两个分支均不满足,无日志、无 prompt 行)。本 PR 之前 envelope 最多只有一个附件,是本 diff 使该冲突可达。失败场景:用户用自己的图片回复图片消息并询问引用图片 → 引用图片被下载、附加后静默丢失,Agent 只看到占位符与用户自己的图片。见证(探针):双图片 envelope 解析后引用图片在 prompt、options、日志中均无踪迹;修复翻转(第二张图片落盘)后可正常呈现。建议修复:将引用图片落盘(以 filePath 附加),或检测冲突并跳过下载并输出 stderr 说明。(此发现的行内评论因解析行 1832 与已修复的第 1 轮 R1-1 线程同行而被抑制 —— 属不同缺陷,故其唯一副本位于本正文。)

中文说明

收敛姿态下延后(第 4 轮,非阻断)——已记录,本轮不要求修改:共 1 条(原文未翻译,列表见上方英文部分)。

[Critical] R4-1 — packages/channels/dingtalk/src/DingtalkAdapter.ts:1828-1835 — A quoted image attached alongside the message's own image is silently discarded by ChannelBase's single-image resolution. Both attachments arrive as {type: 'image', data, mimeType} (data only, no filePath); in ChannelBase.ts (~5253-5282) the first image fills the only imageBase64 slot, and the second fails that guard and also fails else if (att.filePath) — dropped with no log and no prompt line. Before this PR a DingTalk envelope held at most one attachment, so this diff makes the collision reachable. — Failure scenario: a user replies to an image message with their own image and asks about the quoted one; the quoted image is downloaded (download-code POST + up to 50 MB fetch), attached, then silently lost — the agent answers having seen only [Replying to: "[image]"] and the user's own image, and the doc sentence this same PR adds is silently broken. Witness (probe, unmodified PR): resolution of a two-image envelope yields promptText "[Replying to: " image "]\n\ninspect both" and options.imageBase64 = own image only — the quoted image is absent from prompt, options, and logs; file-backing the second image (fix-flip) surfaces it ("User sent an image. It has been saved to: /tmp/channel-files/…"). Suggested fix: file-back the quoted image (attach via filePath like non-image media), at least when the envelope already carries image data, or skip the quoted download with a stderr note when the conflict is detected. (This finding's inline comment was suppressed because its resolved line 1832 overlaps the fixed round-1 R1-1 thread at the same line — a different defect, so its only copy lives in this body.) 中文说明:当消息自带图片且同时附加引用图片时,引用图片被 ChannelBase 的单图片解析逻辑静默丢弃(两个附件均为仅含 data 的图片;第一个占满唯一的 imageBase64 槽位,第二个两个分支均不满足,无日志、无 prompt 行)。本 PR 之前 envelope 最多只有一个附件,是本 diff 使该冲突可达。失败场景:用户用自己的图片回复图片消息并询问引用图片 → 引用图片被下载、附加后静默丢失,Agent 只看到占位符与用户自己的图片。见证(探针):双图片 envelope 解析后引用图片在 prompt、options、日志中均无踪迹;修复翻转(第二张图片落盘)后可正常呈现。建议修复:将引用图片落盘(以 filePath 附加),或检测冲突并跳过下载并输出 stderr 说明。(此发现的行内评论因解析行 1832 与已修复的第 1 轮 R1-1 线程同行而被抑制 —— 属不同缺陷,故其唯一副本位于本正文。)

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

- **Use DingTalk markdown-aware instructions** — DingTalk supports headings, bold text, links, code blocks, and tables. Keep tables compact because narrow screens may scroll horizontally.
- **Restrict access** — In an organization context, `senderPolicy: "open"` may be acceptable. For tighter control, use `"allowlist"` or `"pairing"`. See [DM Pairing](./overview#dm-pairing) for details.
- **Referenced messages** — Quoting (replying to) a user message includes the quoted text as context for the agent. Quoting bot responses is not yet supported.
- **Referenced messages** — Quoting (replying to) a user message includes the quoted text as context for the agent. If the quoted message carries an image, file, audio, or video, the bot downloads and attaches it the same way as when sent directly. Quoting bot responses is not yet supported.

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] R3-1 (round 3, still stands): This sentence reads as covering any quoted message that carries an image, but quoted media is only downloaded for picture/file/audio/video msgTypes — mediaTypeFromMsgType fails closed for richText, so a quoted richText message (text + inline picture) renders the [image] placeholder into the prompt but downloads nothing, while a DIRECT richText message does download its first image — the documented parity ("the same way as when sent directly") is false for that shape. The PR description already declares richText quotes out of scope, so the fix is to qualify the sentence, not to implement the download. — Failure scenario: a user quotes a richText message containing an image and asks about it; referencedText shows [image], nothing downloads, and the agent sees the placeholder with no image, contradicting this sentence.

Suggested change
- **Referenced messages** — Quoting (replying to) a user message includes the quoted text as context for the agent. If the quoted message carries an image, file, audio, or video, the bot downloads and attaches it the same way as when sent directly. Quoting bot responses is not yet supported.
- **Referenced messages** — Quoting (replying to) a user message includes the quoted text as context for the agent. If the quoted message is a picture, file, audio, or video message, the bot downloads and attaches it the same way as when sent directly. Quoting bot responses is not yet supported.
中文说明

[Suggestion] R3-1(第 3 轮已报告,仍然存在):该句读起来像是覆盖任何携带图片的引用消息,但引用媒体只对 picture/file/audio/video 这些 msgType 下载 —— mediaTypeFromMsgTyperichText 失败关闭,因此引用的 richText 消息(文本 + 内嵌图片)只会把 [image] 占位符渲染进 prompt 而不会下载任何内容,而直发的 richText 消息却会下载其第一张图片 —— "与直接发送相同"的表述对这种形态不成立。PR 描述已声明 richText 引用不在本次范围内,因此修复方式是限定该句的适用范围,而非实现 richText 下载。 — 失败场景:用户引用一条包含图片的 richText 消息并询问该图片;referencedText 显示 [image],但没有下载发生,Agent 只看到占位符而没有图片,与该句表述矛盾。

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

downloadCodes: code ? [code] : [],
mediaType: 'video',
mediaType: this.mediaTypeFromMsgType(msgtype),
placeholder: '(video)',

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] R3-2 (round 3, still stands): The direct-video placeholder cleanup has no test. Round 3 added direct-cleanup tests for file and audio only, leaving this video branch's placeholder wiring unguarded: the mutant changing placeholder: '(video)' to '(audio)' (or dropping the field) survives the whole suite — a direct video message would then keep the stray (video) placeholder beside the attachment while every test stays green. — Failure scenario: with that mutant, attachMedia's cleanup comparison envelope.text === cleanPlaceholderText fails ('(video)' !== '(audio)') and the agent prompt keeps the stray placeholder — exactly the artifact the cleanup and its two sibling tests exist to prevent. Suggested fix: add the sibling test it('cleans the generated placeholder for a direct video message', ...) mirroring the file/audio cases (sendDirectMedia(channel, 'video', { downloadCode: 'direct-video-code' }), assert envelope.text is '' and the attachment has type: 'video').

中文说明

[Suggestion] R3-2(第 3 轮已报告,仍然存在):直发视频的占位符清理没有测试。第 3 轮只为 file 和 audio 添加了直发清理测试,video 分支这里的 placeholder 接线因此无人守护:将 placeholder: '(video)' 改为 '(audio)'(或删除该字段)的变异体能通过整个测试套件 —— 届时直发视频消息会在附件旁残留多余的 (video) 占位符,而所有测试仍然是绿的。 — 失败场景:在该变异下,attachMedia 的清理比较 envelope.text === cleanPlaceholderText 失败('(video)' !== '(audio)'),Agent prompt 保留多余占位符 —— 正是清理逻辑及其两个姊妹测试本要防止的问题。建议修复:添加姊妹测试 it('cleans the generated placeholder for a direct video message', ...),仿照 file/audio 用例(sendDirectMedia(channel, 'video', { downloadCode: 'direct-video-code' }),断言 envelope.text'' 且附件的 type'video')。

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

@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 轮)。改动内容与我反驳保留之处如下:

Autofix review round — PR #9347 (round 5)

Addressed all actionable feedback: one Critical from the round-4 CHANGES_REQUESTED review body (R4-1) and the two still-standing round-3 suggestions (R3-1, R3-2, each posted twice). The items listed as "Deferred under the convergence posture — recorded, not requested in this round" were left untouched, except the round-3 probe item "own image + quoted image: second image downloaded then silently discarded", which is the same defect later escalated as R4-1 and is now fixed.

Commit: acb408710ffix(dingtalk): file-back a quoted image colliding with the own image (3 files, +105/−3). No conflict resolution was needed (--conflict false; no merge performed).

Feedback points and decisions

[Critical] R4-1 (review body rv:4964099764) — ACTED

Quoted image alongside the message's own image was silently discarded: both arrived as data-only {type: 'image', data, mimeType} attachments, and ChannelBase's resolution fills its single imageBase64 slot from the first one and drops every later data-only attachment (no filePath, so no prompt line, no log).

  • Reproduced before fixing (source-blind verification): added a focused test for a picture message replying to a picture message; on the pre-fix code it failed exactly on the defect — the second attachment was { type: 'image', data: 'AQID', mimeType: 'image/png' } (expected ... to not have property "data"). This test fails on the pre-round branch and passes after the fix.
  • Fix (minimal, in the adapter — ChannelBase is outside this PR's footprint): attachMedia now checks whether the inline image slot ChannelBase resolves is already occupied (an existing attachment with type: 'image' and data). If it is, the image falls through to the existing file-backed path instead of being appended as a doomed data-only attachment: it is saved to the temp dir and attached with filePath, so ChannelBase surfaces it via the User sent an image. It has been saved to: … prompt line and the agent can read it. The own image keeps the inline slot (matching the finding's suggested fix); a quoted image whose slot is still free (e.g. own message is text/file/audio/video, or the own download failed) remains inline as before.

[Suggestion] R3-1 (rc:3805979974, rc:3806537005) — ACTED

The documented parity sentence was unconditional while quoted media downloads are limited to picture/file/audio/video msgTypes (mediaTypeFromMsgType fails closed for richText). Applied the suggested qualification verbatim in docs/users/features/channels/dingtalk.md: "If the quoted message is a picture, file, audio, or video message, the bot downloads and attaches it the same way as when sent directly." No code change — richText quotes stay out of scope per the PR description.

[Suggestion] R3-2 (rc:3805979990, rc:3806537026) — ACTED

Added the missing direct-video sibling test 'cleans the generated placeholder for a direct video message', mirroring the file/audio cases (sendDirectMedia(channel, 'video', …), asserting text is '', attachment type: 'video', generated dingtalk_video_* file name). Verified it kills the described mutant: temporarily dropping placeholder: '(video)' makes the test fail (expected '(video)' to be ''); the mutant was then reverted and the suite is green.

Deferred under convergence posture — UNCHANGED (recorded, not requested this round)

  • Round-3 probe: quoted-media download runs before the preflightInbound sender-policy gate.
  • Round-4 body: own-media and quoted-media downloads awaited sequentially (prompt-start latency is the sum, not the max).

Verification

  • Pre-fix reproduction run: vitest src/DingtalkAdapter.test.ts -t "quoted image when the message already carries"1 failed as expected (expected { type: 'image', data: 'AQID', … } to not have property "data"), demonstrating the R4-1 defect on the pre-round code
  • Post-fix: npx vitest run in packages/channels/dingtalk316 passed (10 files)
  • Mutant check for R3-2: dropped placeholder: '(video)' → new direct-video test failed (expected '(video)' to be '') → mutant reverted, suite green again
  • npx prettier --check on the three touched files — clean
  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • Not run: integration tests (the touched behavior is fully covered by the package's unit tests, not only exercised through the bundled CLI/integration harness); npm run generate:settings-schema not applicable (no settings source changed)
中文说明

Autofix 审查轮次 — PR #9347(第 5 轮)

已处理全部可执行反馈:第 4 轮 CHANGES_REQUESTED 审查正文中的一个 Critical(R4-1),以及两条仍然成立的第 3 轮建议(R3-1、R3-2,各被重复提交两次)。标注为"收敛姿态下延后——已记录,本轮不要求修改"的条目保持原样未动;唯一的例外是第 3 轮探针条目"自带图片 + 引用图片:第二张图片被下载后被静默丢弃",它与后来升级为 R4-1 的缺陷相同,现已修复。

提交:acb408710ffix(dingtalk): file-back a quoted image colliding with the own image(3 个文件,+105/−3)。无需解决冲突(--conflict false,未执行合并)。

反馈点与决定

[Critical] R4-1(审查正文 rv:4964099764)— 已处理

当消息自带图片且同时附带引用图片时,引用图片被静默丢弃:两者均以仅含 data 的 {type: 'image', data, mimeType} 附件到达,而 ChannelBase 的解析逻辑只用第一个填满唯一的 imageBase64 槽位,之后所有仅含 data 的附件都会被丢弃(没有 filePath,因此既无 prompt 行也无日志)。

  • 修复前先复现(来源无关验证):为"图片消息回复图片消息"场景新增了一个聚焦测试;在修复前代码上该测试恰好失败在缺陷处——第二个附件为 { type: 'image', data: 'AQID', mimeType: 'image/png' }expected ... to not have property "data")。该测试在第 5 轮开始前的分支上失败、修复后通过。
  • 修复(最小化,位于 adapter 内——ChannelBase 不在本 PR 的足迹范围内): attachMedia 现在会检查 ChannelBase 所解析的内联图片槽位是否已被占用(即是否已存在 type: 'image' 且含 data 的附件)。若已占用,图片将落入现有的落盘路径,而不是被追加为一个注定被丢弃的仅含 data 的附件:文件被保存到临时目录并以 filePath 附加,从而由 ChannelBase 通过 User sent an image. It has been saved to: … 这行 prompt 呈现,Agent 可以读取该文件。自带图片保留内联槽位(与发现建议的修复方式一致);槽位仍空闲时的引用图片(例如自带消息为文本/文件/音频/视频,或自带图片下载失败)仍按原逻辑内联。

[Suggestion] R3-1(rc:3805979974、rc:3806537005)— 已处理

文档中的等价性表述是无条件的,但引用媒体下载仅限 picture/file/audio/video 这些 msgType(mediaTypeFromMsgTyperichText 失败关闭)。已在 docs/users/features/channels/dingtalk.md 中逐字应用建议的限定表述:"If the quoted message is a picture, file, audio, or video message, the bot downloads and attaches it the same way as when sent directly."。未改代码——按 PR 描述,richText 引用仍不在本次范围内。

[Suggestion] R3-2(rc:3805979990、rc:3806537026)— 已处理

补充了缺失的直发 video 姊妹测试 'cleans the generated placeholder for a direct video message',仿照 file/audio 用例(sendDirectMedia(channel, 'video', …),断言 text''、附件 type: 'video'、生成的文件名为 dingtalk_video_*)。已验证该测试能杀死所描述的变异体:临时删除 placeholder: '(video)' 后新测试失败(expected '(video)' to be '');随后还原变异体,测试套件恢复全绿。

收敛姿态下延后的条目 — 未改动(已记录,本轮不要求修改)

  • 第 3 轮探针:引用媒体下载先于 preflightInbound 发送者策略门执行。
  • 第 4 轮正文:自带媒体与引用媒体串行 await(prompt 启动延迟为两者之和而非两者最大值)。

验证

  • 修复前复现运行:vitest src/DingtalkAdapter.test.ts -t "quoted image when the message already carries"按预期失败 1 个expected { type: 'image', data: 'AQID', … } to not have property "data"),证明 R4-1 缺陷在第 5 轮开始前的代码上成立
  • 修复后:在 packages/channels/dingtalk 下运行 npx vitest run316 通过(10 个文件)
  • R3-2 变异体检查:删除 placeholder: '(video)' → 新的直发 video 测试失败(expected '(video)' to be '')→ 还原变异体后套件恢复全绿
  • 对三个改动文件运行 npx prettier --check — 通过
  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • 未运行:集成测试(改动的行为已由该包的单元测试完整覆盖,并非只能通过打包 CLI/集成测试框架验证);npm run generate:settings-schema 不适用(未改动任何 settings 源)

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

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

Deferred under the convergence posture (round 5, not a blocker) — recorded, not requested in this round:

  • packages/channels/dingtalk/src/DingtalkAdapter.ts:1546 — [probe] recognition arm of the audio placeholder ternary has zero test coverage — mutant blanking a literal '(audio)' transcription survives the suite (code-age rule: anchored on code…
  • packages/channels/dingtalk/src/DingtalkAdapter.ts:1659 — [probe] direct-media store-failure path has no test pinning placeholder survival — cleanup-on-failure mutant survives the suite (code-age rule: anchored on code unchanged since the ro…
  • packages/channels/dingtalk/src/DingtalkAdapter.ts:1838 — [review] own-media and quoted-media downloads are awaited sequentially — prompt-start latency is the sum, not the max (already deferred in round 4; code unchanged since)
中文说明

收敛姿态下延后(第 5 轮,非阻断)——已记录,本轮不要求修改:共 3 条(原文未翻译,列表见上方英文部分)。

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

Comment on lines +1643 to +1645
safeName =
basename(typeof fileName === 'string' ? fileName : '') ||
`dingtalk_${mediaType}_${Date.now()}`;

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.

[Critical] R5-1: The generated fallback name for stored media has no file extension (dingtalk_<type>_<ts>), and qwen-code's read pipeline resolves file types extension-first — detectFileType gets null from mime.getType for an extensionless name, falls through every allowlist to the isBinaryFile content sampler, whose NUL-byte rule classifies real image/audio/video bytes as binary, so read_file returns "Cannot display content of binary file". This defeats two shapes this PR makes reachable: (1) file-backed quoted images — the exact R4-1 fix this round: when the message's own image occupies the inline slot, the quoted image is stored extensionless, so the saved to: line the new comment says "keeps it reachable for the agent" does not actually make it usable; (2) all quoted audio/video — they always take the file-backed path, and DingTalk audio/video content carries no fileName (the adapter's own direct audio/video branches never read one; the SDK wire shape for audio is {downloadCode, duration}), so every real quoted voice/video message is stored extensionless. The quoted audio/video tests supply an idealized fileName the wire shape lacks, which is why the suite misses this. — Failure scenario: a user replies to a picture with their own picture and asks about the quoted one → the quoted image downloads (up to 50 MB), is stored as dingtalk_image_<ts>, and read_file refuses it as binary — the agent answers from the user's own image only while the prompt claims the quoted one was saved. A user replies to a voice message → the OGG is stored as dingtalk_audio_<ts>, advertised via saved to:, and refused — the quoted-audio feature is unusable for its primary real-world shape.

Witness (probe through the real adapter + real detectFileType from packages/core):

quoted audio, wire shape {downloadCode, duration}:
  PR code:  saved dingtalk_audio_1787085728034 -> detectFileType: binary
            -> llmContent: "Cannot display content of binary file"
  fix-flip (mimeType-derived extension):
            saved dingtalk_audio_1787085763575.ogg -> detectFileType: audio
            -> llmContent: inlineData(audio/ogg) delivered to the model
image arm: extensionless PNG -> binary + refusal; same bytes as .png -> image + inline parts

Suggested fix (spans a helper plus this fallback, so no one-click suggestion): derive an extension from media.mimeType whenever the generated name is used —

// module scope
const GENERATED_MEDIA_EXT: Record<string, string> = {
  'image/jpeg': 'jpg',
  'image/png': 'png',
  'image/gif': 'gif',
  'image/webp': 'webp',
  'audio/ogg': 'ogg',
  'audio/mpeg': 'mp3',
  'video/mp4': 'mp4',
};

// in attachMedia (this fallback)
safeName =
  basename(typeof fileName === 'string' ? fileName : '') ||
  `dingtalk_${mediaType}_${Date.now()}.${GENERATED_MEDIA_EXT[media.mimeType] ?? 'bin'}`;

and tighten the R4-1 test to require the extension (/^dingtalk_image_\d+\.\w+$/), plus model quoted audio/video content without fileName (the wire shape) so the fallback is actually exercised.

中文说明

[Critical] R5-1:存储媒体时生成的回退文件名没有扩展名(dingtalk_<type>_<ts>),而 qwen-code 的读取管线是扩展名优先的 —— detectFileType 对无扩展名调用 mime.getType 得到 null,穿过所有白名单后落到 isBinaryFile 内容采样器,其 NUL 字节规则会把真实的图片/音频/视频字节判定为 binary,于是 read_file 返回 "Cannot display content of binary file"。这使本 PR 新可达的两种形态实际不可用:(1) 落盘的引用图片 —— 正是本轮 R4-1 修复的路径:当消息自带图片占满内联槽位时,引用图片以无扩展名落盘,新注释所谓 "saved to: 行使 Agent 可达" 实际并不成立;(2) 所有引用音频/视频 —— 它们必然走落盘路径,而钉钉音视频 content 不携带 fileName(适配器的直发音视频分支从不读取 fileName;SDK 线格式中音频为 {downloadCode, duration}),因此每条真实的引用语音/视频消息都以无扩展名存储。引用音视频测试提供了线格式中并不存在的理想化 fileName,因此测试套件未能发现此问题。 — 失败场景:用户用自己的图片回复一张图片并询问引用图片 → 引用图片下载(最大 50 MB)后以 dingtalk_image_<ts> 存储,read_file 以 binary 拒绝读取 —— prompt 声称已保存,Agent 却只能看到用户自己的图片。用户回复一条语音消息 → OGG 以 dingtalk_audio_<ts> 存储并经 saved to: 通告,但被拒绝读取 —— 引用音频功能在其最主要的真实形态(语音消息)下不可用。

见证(通过真实适配器 + packages/core 真实 detectFileType 的探针):

引用音频,线格式 {downloadCode, duration}:
  PR 代码:存储 dingtalk_audio_1787085728034 -> detectFileType: binary
           -> llmContent: "Cannot display content of binary file"
  修复翻转(按 mimeType 派生扩展名):
           存储 dingtalk_audio_1787085763575.ogg -> detectFileType: audio
           -> llmContent: inlineData(audio/ogg) 送达模型
图片侧:无扩展名 PNG -> binary 拒绝;同样字节改名 .png -> image 内联送达

建议修复(跨辅助常量与此处回退,故不提供一键 suggestion):凡使用生成文件名时,按 media.mimeType 派生扩展名(上方英文部分附代码),并将 R4-1 测试收紧为要求扩展名(/^dingtalk_image_\d+\.\w+$/),同时让引用音视频测试按真实线格式不带 fileName,使回退路径真正被覆盖。

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

@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 轮)。改动内容与我反驳保留之处如下:

Round summary

Outcome: 1 Critical resolved in code; 3 findings the reviewer explicitly deferred for this round remain deferred (no action requested).

Feedback points

[rc:3807907848] R5-1 — generated media store names carry no file extension (Critical) — RESOLVED in code

Reproduced before changing any code:

  • Probe through the real detectFileType from packages/core: extensionless dingtalk_image_<ts> holding real PNG bytes → binary (so read_file refuses it); the same bytes under a .png name → image; an OGG buffer under an extensionless name → binary, under .oggaudio. This confirms the extension-first read pipeline refuses the stored media exactly as the finding describes.
  • The tightened tests (below) failed against the pre-fix code with generated names such as dingtalk_audio_1787088449755 — no extension (6 failures).

Fix (minimal, as suggested in the finding):

  • Added a module-level mime-type → extension map (jpg, png, gif, webp, ogg, mp3, mp4). The generated fallback name dingtalk_<type>_<ts> now appends the mapped extension, defaulting to .bin for an unknown mime type (classification-neutral, same as today). Supplied file names from DingTalk file messages are untouched.
  • Tests: the quoted-image R4-1 test, the direct audio/video placeholder tests, and the non-string-fileName fallback test now require the extension (/^dingtalk_<type>_\d+\.<ext>$/). The quoted audio/video tests now model the real wire shape — no fileName (audio content is {downloadCode, duration}) — so the generated-name fallback is actually exercised instead of being masked by an idealized fileName.

[rv:4965744106] CHANGES_REQUESTED review — three findings the reviewer explicitly deferred ("recorded, not requested in this round") — REMAIN DEFERRED

  • Recognition arm of the audio placeholder ternary has no test coverage — a coverage gap; the behavior itself is correct.
  • Direct-media store-failure path has no test pinning placeholder survival — a coverage gap; the behavior is correct (placeholder cleanup runs only after a successful store, so a failed store keeps the placeholder text).
  • Own-media and quoted-media downloads are awaited sequentially (prompt-start latency is the sum, not the max) — a performance suggestion already deferred in round 4.

Per the reviewer's round-5 convergence posture these were recorded, not requested; no code change for them this round. They stay visible for a follow-up.

Changes

  • packages/channels/dingtalk/src/DingtalkAdapter.tsGENERATED_MEDIA_EXT map; the generated store name appends the mime-derived extension (+13/−1).
  • packages/channels/dingtalk/src/DingtalkAdapter.test.ts — wire-shape quoted audio/video tests; extension-required generated-name assertions (+24/−9).

Conflict notes

None (--conflict false; no merge performed).

Verification

  • Probe through the real packages/core detectFileType — reproduced the defect: extensionless generated names → binary; with the mime-derived extension → image / audio
  • npx vitest run src/DingtalkAdapter.test.ts (in packages/channels/dingtalk) — 6 failed pre-fix (reproduction), 141 passed post-fix
  • npx vitest run (full packages/channels/dingtalk suite) — 316 passed (10 files)
  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • npx prettier --check on the two touched files — passed
  • Integration tests after npm run bundle: not applicable — the touched behavior is exercised by the channel's unit tests, not the bundled CLI or integration harness. Settings schema regeneration: not applicable — no settings source changed.
中文说明

本轮摘要

结果: 1 条 Critical 已在代码中解决;评审者本轮明确延后的 3 条发现保持延后状态(本轮不要求处理)。

反馈要点

[rc:3807907848] R5-1 — 生成的媒体存储文件名不带文件扩展名(Critical)— 已在代码中解决

在修改任何代码之前先复现:

  • 通过 packages/core 中真实的 detectFileType 进行探针验证:无扩展名的 dingtalk_image_<ts> 存放真实 PNG 字节 → 判定为 binary(因此 read_file 拒绝读取);同样的字节改用 .png 文件名 → image;OGG 字节在无扩展名下 → binary,改为 .oggaudio。这证实了扩展名优先的读取管线确实如该发现所述拒绝已存储的媒体。
  • 收紧后的测试(见下)在修复前的代码上失败,生成的文件名形如 dingtalk_audio_1787088449755 —— 没有扩展名(共 6 处失败)。

修复(按发现建议的最小改动):

  • 新增模块级 mime 类型 → 扩展名映射表(jpgpnggifwebpoggmp3mp4)。生成的回退文件名 dingtalk_<type>_<ts> 现在会追加映射得到的扩展名,未知 mime 类型默认 .bin(分类行为中性,与现状一致)。钉钉文件消息提供的真实文件名不受影响。
  • 测试:引用图片 R4-1 测试、直发音频/视频占位符测试、以及非字符串 fileName 回退测试现在都要求带扩展名(/^dingtalk_<type>_\d+\.<ext>$/)。引用音频/视频测试现在按真实线格式建模 —— 不带 fileName(音频 content 为 {downloadCode, duration})—— 使生成文件名的回退路径真正被覆盖,而不再被理想化的 fileName 掩盖。

[rv:4965744106] CHANGES_REQUESTED 评审 — 评审者明确延后的三条发现("已记录,本轮不要求修改")— 保持延后

  • 音频占位符三元表达式的识别结果分支无测试覆盖 —— 属覆盖缺口;行为本身正确。
  • 直发媒体存储失败路径无测试钉住占位符存活 —— 属覆盖缺口;行为正确(占位符清理仅在存储成功后执行,存储失败时占位符文本保留)。
  • 自有媒体与引用媒体串行 await 下载(prompt 启动延迟为两者之和而非最大值)—— 性能建议,第 4 轮已延后。

按评审者第 5 轮的收敛姿态,这些已被记录、本轮不要求处理;本轮不为其改动代码,保持可见以便后续跟进。

变更内容

  • packages/channels/dingtalk/src/DingtalkAdapter.ts —— GENERATED_MEDIA_EXT 映射表;生成的存储文件名追加按 mime 派生的扩展名(+13/−1)。
  • packages/channels/dingtalk/src/DingtalkAdapter.test.ts —— 按真实线格式建模的引用音频/视频测试;生成文件名断言要求带扩展名(+24/−9)。

冲突说明

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

验证

  • 通过 packages/core 真实 detectFileType 的探针 —— 复现缺陷:无扩展名的生成文件名 → binary;带 mime 派生扩展名后 → image / audio
  • npx vitest run src/DingtalkAdapter.test.ts(位于 packages/channels/dingtalk)—— 修复前 6 处失败(复现),修复后 141 通过
  • npx vitest runpackages/channels/dingtalk 完整套件)—— 316 通过(10 个文件)
  • npm run build —— 通过
  • npm run typecheck —— 通过
  • npm run lint —— 通过
  • npx prettier --check(两个改动文件)—— 通过
  • npm run bundle 后的集成测试:不适用 —— 改动的行为由 channel 单元测试覆盖,而非打包后的 CLI 或集成测试框架。设置 schema 重新生成:不适用 —— 未改动任何设置源。

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

@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 blocking issues. LGTM! ✅

Deferred under the convergence posture (round 6, not a blocker) — recorded, not requested in this round:

  • packages/channels/dingtalk/src/DingtalkAdapter.ts:137 — [review] mime→extension table duplicates core's extensionForMimeType, which normalizes headers the local copy does not
  • packages/channels/dingtalk/src/DingtalkAdapter.ts:1623 — [review] ChannelBase's single-image-slot rule is duplicated in the adapter; the overflow compensation belongs in ChannelBase
  • packages/channels/dingtalk/src/DingtalkAdapter.ts:1623 — [probe] mutant some(() => true) on the slot probe ships green — the non-image-does-not-consume-the-slot half is untested
  • packages/channels/dingtalk/src/DingtalkAdapter.ts:137 — [probe] 4 of 7 extension-map entries have no test pin — deleting one leaves the suite green and stored media unreadable
中文说明

无阻断问题。LGTM!✅

收敛姿态下延后(第 6 轮,非阻断)——已记录,本轮不要求修改:共 4 条(原文未翻译,列表见上方英文部分)。

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

@qqqys
qqqys enabled auto-merge August 19, 2026 02:56
@qqqys

qqqys commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /takeover stop

@qqqys qqqys removed the autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) label Aug 19, 2026
@qqqys
qqqys added this pull request to the merge queue Aug 20, 2026
Merged via the queue into QwenLM:main with commit 36c77ff Aug 20, 2026
37 of 38 checks passed
qqqys added a commit to qqqys/qwen-code that referenced this pull request Aug 20, 2026
Clears the CONFLICTING state this PR has carried for 19 rounds. One file
conflicted, `packages/channels/dingtalk/src/DingtalkAdapter.test.ts`, and
both sides were pure additions to it: this branch adds the
`DingtalkChannel chat records` suite (+1205) and main adds
`DingtalkChannel quoted media` (+634, from QwenLM#9347), both appended after
`DingtalkChannel parsed-message logging`. Git could not align two
insertions at the same anchor; the resolution keeps both suites and both
sets of `node:fs`/`node:path` imports, with nothing dropped from either
side.

Verified on the merged tree: `packages/channels/dingtalk` runs 365 tests
across 10 files, all passing (190 of them in the merged file itself).
Prettier and eslint are clean on it. `tsc --noEmit` reports one error,
`truncateUtf16Units` missing from `@qwen-code/channel-base` — identical
before and after the merge, and an artifact of the worktree resolving
that package to a stale build rather than to this branch's source, which
does export it.
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.15.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants