Skip to content

feat(dingtalk): present tool permission requests with native interactive cards - #10457

Open
BenGuanRan wants to merge 10 commits into
QwenLM:mainfrom
BenGuanRan:feat/dingtalk-permission-cards-10388
Open

feat(dingtalk): present tool permission requests with native interactive cards#10457
BenGuanRan wants to merge 10 commits into
QwenLM:mainfrom
BenGuanRan:feat/dingtalk-permission-cards-10388

Conversation

@BenGuanRan

@BenGuanRan BenGuanRan commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

This PR presents attended DingTalk tool-permission requests as native interactive cards. The card offers allow once, deny, and the persistent allow choice only when the original permission request advertises it. Responses are bound to the originating user and live run, settle the original request at most once, and move stale, cancelled, denied, approved, or expired cards into a terminal state.

Existing text commands remain the fallback when permission cards are disabled or card delivery fails. At Channel startup, the already-loaded default general.language setting selects Chinese permission copy for Chinese values and English for all other values. The daemon follows the same startup-snapshot behavior and does not call the per-session language endpoint. Other channels do not gain native permission cards, and permission-policy semantics are unchanged.

Why it's needed

DingTalk already uses native cards for task status and structured questions, but ordinary tool permissions still require users to type /approve, /approve-always, or /deny. A native card makes this security-sensitive interaction clearer and easier to operate while preserving the exact choices supplied by the permission system. Following the existing default language also avoids showing English permission chrome inside a Chinese Channel experience.

Reviewer Test Plan

How to verify

  1. Start an attended DingTalk run with interactive permission cards enabled and trigger an ordinary tool action that requires approval. Confirm one native card appears instead of permission-command text.
  2. Confirm allow once resumes the original request once and marks the card approved, while deny rejects it once and marks the card denied.
  3. Trigger requests with and without a persistent grant. Confirm the persistent choice appears only when advertised and uses the original grant scope.
  4. Use the card's cancel action and let another request time out. Confirm both deny the pending permission before any delayed card update can race with a text approval, and confirm the cards become cancelled and expired respectively.
  5. Attempt a callback as another group member, submit malformed or duplicate values, and submit a stale card. Confirm none can settle the permission.
  6. Disable permission cards or simulate card delivery failure. Confirm the existing /approve, optional /approve-always, and /deny text instructions are delivered.
  7. Start or reload the Channel with default language zh_CN, then with en or auto. Confirm permission card chrome, terminal states, owner-only feedback, and fallback copy are Chinese for zh_CN and English otherwise; no session-language lookup is required.

Evidence (Before & After)

Before: ordinary DingTalk tool permissions are rendered as text commands that the user must type manually, and the permission flow does not consistently follow the configured default language.

After: ordinary attended permissions are rendered as an owner-bound native card with only the choices supplied by the original permission request; terminal cards cannot be reused, and the card plus immediate fallback follow the default Channel language. No live DingTalk screenshot is included because valid app credentials and published-template delivery evidence were not available in the local environment.

Automated evidence: 639 ChannelBase tests, 335 affected DingTalk tests, and 171 affected CLI tests pass. Repository build, typecheck, lint, targeted formatting, and diff checks pass. Review findings covering callback races, Chinese fallback title selection, and underscore locale normalization were fixed with regression coverage; two final clean review passes found no remaining blocking issue.

Tested on

OS Status
🍏 macOS
🪟 Windows N/A
🐧 Linux N/A

Environment (optional)

macOS 14.4.1, Node.js v25.2.1, local package tests and monorepo validation. Real DingTalk delivery was not run.

Risk & Scope

  • Main risk or tradeoff: permission responses can arrive from the card, existing text commands, timeout handling, or outside settlement; all paths share one response promise and terminal card lifecycle, with regression coverage for duplicate and delayed-update races.
  • Not validated / out of scope: real DingTalk delivery with production credentials and a published template; live per-session language switching; unattended or loop runs; native permission cards in CLI, Web, IDE, and other IM adapters.
  • Breaking changes / migration notes: none. Permission cards are enabled by default only when DingTalk interactive cards are configured and can be disabled independently. The locale is a startup snapshot, so changing the default setting takes effect through the existing Channel reload or process restart.

Linked Issues

Closes #10388

中文说明

本 PR 的改动

本 PR 将钉钉中有人值守的工具权限请求展示为原生交互卡片。卡片提供“仅允许一次”“拒绝”,并且仅当原始权限请求明确提供持久授权时才展示该选项。响应会绑定到发起用户和当前运行,同一原始请求最多结算一次,过期、取消、拒绝、允许或超时的卡片都会进入不可再次操作的终态。

当权限卡片被禁用或卡片投递失败时,现有文本命令仍作为回退。Channel 启动时只读取已加载的默认 general.language 配置:中文配置使用中文权限文案,其余配置使用英文。daemon 同样使用启动时快照,不调用 session 级语言接口。其他渠道不会获得原生权限卡片,权限策略语义保持不变。

为什么需要此改动

钉钉渠道已经使用原生卡片展示任务状态和结构化问题,但普通工具权限仍要求用户手动输入 /approve/approve-always/deny。原生卡片让这一安全敏感交互更清晰、更易操作,同时严格保留权限系统提供的原始选项。沿用已有默认语言也可以避免中文 Channel 体验中出现英文权限卡片文案。

Reviewer 测试计划

如何验证

  1. 启动启用了交互式权限卡片的有人值守钉钉运行,并触发需要审批的普通工具操作。确认只出现一张原生卡片,而不是权限命令文本。
  2. 确认“仅允许一次”只恢复一次原始请求并将卡片标记为已允许;“拒绝”只拒绝一次并将卡片标记为已拒绝。
  3. 分别触发带有和不带有持久授权的请求。确认只有原始请求提供该选项时才显示持久授权,并且沿用原始授权范围。
  4. 使用卡片取消操作,并让另一个请求超时。确认两者都会在任何延迟卡片更新可能与文本批准竞争之前拒绝待处理权限,且卡片分别进入已取消和已过期状态。
  5. 使用其他群成员身份操作、提交畸形或重复值以及提交过期卡片。确认这些操作都不能结算权限。
  6. 禁用权限卡片或模拟卡片投递失败。确认会发送现有的 /approve、可选 /approve-always/deny 文本说明。
  7. 将默认语言设为 zh_CN 后启动或重载 Channel,再分别使用 enauto。确认权限卡片文案、终态、非发起人反馈和回退文案在 zh_CN 下为中文,其他配置下为英文,并且不依赖 session 语言查询。

证据(改动前后)

改动前:普通钉钉工具权限以文本命令展示,用户必须手动输入命令,并且权限流程没有稳定沿用已配置的默认语言。

改动后:普通的有人值守权限以仅原发起人可操作的原生卡片展示,并且只包含原始权限请求提供的选项;终态卡片无法重复使用,卡片和直接回退文案遵循默认 Channel 语言。由于本地环境没有可用的应用凭证和已发布模板投递证据,因此未附真实钉钉截图。

自动化证据:639 个 ChannelBase 测试、335 个受影响钉钉测试和 171 个受影响 CLI 测试通过。仓库构建、类型检查、lint、目标格式检查和差异检查均通过。评审发现的回调竞态、中文回退标题选择和下划线语言配置归一化问题均已修复并补充回归覆盖;最终两轮复核未发现剩余阻断问题。

测试平台

OS 状态
🍏 macOS
🪟 Windows N/A
🐧 Linux N/A

环境(可选)

macOS 14.4.1、Node.js v25.2.1,本地包测试与 monorepo 校验。未运行真实钉钉投递。

风险与范围

  • 主要风险或取舍:权限响应可能来自卡片、现有文本命令、超时处理或外部结算;所有路径共享同一个响应 Promise 和卡片终态生命周期,并通过回归测试覆盖重复响应和延迟更新竞态。
  • 未验证或不在范围内:使用生产凭证和已发布模板的真实钉钉投递;实时 session 语言切换;无人值守或循环运行;CLI、Web、IDE 以及其他 IM 适配器的原生权限卡片。
  • 破坏性变更或迁移说明:无。权限卡片仅在钉钉交互卡片已配置时默认启用,并可独立关闭。语言为启动时快照,修改默认配置后通过现有 Channel 重载或进程重启生效。

关联 Issue

Closes #10388

@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Aug 29, 2026
@BenGuanRan
BenGuanRan force-pushed the feat/dingtalk-permission-cards-10388 branch from acf6330 to 779699b Compare August 31, 2026 17:02
@github-actions

Copy link
Copy Markdown
Contributor

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

中文

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

@BenGuanRan

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 31, 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-ci-bot

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

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

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

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

Qwen Code · serve A/B

@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-dev-bot

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

Copy link
Copy Markdown
Collaborator

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

中文说明

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

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Autofix review round — no action needed (PR #10457)

Triaged all feedback newer than the last evaluation (2026-08-29T04:24:27Z):

  • Issue-level comment [ic:5481964814] (serve daemon A/B report) — this is an automated A/B verification report, not a review finding. It compared the PR base against this PR's head (432d03f) across 12 scenarios and reported ✅ no response changes. There is nothing to fix.
  • Reviews — none received this round.
  • Inline comments — none received this round.
  • Failed checks / still-red checks — none. All CI checks are green or routinely skipped.

No defects, suggestions, or questions were raised, so no code changes were made and no commit was created.

中文说明

Autofix 评审轮次 — 无需处理(PR #10457

已对上次评估(2026-08-29T04:24:27Z)之后的所有反馈进行分类:

  • Issue 级评论 [ic:5481964814](serve daemon A/B 报告)——这是一条自动化 A/B 验证报告,并非评审发现。该报告将 PR 基线与本 PR 的 head(432d03f)在 12 个场景下进行了对比,结果为 ✅ 响应无变化。没有需要修复的内容。
  • 评审(Reviews)——本轮未收到任何评审。
  • 行内评论(Inline comments)——本轮未收到任何行内评论。
  • 失败的检查 / 持续失败的检查——无。所有 CI 检查均为通过或常规跳过状态。

本轮没有提出任何缺陷、建议或问题,因此未做任何代码改动,也没有创建提交。

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


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

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 AutoFix ran out of time before finishing (timeout (3600000ms)) (attempt 1/100) — it will retry on the next scan.

⚠️ This change was NOT pushed — any commit referenced below was made only in the runner workspace and has been discarded. What the agent reported:
Qwen failed during address-review: timeout (3600000ms).

See the Qwen Autofix agent step logs for model/tool output.

中文说明

🤖 AutoFix 在完成前耗尽了时间(timeout (3600000ms))(第 1/100 次尝试)—— 将在下次扫描时重试。

Run log: https://github.com/QwenLM/qwen-code/actions/runs/33463984189


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

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Could not produce a passing fix for this feedback (round 2/100). This item now needs a human; the loop stays engaged and still picks up new feedback and base conflicts, but will not retry this item on its own.

⚠️ This change was NOT pushed — any commit referenced below was made only in the runner workspace and has been discarded. What the agent reported:

Round stopped: the gate's packages/cli test leg fails for environmental reasons this round cannot fix in scope

PR #10457 (DingTalk permission cards), same-run verification repair of the
rejection "tests failed in packages/cli" charged to merge commit cf8ff569d6.

Blocker

The verification gate re-runs
npm run test --workspace packages/cli -- --changed origin/main --passWithNoTests
inside an env -i clean child whose allowlist (qwen-autofix.yml, the
review-address "Verification gate" step) is PATH, HOME, RUNNER_TEMP, WORKDIR,
BRANCH, GITHUB_OUTPUT, CI, KISS_AUDIT, FOOTPRINT_ENFORCE — it does NOT pass
RUNNER_NAME. Every slow-runner accommodation this repo merged for shared
ECS hosts (#10552 "ci: stabilize tests under shared ECS host contention",
#10667) keys on RUNNER_NAME starting with ecs-qwen-:

  • packages/cli/vitest.config.ts: testTimeout 15000→60000, hookTimeout set,
    maxWorkers full-core-count→25%
  • serve/live test budgets, e.g. packages/cli/src/commands/serve.test.ts:1045
    const ecs = process.env['RUNNER_NAME']?.startsWith('ecs-qwen-') with
    startupMs = ecs ? 60_000 : 30_000

All of those are inactive inside the gate. Meanwhile this 64-core host ran at
load average ~150–207 for the whole window (uptime: 148.66, 167.84, 186.57,
later 206.90, 187.69, 184.49). Consequences, both measured:

  1. Cold serve startup via the dev entrypoint takes 44s — probe output:
    `qwen serve: startup timing: processToListenMs=43666 runQwenServeToListenMs=313
中文说明

🤖 未能为该反馈产生可通过验证的修复(第 2/100 轮)。此项现在需要人工处理;循环保持在线,仍会拾取新反馈与 base 冲突,但不会自行重试此项。

⚠️ 此改动未被推送 —— 下文引用的任何提交都只存在于 runner 工作区,已被丢弃。以下是 agent 的报告:

本轮停止:门禁的 packages/cli 测试环节因环境原因失败,本轮无法在范围内修复

PR #10457(钉钉权限卡片),针对合并提交 cf8ff569d6 被判"tests failed in
packages/cli"的同轮验证修复(same-run verification repair)。

阻塞点

验证门禁会以 env -i 干净子进程重跑
npm run test --workspace packages/cli -- --changed origin/main --passWithNoTests
该子进程的环境变量白名单(qwen-autofix.yml 中 review-address 的
"Verification gate" 步骤)只有 PATH、HOME、RUNNER_TEMP、WORKDIR、BRANCH、
GITHUB_OUTPUT、CI、KISS_AUDIT、FOOTPRINT_ENFORCE —— 没有传入
RUNNER_NAME。而本仓库为共享 ECS 宿主机合并的所有慢速 runner 照顾措施
#10552 "ci: stabilize tests under shared ECS host contention"、#10667
都以 RUNNER_NAMEecs-qwen- 开头为条件:

  • packages/cli/vitest.config.ts:testTimeout 15000→60000、设置 hookTimeout、
    maxWorkers 从全核数→25%
  • serve/实时测试的预算,例如 packages/cli/src/commands/serve.test.ts:1045
    const ecs = process.env['RUNNER_NAME']?.startsWith('ecs-qwen-')
    startupMs = ecs ? 60_000 : 30_000

这些照顾措施在门禁里全部失效。与此同时,这台 64 核宿主机在整个窗口期的
负载均值约为 150–207(uptime:148.66、167.84、186.57,随后
206.90、187.69、184.49)。以下为实测后果:

  1. 通过 dev 入口冷启动 serve 需要 44 秒 —— 探针输出:
    qwen serve: startup timing: processToListenMs=43666 runQwenServeToListenMs=31367
    —— 超过非 ECS 的 30 秒预算,因此 serve.test.ts 的 "reaches listening
    through the dev entrypoint without loading interactive Ink internals first"
    在本机必然失败。本机单独运行该测试同样失败,且该文件与 origin/main
    逐字节一致。
  2. maxWorkers 默认为核数(约 60 个 worker),在饱和宿主机上,非 ECS 的
    15 秒单测上限会让负载压到的任何重型测试超时:
    server-default-bridge-wiring.test.ts(7/7 全部超时)、
    server.test.ts(1163 个中 1 个)、
    workspace-channel-management.test.ts(15 个中 1 个)、
    fast-path-open.test.ts(首个测试超时后状态泄漏,连带 5 个失败)、
    run-qwen-serve-live.test.ts(30 秒预算,3 个失败)。

按设计,包测试不参与 A/B 基线对比(run-autofix-review-verification.sh:
"package tests ... A/B-exempt ... Their rejections stay charged to the
round"),因此尽管本轮改动的文件并未参与这些失败,该环境性失败仍被记在
本轮头上:六个失败套件全部与 origin/main 逐字节一致,唯一例外是本 PR 在
workspace-channel-management.test.ts 中新增的 14 行(该新测试单独运行通过)。

证据

  • 门禁 junit(被拒运行的 packages/cli/junit.xml):6 个失败套件、19 个失败
    测试,全部与 serve 相关,全部是超时或超时连锁。

Run log: https://github.com/QwenLM/qwen-code/actions/runs/33464526848


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

…ion-cards-10388

# Conflicts:
#	packages/channels/base/src/ChannelBase.ts
@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 轮)。改动内容与我反驳保留之处如下:

Autofix address-review summary — PR #10457

Feedback triage

feedback.md contained no new findings: no reviews, no inline comments, no issue-level comments, no failed or still-red checks. With --conflict true, this round's work is merging origin/main into the PR branch and resolving the conflicts.

Merge: origin/main → feat/dingtalk-permission-cards-10388

origin/main advanced from the branch's last merge base (f8609836) to 56f92c84. One textual conflict and two semantic conflicts appeared, all in the permission-prompt area where this PR and main PR #10614 ("Improve fallback permission and error messages") evolved the same code:

Conflict 1 — formatPermissionRequest (textual)

  • main unified the task/non-task message layouts into one format that adds Tool: / Action: / Parameters: context lines and surfaces the permission options' own labels.
  • PR localized the fallback message text through PERMISSION_COPY (en/zh).

Resolution keeps main's unified format and routes every fixed string through PERMISSION_COPY, adding request/tool/action/parameters keys (with zh translations 请求:/工具:/操作:/参数:) for main's new lines. The now-unused command key was replaced by action (zh value 操作: unchanged, en updated to Action:).

Conflict 2 — duplicate permissionOptionLabel methods (semantic, would not compile)

Both sides added a permissionOptionLabel method with different signatures: main's (option, fallback) label resolver and the PR's (name, kind) zh-label translator for card decisions.

Resolution keeps main's method and makes it locale-aware through a new localizedPermissionOptionLabel, which translates stock option names on zh channels: Allow/Allow once → 仅允许本次, Deny/Reject → 拒绝, and always-allow options matched by option id plus stock name prefix → 始终允许此项目 / 始终允许此用户 / 始终允许. This preserves the PR's zh localization under main's name-first label policy and extends it to the Reject stock name, which is what the CLI actually sends today (basicPermissionOptions in permissionUtils.ts) and which the PR's original map did not cover. The PR's duplicate method was deleted; its card-decision code now reuses main's new approvalOption/denialOption helpers.

Conflict 3 — test helper defaults (semantic)

main changed the shared emitPermission test helper's stock option names (Allow onceAllow, DenyReject) and added _meta.toolName, which three PR-side test expectations written against the old names/hardcoded labels no longer matched. Updated those three expectations to the name-first behavior; all PR zh assertions were kept as-is and pass under the resolution.

Supporting reuse

The card-presentation title now reuses main's permissionTitle (localized Tool use / 工具调用 fallback) instead of duplicating the sanitize/fallback logic.

Everything main brought in outside this area (status-card network recovery #10357, hooks trust-boundary fixes #10427, DWS reactions #10610, qwen-live, OpenTUI batch 4, etc.) merged cleanly and was verified by the package test suites below.

Verification

  • npm run build — passed (exit 0)
  • vitest run src/ChannelBase.test.ts (packages/channels/base) — 641 passed
  • npx vitest run (packages/channels/dingtalk, all 13 test files) — 508 passed
  • vitest run channel CLI tests (daemon-worker, runtime, start, config-utils in packages/cli/src/commands/channel) — 213 passed (4 files)
  • vitest run src/serve/routes/workspace-channel-management.test.ts (packages/cli) — 15 passed
  • npm run typecheck — passed
  • npm run lint — passed (the commit additionally passed the repository's pre-commit lint-staged gate: prettier + eslint --fix --max-warnings 0 over all staged files)
  • Mutation probes (zh localization layer added this round):
    • Disabled the locale !== 'zh' gate in localizedPermissionOptionLabel → the zh permission tests in ChannelBase.test.ts FAILED as expected (labels fell back to English); restored, re-ran → green.
    • Removed the proceed_always_project translation branch → the zh card test expecting 始终允许此项目 FAILED as expected; restored, re-ran → green.
中文说明

Autofix address-review 总结 — PR #10457

反馈分类

feedback.md没有新的发现:没有审查意见、没有行内评论、没有 issue 级评论、没有失败或仍然失败的检查项。由于 --conflict true,本轮的工作是将 origin/main 合并进 PR 分支并解决冲突。

合并:origin/main → feat/dingtalk-permission-cards-10388

origin/main 从该分支上次的合并基点(f8609836)前进到了 56f92c84。出现了 1 处文本冲突和 2 处语义冲突,全部位于权限提示区域——本 PR 与 main 上的 PR #10614("改进权限与错误回退消息")在同一块代码上各自演进:

冲突 1 — formatPermissionRequest(文本冲突)

  • main 将任务/非任务两种消息布局统一为一种格式,新增 Tool: / Action: / Parameters: 上下文行,并展示权限选项自身的标签。
  • 本 PR 通过 PERMISSION_COPYen/zh)将回退消息文本本地化。

解决方案保留 main 的统一格式,并将所有固定文案经由 PERMISSION_COPY 输出;为 main 新增的几行补充了 request/tool/action/parameters 键(中文翻译为 请求:/工具:/操作:/参数:)。不再使用的 command 键由 action 取代(中文值 操作: 不变,英文更新为 Action:)。

冲突 2 — 重复的 permissionOptionLabel 方法(语义冲突,无法编译)

双方各自添加了签名不同的 permissionOptionLabel 方法:main 的是 (option, fallback) 标签解析器,本 PR 的是用于卡片决策的 (name, kind) 中文标签翻译器。

解决方案保留 main 的方法,并通过新增的 localizedPermissionOptionLabel 使其具备语言感知能力:在 zh 渠道下翻译标准选项名:Allow/Allow once → 仅允许本次,Deny/Reject → 拒绝,以及按选项 id 加标准名前缀匹配的 always-allow 选项 → 始终允许此项目 / 始终允许此用户 / 始终允许。这在 main 的"选项名优先"标签策略下保留了本 PR 的中文本地化,并将其扩展到 Reject 这个标准名——它是 CLI 如今实际下发的名称(见 permissionUtils.ts 中的 basicPermissionOptions),而本 PR 原有的映射表并未覆盖。本 PR 的重复方法被删除;其卡片决策代码改为复用 main 新增的 approvalOption/denialOption 辅助方法。

冲突 3 — 测试辅助函数默认值(语义冲突)

main 修改了共享测试辅助函数 emitPermission 的标准选项名(Allow onceAllowDenyReject)并添加了 _meta.toolName,导致本 PR 侧 3 处基于旧名称/硬编码标签编写的测试期望不再匹配。已将这 3 处期望更新为"名称优先"的行为;本 PR 的所有中文断言保持原样,并在该解决方案下全部通过。

顺带的复用

卡片展示的标题改为复用 main 的 permissionTitle(本地化的 Tool use / 工具调用 回退),不再重复 sanitize/回退逻辑。

main 带入的该区域之外的所有内容(状态卡片网络恢复 #10357、hooks 信任边界修复 #10427、DWS 表情回应 #10610、qwen-live、OpenTUI 第 4 批等)均干净合并,并由下文的各包测试套件验证。

验证

  • npm run build — 通过(exit 0)
  • vitest run src/ChannelBase.test.ts(packages/channels/base)— 641 通过
  • npx vitest run(packages/channels/dingtalk,共 13 个测试文件)— 508 通过
  • vitest run channel CLI 测试(packages/cli/src/commands/channel 下的 daemon-workerruntimestartconfig-utils)— 213 通过(4 个文件)
  • vitest run src/serve/routes/workspace-channel-management.test.ts(packages/cli)— 15 通过
  • npm run typecheck — 通过
  • npm run lint — 通过(提交时还通过了仓库的 pre-commit lint-staged 门禁:对全部暂存文件运行 prettier 与 eslint --fix --max-warnings 0
  • 变异探针(针对本轮新增的中文本地化层):
    • 禁用 localizedPermissionOptionLabel 中的 locale !== 'zh' 判断 → ChannelBase.test.ts 中的中文权限测试按预期失败(标签回退为英文);恢复后重跑 → 通过。
    • 删除 proceed_always_project 翻译分支 → 期望 始终允许此项目 的中文卡片测试按预期失败;恢复后重跑 → 通过。

Base-conflict check · 基分支冲突检查: conflicted with main — resolved in this push. · 与 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

@BenGuanRan
BenGuanRan marked this pull request as ready for review September 1, 2026 18:27
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 1, 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 ✓

Problem: real and observed — linked issue #10388 (open, triaged welcome-pr, P2) documents that non-YOLO DingTalk sessions force users to type /approve / /approve-always / /deny command text by hand, while the adapter already renders native cards for status and questions. The issue triage accepted the direction for exploration.

Direction: aligned. DingTalk channel infrastructure for interactive cards already exists in this repo (status cards, question cards); this extends it to the remaining permission flow without weakening the permission boundary. No direct CHANGELOG reference in comparable agents, but permission-request UX is an actively invested area there too — nothing cuts against this.

Size: cross-package change (packages/channels/** + packages/cli/**), so the core-module lens applies. Breakdown: ~826 production logic lines, ~1059 test lines, ~735 docs lines. Production lines exceed 500, so this is flagged for maintainer awareness per the feat rule — not blocking on size, but a human should sign off on the shared ChannelBase seam.

Approach: the scope matches the issue's acceptance criteria — a dedicated permission-card-controller in the dingtalk package, a presenter seam in ChannelBase, owner-bound single-settlement callbacks, terminal card states, and text-command fallback when cards are disabled or delivery fails. Choosing a startup snapshot of general.language instead of a per-session language lookup is a reasonable scope limit. Keeping the review focused on the ChannelBase seam, since that is the shared surface other channels inherit.

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

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:真实且已被观测——关联 issue #10388(开放,已标记 welcome-pr、P2)说明非 YOLO 的钉钉会话需要用户手动输入 /approve / /approve-always / /deny 命令文本,而该适配器已经为状态和提问提供原生卡片。issue 分诊已接受该方向的探索。

方向:对齐。仓库中钉钉渠道已有交互卡片基础设施(状态卡片、提问卡片),本 PR 将其扩展到剩余的权限流程,且不削弱权限边界。同类产品的 CHANGELOG 中没有直接对应条目,但权限请求 UX 在那里也是持续投入的方向——没有反对此改动的信号。

规模:跨包改动(packages/channels/** + packages/cli/**),适用核心模块视角。拆分:约 826 行生产逻辑、约 1059 行测试、约 735 行文档。生产行数超过 500,按 feat 规则标记给维护者关注——不因规模阻塞,但共享的 ChannelBase 接缝需要人工把关。

方案:范围与 issue 的验收标准一致——钉钉包内独立的 permission-card-controllerChannelBase 中的 presenter 接缝、绑定发起人且只结算一次的回调、卡片终态,以及卡片禁用或投递失败时的文本命令回退。使用启动时快照 general.language 而非 session 级语言查询是合理的范围约束。审查将聚焦 ChannelBase 接缝,因为那是其他渠道继承的共享面。

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

进入代码审查 🔍

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Code review

No critical blockers. The implementation tracks the existing question-card pattern closely — tryPresentPermission in the base channel mirrors tryPresentUserInput almost step for step (segment close, respondInvoked tracking, settlement listeners, post-present re-check), and the DingTalk side reuses the published question-card template plus the existing card client and presenter instead of registering anything new. The new PermissionCardController owns the full lifecycle: reserved → pending → claimed → terminal, with owner-bound callbacks, one-shot forbidden feedback per foreign actor, strict decision parsing (exactly one form field, exactly one advertised value), and a terminal projection reserved before any async settlement so a late card update can't race the text path.

Three things worth a human look, none blocking:

  1. Shared-base semantic change. respondToUserInput now returns false to a second concurrent responder instead of handing back the first responder's promise result. That's more correct (the second response genuinely wasn't the one accepted), but it also changes the existing question-card flow for every channel, not just DingTalk permission cards. Both flows have updated tests pinning the new behavior — flagged because it's the one behavior change that lives outside this feature.
  2. Terminal-state nuance. When a permission settles via a text command while its card is still pending, the card finalizes as expired ("no longer available") rather than approved/denied. Consistent with the PR's stated stale-card semantics, just noting the mapping (resolved_outside_presenter → expired, cancellation reasons → cancelled).
  3. Callback routing order. Permission claims now run before question claims in routeCardCallback; safe because outTrackIds are controller-unique and an unclaimed callback falls through unchanged, but it's a routing change in a hot path.

Everything else follows convention: kebab-case modules, colocated tests, ESM imports, no drive-by changes. The 735 lines of docs are the committed design doc and implementation plans, per repo convention. Tests are substantive — no skips, no weakened assertions; the only removed assertion is the intentional second-responder semantic, replaced by its negation.

sequenceDiagram
    participant P1 as DingTalk user
    participant P2 as DingtalkChannel
    participant P3 as ChannelBase
    participant P4 as PermissionCardController
    participant P5 as DingTalk card API
    participant P6 as Agent bridge
    P3->>P4: present permission context with owner, decisions, onSettled
    P4->>P5: createAndDeliver card
    P1->>P5: clicks allow, deny or cancel
    P5->>P2: card callback
    P2->>P4: claim after owner check and decision parse
    P4->>P3: respond with decision, settles at most once
    P3->>P6: respondToPermission
    P4->>P5: update card to approved, denied, cancelled or expired
Loading
Files changed (23 of 23 shown)
File What changed
docs/design/2026-08-29-dingtalk-permission-cards.md New design doc for the permission card flow
docs/plans/2026-08-29-dingtalk-permission-cards.md New implementation plan
docs/plans/2026-08-31-dingtalk-permission-card-language.md New plan for the language follow-up
packages/channels/base/src/ChannelBase.ts Permission presenter seam, localized copy, settle-once respond change
packages/channels/base/src/ChannelBase.test.ts Tests for the seam, localization, and settlement races
packages/channels/base/src/types.ts New ChannelPermissionDecision and ChannelPermissionRequestContext types
packages/channels/base/src/index.ts Exports the two new types
packages/channels/dingtalk/src/permission-card-controller.ts New card state machine - the core of the feature
packages/channels/dingtalk/src/permission-card-controller.test.ts Lifecycle, race, and security tests for the controller
packages/channels/dingtalk/src/DingtalkAdapter.ts Controller wiring, callback routing order, localized feedback, presenter override
packages/channels/dingtalk/src/DingtalkAdapter.test.ts Routing, gating, and locale feedback tests
packages/channels/dingtalk/src/interaction-presenter.ts presentPermission guard and cancelRun hook
packages/channels/dingtalk/src/interaction-presenter.test.ts Presenter permission path tests
packages/channels/dingtalk/src/interactive-card-types.ts permissionCard config parsing with default and timeout clamp
packages/channels/dingtalk/src/interactive-card-types.test.ts Config parsing tests for permissionCard
packages/channels/dingtalk/src/index.ts permissionCard config descriptor for channel setup
packages/cli/src/commands/channel/runtime.ts resolveChannelLocale helper
packages/cli/src/commands/channel/runtime.test.ts Locale mapping tests
packages/cli/src/commands/channel/start.ts Threads the locale snapshot into channel creation
packages/cli/src/commands/channel/start.test.ts Start passes locale tests
packages/cli/src/commands/channel/daemon-worker.ts Daemon resolves locale from the settings snapshot
packages/cli/src/commands/channel/daemon-worker.test.ts Daemon locale pass-through test
packages/cli/src/serve/routes/workspace-channel-management.test.ts Expects the new permissionCard descriptor entry

Testing evidence (PR's own CI, read via API — PR code was not executed in this run)

The unit gate Test (ubuntu-latest, Node 22.x) is green on the reviewed commit, as are Desktop Shell (both OSes), Integration Tests (no-AK, No Sandbox), Serve A/B, web-shell E2E Smoke, Real daemon E2E, the TruffleHog secret scan, and the dependency CVE audit. Test (macos-latest / windows-latest, Node 22.x) and the sandboxed CLI integration lane are skipped by workflow choice for this run — not failures. No red checks. The still-running review-pr / triage jobs are bot orchestration, not PR CI.

Not verified: real DingTalk delivery. The suite mocks the DingTalk SDK end to end, and the author states plainly that no live delivery with app credentials and a published template was run — that part of the claim rests on the author's word, and no CI here can close it.

Check Conclusion
Test (ubuntu-latest, Node 22.x) ✅ success
Test (macos-latest, Node 22.x) ⏭️ skipped
Test (windows-latest, Node 22.x) ⏭️ skipped
Integration Tests (no-AK, No Sandbox) ✅ success
Integration Tests (CLI, No Sandbox) ⏭️ skipped
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Serve A/B (ubuntu-latest, Node 22.x) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
Real daemon E2E / Java 11 ✅ success
Secret scan (TruffleHog) ✅ success
Dependency CVE audit ✅ success
precheck-pr / precheck ✅ success

Sandboxed verification is now in flight alongside this re-run — the @qwen-code /verify job was triggered with it and its report will post to this thread. It A/Bs the new presentation seam and the settlement-race guards against the base build, settling whether they are load-bearing (the suite only exists with the diff). Live template delivery with real DingTalk credentials stays out of reach of any sandbox lane and still needs a maintainer's real-app pass before merge confidence.

中文说明

代码审查

未发现阻断性问题。实现紧贴现有提问卡片模式——基础渠道中的 tryPresentPermission 几乎逐步复刻 tryPresentUserInput(输出段关闭、respondInvoked 跟踪、结算监听、展示后复查);钉钉侧复用已发布的提问卡片模板和现有卡片客户端与 presenter,没有注册新模板。新的 PermissionCardController 拥有完整生命周期:reserved → pending → claimed → terminal,回调绑定发起人、对外部操作者只发一次越权反馈、决策解析严格(恰好一个表单字段、恰好一个已声明取值),并在任何异步结算前预留终态投影,避免延迟卡片更新与文本路径竞争。

三处值得人工看一眼(均非阻断):

  1. 共享基础层的语义变更。 第二个并发响应者调用 respondToUserInput 现在得到 false,而不是共享首个响应者的 promise 结果。这更正确(第二个响应确实不是被接受的那个),但同时也改变了所有渠道现有的提问卡片流程,不限于钉钉权限卡片。两条流程都有更新后的测试钉住新行为——因为它是唯一落在本特性之外的行为变更,特此标出。
  2. 终态细节。 卡片仍待处理时权限被文本命令结算的,卡片终态为 expired("不再可用")而非已批准/已拒绝。与 PR 声明的过期卡片语义一致,仅说明映射关系(resolved_outside_presenter → expired,取消类原因 → cancelled)。
  3. 回调路由顺序。 权限 claim 现在先于提问 claim 执行;因为 outTrackId 在各控制器间唯一、未认领的回调原样穿透,所以安全,但这是热路径上的路由变更。

其余均符合约定:kebab-case 模块、同目录测试、ESM 导入、无顺手改动。735 行文档是按仓库约定提交的设计文档与实现计划。测试扎实——无 skip、无削弱断言;唯一被移除的断言是刻意的"第二响应者"语义变更,并已替换为其反向断言。

测试证据(读取 PR 自身 CI,本次运行未执行 PR 代码)

单元门槛 Test (ubuntu-latest, Node 22.x) 在被审提交上为绿;Desktop Shell(双平台)、Integration Tests (no-AK, No Sandbox)Serve A/Bweb-shell E2E SmokeReal daemon E2E、TruffleHog 密钥扫描、依赖 CVE 审计均为绿。Test (macos-latest / windows-latest, Node 22.x) 与沙箱化 CLI 集成通道在本次运行中被工作流跳过——不是失败。无红色检查。仍在运行的 review-pr / triage 是机器人编排任务,不是 PR CI。

未验证:真实钉钉投递。测试套件对钉钉 SDK 做了端到端 mock,作者也明确说明未使用应用凭证和已发布模板进行真实投递——该部分依赖作者陈述,本次 CI 无法补齐。

沙箱验证已随本次重跑启动——@qwen-code /verify 任务已触发,报告将发布到本线程。它会对 base 构建做 A/B,验证新的展示接缝与结算竞态守卫是否"承重"(该套测试只随 diff 存在)。但使用真实钉钉凭证的模板投递仍超出任何沙箱通道的能力,合并前仍需维护者在真实应用上验证一次。

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — clean static review and genuinely good test coverage, but the core-module size escalation needs a maintainer's sign-off, and real DingTalk delivery was never observed.

Stepping back: this is a well-built PR. My independent sketch of the feature — a presenter seam in the base channel, one idempotent settlement shared by card, text, and timeout paths, and a DingTalk controller reusing the existing card infrastructure — is essentially what landed, and the implementation mirrors the established question-card pattern closely enough that a maintainer familiar with that flow will find no surprises. The settlement-race tests are the kind that would actually catch regressions, and the security edges (foreign actors, malformed payloads, stale cards) each have a dedicated test rather than a hopeful assertion.

Re-run note (same commit 8a96a48a; this pass was re-triggered by a maintainer): nothing on the diff, tests, or CI changed my reading. One new development — a sandboxed /verify run is now in flight alongside this triage pass and will post its A/B report to this thread; it settles part of the second concern below, though not the real-DingTalk delivery part.

Two things keep this at a defer instead of an approval. First, policy: ~826 production lines spanning packages/channels/** and packages/cli/**, including a semantic change to the shared ChannelBase respond path that touches every channel, crosses the size bar that asks for maintainer awareness on a fork-originated feature. Second, substance: the central user-visible claim — a native permission card actually rendering and settling in DingTalk — has never been run against the real service; the SDK is mocked end to end. Neither is a defect in the code; both are reasons a human should own the merge call.

Not requesting changes — I found nothing that warrants them.

中文说明

置信度:3/5 —— 静态审查干净、测试覆盖扎实,但核心模块规模升级需要维护者签核,且真实钉钉投递从未被观测到。

退一步看:这是一个构建良好的 PR。我对该特性的独立设想——基础渠道中的 presenter 接缝、卡片/文本/超时路径共享的唯一幂等结算、复用现有卡片基础设施的钉钉控制器——基本就是落地的方案;实现与既有提问卡片模式高度一致,熟悉该流程的维护者不会感到意外。结算竞态测试是真正能抓到回归的那种;安全边界(外部操作者、畸形载荷、过期卡片)各有专门测试,而不是侥幸断言。

重跑说明(同一提交 8a96a48a,由维护者重新触发):diff、测试与 CI 均未变化,我的判断不变。一个新进展——沙箱 /verify 运行已随本次 triage 启动,其 A/B 报告将发布到本线程;它能补齐下述第二点顾虑的一部分,但无法覆盖真实钉钉投递部分。

有两点使我选择暂缓而非批准。其一是政策:约 826 行生产代码横跨 packages/channels/**packages/cli/**,且包含影响所有渠道的共享 ChannelBase 响应路径语义变更,超过了要求维护者关注来自 fork 的特性 PR 的规模线。其二是实质:核心的用户可见主张——原生权限卡片在钉钉中真实渲染并结算——从未在真实服务上运行过;SDK 被端到端 mock。两者都不是代码缺陷,但都构成由人类来做合并决定的理由。

不请求修改——我没有发现值得请求修改的问题。

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

⏸️ Deferring to @wenshao — needs a human call on this one.

Why this wasn't auto-approved even though the review itself is clean:

  1. Core-module size escalation (policy). ~826 production logic lines span packages/channels/** and packages/cli/**, and the change includes a semantic update to the shared ChannelBase respond path that every channel inherits. A fork-originated feat touching shared infrastructure at this size is escalated for maintainer awareness rather than auto-approved.
  2. The central behavioral claim is unobserved. Native permission-card delivery and settlement have never run against the real DingTalk service — the SDK is mocked end to end in the suite, and no sandbox lane can substitute for a pass with real app credentials and a published template.

Nothing here is a request for changes from the author — the code quality is high and the tests are substantive. What's needed is a maintainer to (a) own the sign-off on the shared-base change, and (b) arrange or perform one real DingTalk verification pass.

Update (re-run on the same commit): this pass was re-triggered by @wenshao, so the defer is now addressed to you directly. Nothing in the review changed — the diff, findings, and green CI are exactly as documented above, and a sandboxed /verify run is in flight and will post its report here. What still needs a human call: (a) sign off on the shared-base change, and (b) arrange or perform one real DingTalk delivery pass.

中文说明

⏸️ 转交 @wenshao 处理——这一单需要人来拍板。

审查本身干净却未自动批准的原因:

  1. 核心模块规模升级(政策)。 约 826 行生产逻辑横跨 packages/channels/**packages/cli/**,且包含所有渠道继承的共享 ChannelBase 响应路径的语义变更。来自 fork 的 feat 在此规模触及共享基础设施时,按规则升级为维护者关注而非自动批准。
  2. 核心行为主张未被观测。 原生权限卡片的投递与结算从未在真实钉钉服务上运行——测试套件对 SDK 做了端到端 mock,任何沙箱通道都无法替代使用真实应用凭证和已发布模板的一次验证。

这不是对作者的修改请求——代码质量高、测试扎实。需要的只是维护者:(a)对共享基础层变更签核;(b)安排或执行一次真实钉钉验证。

更新(同一提交上的重跑):本次由 @wenshao 重新触发,暂缓说明现直接 @ 你。审查结论没有变化——diff、发现与全绿的 CI 与上文完全一致,沙箱 /verify 运行正在进行,其报告将发布在这里。仍需人来拍板:(a)对共享基础层变更签核;(b)安排或执行一次真实钉钉投递验证。

Qwen Code · 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.

Partially reviewed — gaps disclosed. Suggestions are inline.

Unresolved, please confirm:

  • [Critical] Promoted blocker from the autofix bot's issue-level comment (comment 5491209528): the review-address verification gate's packages/cli test leg reportedly fails for environmental reasons — RUNNER_NAME is absent from the gate's env -i clean-c…

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

中文说明

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

未决,请确认:共 1 条(原文未翻译,列表见上方英文部分)。

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

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

Comment on lines +41 to +43
channel.permissionPresentation = { kind: 'presented' };
await channel.dispatchPermissionRequest(permissionRequest());
expect(

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-11: Both committed plan documents landed as 'reconciled' records, but they contain five snippets that contradict the shipped implementation; a follow-up agent executing these plans task-by-task would copy code that no longer reproduces the suite. (1) This doc's Task 1 Step 1 uses harness properties that do not exist — channel.permissionPresentation / channel.presentedPermission (shipped: permissionPresentationResult / permissionPresentations, ChannelBase.test.ts:101-104) — asserts toContain on the {chatId, text} record instead of sent.at(-1)?.text, and calls a nonexistent permissionRequest() helper (shipped: emitPermission). (2) Its Task 1 Step 4 race snippet calls channel.receive('/deny'), an API that exists nowhere (shipped race test drives handleInbound(envelope(...)), ChannelBase.ts:5860, test :2025-2031). (3) Its Task 3 Step 4 wiring snippet omits the two target-match clauses shipped presentPermission enforces (interaction-presenter.ts:295-296), contradicting this plan's own Task 3 Step 3, which instructs a target-mismatch test. (4) docs/plans/2026-08-31-dingtalk-permission-card-language.md Task 2 Step 3 PERMISSION_COPY snippet uses a command: key the shipped constant does not have (ChannelBase.ts:255-284 uses request/tool/action/parameters). (5) Its Task 3 Step 3 PERMISSION_CARD_COPY snippet shows required: and two-element array terminals where the shipped constant uses title: and { description, button } objects (permission-card-controller.ts:48-72; consumers at :279-280/:348 read exactly that shape). Please reconcile all five snippets to the shipped shapes.

Witness:

not run — docs-vs-code contradictions settled by reading both sites at the reviewed commit (no run capability applies to prose snippets); e.g. plan line 79 `channel.receive('/deny')` vs shipped `handleInbound` (ChannelBase.ts:5860), and the 2026-08-31 snippet `required:` vs shipped `title:` (permission-card-controller.ts:48-72)
中文说明

两份计划文档都以「已对齐」记录的形式入库,但包含五处与最终实现矛盾的代码片段;后续按任务执行这些计划的 agent 会复制到无法复现测试套件的代码。(1) 本文档 Task 1 Step 1 使用了不存在的 harness 属性——channel.permissionPresentation / channel.presentedPermission(最终实现为 permissionPresentationResult / permissionPresentations,ChannelBase.test.ts:101-104)——对 {chatId, text} 记录而非 sent.at(-1)?.text 断言 toContain,并调用不存在的 permissionRequest() 辅助函数(最终实现为 emitPermission)。(2) 其 Task 1 Step 4 竞态片段调用 channel.receive('/deny')——该 API 在任何地方都不存在(最终竞态测试走 handleInbound(envelope(...)),ChannelBase.ts:5860,测试 :2025-2031)。(3) 其 Task 3 Step 4 接线片段缺少最终 presentPermission 强制的两个目标匹配子句(interaction-presenter.ts:295-296),与本计划自己的 Task 3 Step 3(要求编写目标不匹配测试)矛盾。(4) docs/plans/2026-08-31-dingtalk-permission-card-language.md Task 2 Step 3 的 PERMISSION_COPY 片段使用了最终常量没有的 command: 键(ChannelBase.ts:255-284 使用 request/tool/action/parameters)。(5) 其 Task 3 Step 3 的 PERMISSION_CARD_COPY 片段使用 required: 与二元数组终态,而最终常量使用 title:{ description, button } 对象(permission-card-controller.ts:48-72;消费者 :279-280/:348 正是读取该形态)。请将五处片段全部对齐到最终形态。

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

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.

Still deferred: the current round is a same-run verification repair limited to one commit (reverting the rejected packages/cli/vitest.config.ts change), so no finding batch was implemented. The five plan-document snippets remain queued for reconciliation with the shipped shapes in the next round.

继续顺延:本轮为同轮验证修复,仅限单个提交(回退被拒的 packages/cli/vitest.config.ts 改动),未实施任何发现批次。五处计划文档片段与最终实现的对齐排入下一轮。

Comment on lines +146 to +148
protected async presentPermissionRequest(
context: ChannelPermissionRequestContext,
): Promise<UserInputPresentationResult> {

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-27: The permission-side onSettled closure built in tryPresentPermission (ChannelBase.ts:890-903) — a duplicate of the user-input closure, not shared code — has no witness anywhere: all nine onSettled registrations in this file run on userInputPresentations contexts, and every controller/adapter test injects a hand-rolled fake. Dropping settlementListeners.add from the permission-side closure keeps the suite green, but external settlement (owner /approve text, daemon permissionResolved, run cancellation) never reaches PermissionCardController — the card stays pending and tappable until the 270 s timeout auto-denies or the next tap projects a stale state. Please add a case: presented permission, register context.onSettled(spy), settle externally through ChannelBase's real path (handleInbound owner /approve <requestId>, or emit permissionResolved), and assert the spy fires with the settlement reason.

Witness:

mutant (permission-side closure drops `settlementListeners.add`): full suite 642/642 green; probe red — `expected "spy" to be called once, but got 0 times`; intact 643/643 green

Acceptance criterion: removing pending.settlementListeners.add(listener) from the permission-side closure must turn the new test red (the existing user-input settlement tests pin the separate closure in tryPresentUserInput). Settle through ChannelBase's real path — settleUserInput guards if (pending.settled) return; (ChannelBase.ts:2946-2952) — rather than re-simulating controller internals.

中文说明

tryPresentPermission 中构建的权限侧 onSettled 闭包(ChannelBase.ts:890-903)——是用户输入闭包的复制品而非共享代码——在任何地方都没有见证:本文件全部九个 onSettled 注册都运行在 userInputPresentations 上下文上,所有控制器/适配器测试注入的都是手工伪造品。从权限侧闭包中删除 settlementListeners.add 后套件仍绿,但外部结算(发起人 /approve 文本、daemon permissionResolved、运行取消)永远到达不了 PermissionCardController——卡片保持待处理且可点击,直到 270 秒超时自动拒绝或下一次点击投影过期状态。请增加用例:展示权限后注册 context.onSettled(spy),通过 ChannelBase 真实路径外部结算(handleInbound 发起人 /approve <requestId>,或发出 permissionResolved),断言 spy 以结算原因被触发。

验收标准:从权限侧闭包移除 pending.settlementListeners.add(listener) 必须使新测试变红(现有用户输入结算测试固定的是 tryPresentUserInput 中的另一个闭包)。请通过 ChannelBase 真实路径结算——settleUserInputif (pending.settled) return; 守卫(ChannelBase.ts:2946-2952)——而不要重新模拟控制器内部。

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

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.

Still deferred: the current round is a same-run verification repair limited to one commit (reverting the rejected packages/cli/vitest.config.ts change). The permission-side onSettled settlement witness stays queued for the next round.

继续顺延:本轮为同轮验证修复,仅限单个提交(回退被拒的 packages/cli/vitest.config.ts 改动)。权限侧 onSettled 结算见证排入下一轮。

Comment thread packages/channels/base/src/ChannelBase.test.ts
Comment on lines +1946 to +1947
expect(ch.sent[0]!.text).toContain('运行工具需要授权');
expect(ch.sent[0]!.text).toContain('操作:');

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-34: The zh PERMISSION_COPY keys tool (工具:), parameters (参数:), and request (请求:) have zero witnesses — grep finds none of them in ChannelBase.test.ts — while their English counterparts are pinned (Tool: shell, Parameters: unknown, Request: req-123). Corrupting any of the three keeps the suite green, and every zh channel on the text-fallback path (every non-DingTalk channel via the base default; DingTalk with permissionCard.enabled: false) renders a mixed-language fallback message. The comparator confirms the suite can catch zh-copy corruption (corrupting action: fails the zh test) — it simply never looks at these three keys, and the probe output shows the existing fixture renders both lines. Please assert 工具: and 参数: in uses Chinese text when native permission presentation is unsupported.

Witness:

mutant (zh `request`/`tool`/`parameters` set to English): full ChannelBase.test.ts 642/642 green; comparator — corrupting zh `action` makes the zh fallback test fail, so the suite can catch zh-copy corruption but never looks at these three keys; probe output shows both lines render (`工具: run_shell_command`, `参数: command`)

Acceptance criterion: corrupting the zh tool or parameters values in PERMISSION_COPY must turn the new assertions red. The Parameters line renders only when the summary is non-empty — ...(parameters ? [...] : []) (ChannelBase.ts:3078) — so keep a rawInput that yields a non-empty permissionParameterSummary.

中文说明

zh PERMISSION_COPYtool工具:)、parameters参数:)、request请求:)三个键零见证——在 ChannelBase.test.ts 中 grep 均无结果——而英文对应项已被固定(Tool: shellParameters: unknownRequest: req-123)。破坏三者中任何一个套件仍绿,而所有走文本回退的中文渠道(基类默认下的所有非钉钉渠道;permissionCard.enabled: false 的钉钉)都会渲染中英混杂的回退消息。对照实验证实套件能捕获 zh 文案破坏(破坏 action: 会使 zh 测试失败)——只是从未检查这三个键,且探针输出显示既有 fixture 会渲染这两行。请在 uses Chinese text when native permission presentation is unsupported 中断言 工具:参数:

验收标准:破坏 PERMISSION_COPY 中 zh 的 toolparameters 值必须使新断言变红。Parameters 行仅在摘要非空时渲染——...(parameters ? [...] : [])(ChannelBase.ts:3078)——因此请保留能产生非空 permissionParameterSummary 的 rawInput。

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

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.

Still deferred: the current round is a same-run verification repair limited to one commit (reverting the rejected packages/cli/vitest.config.ts change). The zh tool/parameters/request copy assertions stay queued for the next round.

继续顺延:本轮为同轮验证修复,仅限单个提交(回退被拒的 packages/cli/vitest.config.ts 改动)。zh tool/parameters/request 文案断言排入下一轮。

Comment on lines +1958 to +1961
ch.permissionPresentationHandler = async (context) => {
await context.respond('allow_always');
return { kind: 'handled' };
};

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-26: The presentation contract's success branch — { kind: 'handled' } after a successful context.respond() (result.kind === 'presented' || (result.kind === 'handled' && respondInvoked), ChannelBase.ts:927) — has zero witnesses; the only permission handled test keeps respondInvoked false by responding with an unadvertised decision. Narrowing the condition to result.kind === 'presented' keeps the suite green, but an adapter that settles the request inside presentPermissionRequest (a fire-and-forget respond in flight) and returns { kind: 'handled' } then falls through to text dispatch and sends a /approve prompt for a request that was already approved. Please add a case whose handler fires void context.respond('allow_once') (an advertised decision, left in flight) and returns { kind: 'handled' }; assert respondToPermission is called once with the proceed_once outcome and no fallback text is sent.

Witness:

mutant (condition narrowed to `result.kind === 'presented'`): full suite 642/642 green; probe red — the `/approve … Allow once` fallback text was sent for the already-responded request; intact 643/643 green

Acceptance criterion: narrowing the condition at ChannelBase.ts:927 to result.kind === 'presented' must turn the new test red. The test must respond with an advertised decision — respondInvoked is only set when permissionPresentationResponse produced a real response (ChannelBase.ts:899-906) — and leave the respond in flight, since an awaited-and-accepted respond removes the pending record.

中文说明

展示契约的成功分支——成功 context.respond() 之后返回 { kind: 'handled' }result.kind === 'presented' || (result.kind === 'handled' && respondInvoked),ChannelBase.ts:927)——零见证;唯一的权限 handled 测试通过以未提供的决策应答使 respondInvoked 保持 false。把条件收窄为 result.kind === 'presented' 后套件仍绿,但在 presentPermissionRequest 内部结算请求(进行中的即发即忘 respond)并返回 { kind: 'handled' } 的适配器会落入文本分发,为已批准的请求再发送 /approve 提示。请增加用例:handler 以 void context.respond('allow_once')(已提供决策、保持进行中)应答并返回 { kind: 'handled' };断言 respondToPermissionproceed_once 结果被调用一次且未发送回退文本。

验收标准:把 ChannelBase.ts:927 的条件收窄为 result.kind === 'presented' 必须使新测试变红。测试必须以已提供的决策应答——respondInvoked 只在 permissionPresentationResponse 产生真实应答时置位(ChannelBase.ts:899-906)——并保持应答进行中,因为被等待且接受的应答会移除待处理记录。

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

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.

Still deferred: the current round is a same-run verification repair limited to one commit (reverting the rejected packages/cli/vitest.config.ts change). The handled-after-respond success-branch witness stays queued for the next round.

继续顺延:本轮为同轮验证修复,仅限单个提交(回退被拒的 packages/cli/vitest.config.ts 改动)。respond 后返回 handled 的成功分支见证排入下一轮。

Comment thread packages/channels/dingtalk/src/permission-card-controller.test.ts
Comment on lines +312 to +314
it('expires and denies a timed-out permission', async () => {
vi.useFakeTimers();
const { client, controller } = createHarness(1_000);

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-31: expire()'s if (record.state !== 'pending') return; guard — the only thing stopping the timeout from double-settling an already-claimed card — has no witness: both timeout tests fire only on unclaimed records, and claim() leaves the timer armed (the only clearTimeout is in removeRecord). Removing the guard keeps the suite green, but a timeout firing while the owner's accepted respond() is in flight issues a second respond('deny') and finalizes 'expired' — the approval still settles, so the card shows 'no longer available' while the tool runs. Please add a case: fake timers, createHarness(1_000), a never-resolving respond; present, claim 'allow_once', run execute, advance 1 000 ms; assert respond was called exactly once with 'allow_once' and updateInstance was not called.

Witness:

mutant (expire state guard removed): all 334 dingtalk tests stay green; probe red — `expected "spy" to be called 1 times, but got 2 times` (respond called `'allow_once'` then `'deny'`); intact green

Acceptance criterion: deleting if (record.state !== 'pending') return; from expire must turn the new test red. Do not resolve the held respond before asserting — the only clearTimeout is inside removeRecord (permission-card-controller.ts:253), so the record must stay claimed-but-not-finalized when timers advance.

中文说明

expire()if (record.state !== 'pending') return; 守卫——阻止超时对已 claim 卡片二次结算的唯一屏障——没有见证:两个超时测试都只对未 claim 的记录触发,且 claim() 不解除定时器(唯一的 clearTimeoutremoveRecord 中)。删除该守卫后套件仍绿,但当发起人已接受的 respond() 进行中时超时触发,会发出第二个 respond('deny') 并终态化为 'expired'——批准照常结算,于是工具运行时卡片显示「已失效」。请增加用例:假定时器、createHarness(1_000)、永不解析的 respond;展示、claim 'allow_once'、执行 execute、推进 1 000 毫秒;断言 respond 恰好以 'allow_once' 被调用一次且 updateInstance 未被调用。

验收标准:从 expire 删除 if (record.state !== 'pending') return; 必须使新测试变红。断言前不要解析挂起的 respond——唯一的 clearTimeoutremoveRecord 内(permission-card-controller.ts:253),因此推进定时器时记录必须保持已 claim 未终态。

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

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.

Still deferred: the current round is a same-run verification repair limited to one commit (reverting the rejected packages/cli/vitest.config.ts change). The expire state-guard witness stays queued for the next round.

继续顺延:本轮为同轮验证修复,仅限单个提交(回退被拒的 packages/cli/vitest.config.ts 改动)。expire 状态守卫见证排入下一轮。

Comment on lines +320 to +325
expect(respond).toHaveBeenCalledWith('deny');
expect(client.updateInstance).toHaveBeenCalledWith(
expect.objectContaining({
cardParamMap: expect.objectContaining({ card_status: 'expired' }),
}),
);

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-23: No permission-card test ever rejects client.updateInstance, so the design promise that failed card updates never reopen or retry a settled permission rests solely on the try/catch in projectTerminal (permission-card-controller.ts:300-305), which has zero witnesses — while both sibling controllers test this exact seam. Removing the catch turns a card-update API failure during terminal projection into an unhandled promise rejection in the daemon worker instead of an onError log. Please add a case mirroring 'expires and denies a timed-out permission' with updateInstance mocked to reject before the terminal event; assert the controller still settles (respond with 'deny'), the record is removed (a follow-up claim returns { kind: 'ignored' }), and a harness-supplied onError spy receives 'permission card finalization'.

Witness:

mutant (try/catch removed): probe red — `expected "spy" to be called with arguments` and vitest reported `Unhandled Rejection ⎯⎯ Error: card update failed`; shipped 20-test suite green; intact probe: `respond` still `'deny'`, `onError` called with `'permission card finalization'`

Acceptance criterion: deleting the try/catch in projectTerminal must turn the new test red. Note finalize removes the record before projecting (permission-card-controller.ts:246-248), so the follow-up-claim-is-ignored assertion holds regardless of the projection failure — do not assert that a failed projection keeps the record claimable.

中文说明

没有任何权限卡片测试拒绝 client.updateInstance,因此「卡片更新失败不会重开或重试已结算权限」的设计承诺完全依赖 projectTerminal 中的 try/catch(permission-card-controller.ts:300-305),而它零见证——两个兄弟控制器都测试了这一接缝。删除该 catch 会把终态投影期间的卡片更新 API 失败变成 daemon worker 中的未处理 promise 拒绝,而不是 onError 日志。请仿照 'expires and denies a timed-out permission' 增加用例:在终态事件前把 updateInstance mock 为拒绝;断言控制器仍结算(respond 收到 'deny')、记录被移除(后续 claim 返回 { kind: 'ignored' })、harness 提供的 onError spy 收到 'permission card finalization'

验收标准:删除 projectTerminal 中的 try/catch 必须使新测试变红。注意 finalize 在投影前移除记录(permission-card-controller.ts:246-248),因此「后续 claim 被忽略」的断言与投影失败与否无关——不要断言失败的投影会使记录保持可 claim。

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

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.

Still deferred: the current round is a same-run verification repair limited to one commit (reverting the rejected packages/cli/vitest.config.ts change). The updateInstance rejection witness stays queued for the next round.

继续顺延:本轮为同轮验证修复,仅限单个提交(回退被拒的 packages/cli/vitest.config.ts 改动)。updateInstance 拒绝见证排入下一轮。

Comment on lines +350 to +354
settle('resolved_outside_presenter');
await vi.waitFor(() =>
expect(client.updateInstance).toHaveBeenCalledWith({
outTrackId,
cardParamMap: expect.objectContaining({ card_status: 'expired' }),

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-29: The settlement listener's non-resolved branch (reason === 'resolved_outside_presenter' ? 'expired' : 'cancelled', permission-card-controller.ts:106) is never exercised: every settle() call in this file uses 'resolved_outside_presenter', and both 'cancelled' projection tests go through cancelRun, a different code path. ChannelBase genuinely produces the other reasons — a selected reject option maps to 'cancelled' (ChannelBase.ts:2968-2987, owner answering /deny by text while a card is pending) and removePendingPermissionsForSession settles with 'run_cancelled' (ChannelBase.ts:2576, 3765, 6544). Collapsing the ternary to 'expired' keeps the suite green while the card projects 'This permission request is no longer available.' instead of 'Permission request cancelled.' Please add a case settling with 'run_cancelled' (and/or 'cancelled') while pending, asserting card_status: 'cancelled' with the cancelled copy and respond not called.

Witness:

mutant (ternary collapsed to `'expired'`): controller + presenter suites 80/80 green; probe (`settle('run_cancelled')` while pending) red — observed `card_status: "expired"` with `"This permission request is no longer available."` where cancelled expected; intact probe green

Acceptance criterion: replacing the ternary with 'expired' must turn the new test red. Settle before any claim — the listener skips claimed records (if (record.state === 'claimed') return;, permission-card-controller.ts:102).

中文说明

结算监听器的非 resolved 分支(reason === 'resolved_outside_presenter' ? 'expired' : 'cancelled',permission-card-controller.ts:106)从未被执行:本文件所有 settle() 调用都用 'resolved_outside_presenter',两个 'cancelled' 投影测试都经由 cancelRun——另一条代码路径。ChannelBase 确实会产生其他原因——选中拒绝选项映射为 'cancelled'(ChannelBase.ts:2968-2987,卡片待处理时发起人以文本 /deny 应答),removePendingPermissionsForSession'run_cancelled' 结算(ChannelBase.ts:2576、3765、6544)。把三元式坍缩为 'expired' 后套件仍绿,卡片却投影 'This permission request is no longer available.' 而非 'Permission request cancelled.'。请增加待处理状态下以 'run_cancelled'(和/或 'cancelled')结算的用例,断言 card_status: 'cancelled' 与取消文案,且 respond 未被调用。

验收标准:把三元式替换为 'expired' 必须使新测试变红。请在任何 claim 之前结算——监听器会跳过已 claim 的记录(if (record.state === 'claimed') return;,permission-card-controller.ts:102)。

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

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.

Still deferred: the current round is a same-run verification repair limited to one commit (reverting the rejected packages/cli/vitest.config.ts change). The settlement-listener non-resolved branch witness stays queued for the next round.

继续顺延:本轮为同轮验证修复,仅限单个提交(回退被拒的 packages/cli/vitest.config.ts 改动)。结算监听器非 resolved 分支见证排入下一轮。

Comment on lines +387 to +390
it('cancels every pending permission owned by a terminal run', async () => {
const { client, controller } = createHarness();
const first = createContext('permission-1');
const second = createContext('permission-2');

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-37: The per-run scoping of cancelRun (this.pendingByRun.get(runId), permission-card-controller.ts:185) has no behavioural witness: every test in this file uses only the harness-default runId: 'run-1', including the two-card test where both cards share run-1. Widening cancelRun to all runs keeps the suite green, but in production a channel serves multiple attended sessions concurrently — one run terminalizing would finalize another run's card cancelled and remove its record, so that user's later Allow/Deny tap hits claimignored and the permission sits blocked until the 270 s timeout auto-denies. The sibling pins exactly this for question cards (question-card-controller.test.ts:509, 596-610). Please mirror it: present cards for permission-1 (run-1) and permission-2 with second.context.runId = 'run-2', call cancelRun('run-1'), and assert the second card receives no updateInstance and is still claimable.

Witness:

mutant (`cancelRun` widened across all runs via `flatMap((set) => [...set])`, log confirmed `requestIds: ["permission-1","permission-2"]` with runId ignored): 334 tests green; fix-witness probe red — second run's card got a cancelled projection (`expected true to be false`); restored run-scoped code green (21 passed)

Acceptance criterion: mutating cancelRun to cancel across all runs must turn the new test red. Override second.context.runId = 'run-2' — the harness hardcodes runId: 'run-1' (test :28) — and note removeRecord deletes the pendingByRun entry when its set empties (permission-card-controller.ts:262-263), so the run-2 record must remain claimable afterwards.

中文说明

cancelRun 的按运行作用域(this.pendingByRun.get(runId),permission-card-controller.ts:185)没有行为见证:本文件所有测试都只用 harness 默认的 runId: 'run-1',包括两张卡片同属 run-1 的双卡片测试。把 cancelRun 放宽到所有运行后套件仍绿,但生产中一个渠道同时服务多个人值守会话——一个运行终态化会把另一个运行的卡片终态化为 cancelled 并移除其记录,该用户随后的允许/拒绝点击会得到 claimignored,权限被阻塞直到 270 秒超时自动拒绝。兄弟控制器已为问题卡片固定了完全相同的行为(question-card-controller.test.ts:509、596-610)。请仿照:为 permission-1(run-1)与 second.context.runId = 'run-2'permission-2 展示卡片,调用 cancelRun('run-1'),断言第二张卡片未收到 updateInstance 且仍可被 claim。

验收标准:把 cancelRun 突变为跨所有运行取消必须使新测试变红。请覆盖 second.context.runId = 'run-2'——harness 硬编码 runId: 'run-1'(测试 :28)——并注意 removeRecord 在集合清空时删除 pendingByRun 条目(permission-card-controller.ts:262-263),因此之后 run-2 记录必须仍可被 claim。

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

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.

Still deferred: the current round is a same-run verification repair limited to one commit (reverting the rejected packages/cli/vitest.config.ts change). The per-run cancelRun scoping witness stays queued for the next round.

继续顺延:本轮为同轮验证修复,仅限单个提交(回退被拒的 packages/cli/vitest.config.ts 改动)。按运行作用域的 cancelRun 见证排入下一轮。

…gs (QwenLM#10457)

- ChannelBase: translate the stock prefix of scoped always-allow labels
  but keep the command/tool scope suffix (exec/mcp confirmations always
  generate suffixed names, and zh channels dropped them)
- packages/cli: slow-host test budgets now also key on measured host
  saturation (isSlowTestHost), because the review-address verification
  gate re-runs tests through an env -i child that does not carry
  RUNNER_NAME; raise the dev-entrypoint startup budget accordingly
- packages/cli: llm/AppContainer/live-serve tests point HOME at a
  scratch directory; the gate inherits a HOME the test process cannot
  write to, which crashed best-effort ~/.qwen writes

Adds mutation-witnessed tests for permission-card controller guards
(question_id, checkbox payloads, hasBusinessPayload gate, isCancel,
requestId action, per-actor forbidden dedupe) and the zh user-scope
always-allow branch.
@wenshao

wenshao commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

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

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

Scripted assertions: 130 passed · 0 failed · 130 total

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

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

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

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

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

Verification report

PR #10457 — feat(dingtalk): present tool permission requests with native interactive cards

Verdict: findings — 130/130 scripted assertions passed (0 unexpected failures); one Suggestion-level coverage gap and one Nice-to-have observation, neither blocking. Verified head: 8a96a48a551588fdc414795487e52186a4ba300c (merge-ref base 09958e0).

中文摘要
  • 结论: findings。130 条脚本化断言全部按预期通过,无意外失败;发现 1 个 Suggestion 级测试覆盖缺口和 1 个 Nice-to-have 观察,均不阻断合并。
  • A/B 结论: 中心声明被证实为 load-bearing。base(HEAD^1)对 attended 权限请求只发文本命令回退(6/6),head 通过真实卡片客户端向回环 DingTalk API 投递一张原生卡且抑制文本(16/16)。竞态修复 hunk(respondToUserInputawait; return false)单独还原后,两种竞态顺序都复现"双成功":迟到的文本回复收到 "Permission approved."、输掉的卡片被标记 approved;head 则正确告知失败方(见 02-ab-race-head-vs-ctrl.png)。
  • Findings: (F1, Suggestion)canEnvelopeAnswerPendingPermission 中"卡片展示期间仅发起人可用文本命令结算"的新子句在共享会话 scope(single/chat_thread/群 thread)下是唯一防线,但该子句被还原后全部 642 个 ChannelBase 测试仍绿——行为正确(head 经 harness 证实拦截非发起人)却无测试钉住;harness 已提供现成 fixture。(F2, Nice-to-have)卡片只携带标题,文本回退原有的 Tool/Parameters 摘要在卡片路径上不再展示。
  • 未覆盖范围: 真实钉钉投递与已发布模板、逐 commit 归因(shallow checkout)、对当前 main 的试合并、interaction-presenter 的 run/owner 守卫(仅由其自身套件覆盖)、CLI/Web/IDE 其他表面。

Central claim and A/B proof

Central claim: an attended DingTalk tool-permission request is presented as an owner-bound native interactive card whose responses — card callback, owner text command, timeout, cancel, outside settlement — share one settlement path that resolves the original request at most once and moves stale cards to terminal states.

Harness: real compiled ChannelBase (per-build dist/) + real PermissionCardController + real DingtalkInteractiveCardClient talking over real HTTP to a loopback server that encodes DingTalk's response semantics (deliverResults success flags, stringified cardParamMap, access-token header). Only the two external seams (agent bridge, DingTalk peer) are faked. Witness: 01-ab-feature-base-vs-head.png.

cell build observable oracle result
feature-base base HEAD^1 (rebuilt in scratch worktree) one text message Permission required to run a tool + /approve//approve-always//deny; zero card API calls 6/6
feature-head head one createAndDeliver (question template, qwen-permission-* outTrackId, exactly the advertised decisions, original labels), zero text; owner callback → exactly one bridge.respondToPermission with original optionId, card finalized approved once; unadvertised allow_always omitted on second request 16/16
feature-head-zh head, locale: 'zh' card chrome 需要授权/请选择后续操作/`仅允许本次 始终允许此项目

Control hygiene: base dist imports only node builtins outside its own tree; package.json/lockfile untouched by the PR; base worktree node_modules realpath = root (deps unchanged), so the shared root node_modules is a clean control.

Race-fix hunk A/B (commit 8a96a48 "handle permission response races"; control = head with exactly that one hunk reverted, 1 insertion(+), 2 deletions(-)). Witness: 02-ab-race-head-vs-ctrl.png.

race order build bridge settlements loser observes card terminal
card allow_once in flight, then owner text /approve head 1 "Permission request is no longer pending." approved ×1
same control (hunk reverted) 1 "Permission approved." (double success) approved ×1
text /approve in flight, then card allow_once head 1 losing card finalized expired text confirms approval
same control (hunk reverted) 1 losing card claims approved text confirms approval

The hunk is load-bearing in both orderings: without it the loser reports success it did not cause (a wrong answer nobody is told about), while settlement count stays 1 either way — the visible defect is the fabricated confirmation, which the two-cell A/B of the whole PR could not isolate.

Guard matrix (head, witness 03-guard-matrix.png)

cell result
duplicate callback after claim; stale deny after finalize 5/5 ignored, one settlement, card stays approved
foreign group actor 7/7 forbidden once, repeat silently ignored, no settlement, owner settles afterwards (denied)
malformed formData (extra key / two values / unadvertised decision / empty / numeric / missing field / nested non-string) + unrelated actionId + payload-less callback 12/12 ignored, card still pending, valid claim works after
timeout (120 ms) 5/5 card expired, pending denied via cancel optionId, late claim ignored
owner cancel action 3/3 denied once, card cancelled
outside settlement (permissionResolved) 4/4 card expired, late callback ignored, bridge untouched
card delivery failure (deliverResults success:false) 5/5 text fallback delivered, failure surfaced to onError (not swallowed), text approval works after
owner-only text while card pending (per-user scope) 3/3 non-owner text cannot settle, owner can
owner-only text while card pending (shared chat_thread scope) 2/2 at head; on the M6-reverted build the intruder does settle (1/1 expected-fail cell)

Mutation matrix (witness 04-mutation-matrix.png)

Positive controls land in the same file as each mutant and are all killed, proving each runner collects the file it mutates.

mutant pinned suite verdict classification
P0 zh allowOnce copy mangled (ChannelBase.ts) -t permission (73) KILLED (1 failed) control
P1 en card title mangled (controller) controller suite (20) KILLED (1 failed) control
P2 'chinese' match mangled (runtime.ts) runtime suite (27) KILLED (1 failed) control
M1 race hunk reverted -t "native permission response promise" KILLED — fails the intended assertion: expected "Permission request is no longer pending.", received "Permission denied." (log 18-M1-kill-message.log) vacuity check passed
M5 text path no longer routes card-backed perms through respondToUserInput same filter KILLED (1 failed) guard pinned
M6 sender-match clause reverted (!userInputPresented || …) full ChannelBase suite: 642/642 green SURVIVED coverage gap (see F1)
M2 owner binding disabled (controller) controller suite KILLED (1 failed) guard pinned
M3 unadvertised decisions accepted (controller) controller suite KILLED (1 failed) guard pinned
M4 state guard removed, terminal cards re-claimable (controller) controller suite KILLED (3 failed) guard pinned
M7 underscore normalization dropped (zh_CN) runtime suite KILLED (1 failed) guard pinned

M1 and M5 defend different facets (dedup vs routing) and each is killed alone, so no layered-guard masking exists; no combination row needed.

Reviewer Test Plan, per step

  1. One native card instead of text — feature-head 16/16 (sent=0, one createAndDeliver).
  2. allow-once resumes once + card approved; deny once + denied — feature-head, guard-foreign (deny path), race cells (exactly-one settlement).
  3. Persistent choice only when advertised, original scope — feature-head (unadvertised persistent grant omitted); allow_always → proceed_always_project mapping pinned by the suite's "maps native permission decisions to original option ids exactly once" (also the M1 kill vehicle).
  4. Cancel and timeout deny the pending permission and terminalize cards — guard-cancel 3/3, guard-timeout 5/5, race cells show no delayed update can race a text approval (loser gets false, one finalization).
  5. Other member / malformed / duplicate / stale cannot settle — guard-foreign 7/7, guard-malformed 12/12, guard-dup 5/5. Text-command side: owner-only holds at head (3/3 + 2/2) but see F1.
  6. Disabled or failed delivery → text instructions — fallback-delivery-failure 5/5; disabled ≡ presentation unsupported ≡ feature-base text path.
  7. zh_CN vs en/auto at startup, no session lookup — feature-head-zh 7/7, locale probes 23/23 (05-targeted-gates.png), CLI suites pin daemon + start wiring.

Findings

F1 — Suggestion (coverage gap, non-blocking): the owner-only text-command guard is load-bearing but unpinned by any test.
canEnvelopeAnswerPendingPermission (packages/channels/base/src/ChannelBase.ts:3040) adds (!userInputPresented && !permissionPresented) || senderId match. Under per-user session scopes the pre-existing shared-target clause already enforces sender match, so reverting the new clause leaves all 642 ChannelBase tests green (M6). Under shared scopes (single, chat_thread, group thread) the new clause is the only thing stopping a group member from settling the owner's card-backed permission via /approve text: with the clause reverted my harness cell settles the permission from intruder-9 (log 17-owner-only-shared-m6.log, n=1), while head blocks it (log 16-owner-only-shared-head.log). The behavior at head is correct; the gap is that a future revert ships green. Fix is a test, not code: the harness cell owner-only-text-shared-session in perm-harness.mjs is a ready-made fixture (config sessionScope: 'chat_thread', group envelope from a second sender, assert respondToPermission not called, then owner settles).

F2 — Nice-to-have: the card carries less request detail than the text fallback it replaces.
The text fallback shows Tool:/Action:/Parameters: lines; the card's cardParamMap carries only question_desc = <title> plus the decision form (see cardData() in permission-card-controller.ts and the feature-head wire capture). An approver sees the action title but not the parameter summary the text path provided. Deliberate-looking template choice, but for a security-sensitive approval the parameter summary is the part an owner most needs; consider folding permissionParameterSummary into question_desc.

Not covered

  • Real DingTalk delivery with production credentials and a published template (the PR states the same; my loopback peer encodes the documented wire semantics, i.e. this reproduces the wire shape, not DingTalk-side template rendering).
  • Per-commit attribution: checkout is depth-2 (1 commit reachable vs 5 in the metadata snapshot); the aggregate HEAD^1..HEAD diff was verified instead.
  • Trial merge into current main (no network): conflict-freeness of the merge ref was pre-validated by the workflow, not re-measured here.
  • DingtalkInteractionPresenter.presentPermission run/owner/target guards and DingtalkChannel.presentPermissionRequest's cardRuns check: exercised only by the package's own suites (508/508 green); my harness drove the controller directly, which is the seam those guards protect.
  • Full CLI suite: only the four touched/related test files ran (27 + 129 + 15 passed).
  • Live per-session language switching and native permission cards in CLI/Web/IDE/other adapters — out of the PR's stated scope.

Methodology

Environment: CI verify container (node v22.23.2), merge-ref checkout (HEAD = merge, HEAD^1 = base tip, HEAD^2 = verified head). Builds: head dist prebuilt by the workflow; base and control channel-base dists compiled with the repo's own tsc inside scratch worktrees tmp/base-tree (HEAD^1) and tmp/race-ctrl (HEAD + exactly the race hunk reverted, verified by git diff --stat = 1 file, +1/−2). Harness perm-harness.mjs drives the real compiled code; the loopback peer waits for the controller's own state machine (waitForCardPending) before injecting callbacks, closing a client/server ordering race in the harness itself (one early capture showed it; fixed and re-captured — no PR behavior involved). Mutation matrix applied per-hunk to source, ran the pinned suite, git checkout -- after each; tree confirmed clean afterwards. Raw logs in logs/, harness + drivers alongside this report. Assertion convention: each harness ASSERT line, each locale probe, each mutation verdict, and each suite run (counted once) is one scripted assertion; fail counts only unexpected outcomes (expected control/mutant behaviors are encoded as passing expectations).

Flakiness gate log

rounds=5 files=9 skipped=0
file packages/channels/base/src/ChannelBase.test.ts: (cd packages/channels/base) npx --no-install vitest run ./src/ChannelBase.test.ts
file packages/channels/dingtalk/src/DingtalkAdapter.test.ts: (cd packages/channels/dingtalk) npx --no-install vitest run ./src/DingtalkAdapter.test.ts
file packages/channels/dingtalk/src/interaction-presenter.test.ts: (cd packages/channels/dingtalk) npx --no-install vitest run ./src/interaction-presenter.test.ts
file packages/channels/dingtalk/src/interactive-card-types.test.ts: (cd packages/channels/dingtalk) npx --no-install vitest run ./src/interactive-card-types.test.ts
file packages/channels/dingtalk/src/permission-card-controller.test.ts: (cd packages/channels/dingtalk) npx --no-install vitest run ./src/permission-card-controller.test.ts
file packages/cli/src/commands/channel/daemon-worker.test.ts: (cd packages/cli) npx --no-install vitest run ./src/commands/channel/daemon-worker.test.ts
file packages/cli/src/commands/channel/runtime.test.ts: (cd packages/cli) npx --no-install vitest run ./src/commands/channel/runtime.test.ts
file packages/cli/src/commands/channel/start.test.ts: (cd packages/cli) npx --no-install vitest run ./src/commands/channel/start.test.ts
file packages/cli/src/serve/routes/workspace-channel-management.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/routes/workspace-channel-management.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/channels/base/src/ChannelBase.test.ts: PPPPP
  packages/channels/dingtalk/src/DingtalkAdapter.test.ts: PPPPP
  packages/channels/dingtalk/src/interaction-presenter.test.ts: PPPPP
  packages/channels/dingtalk/src/interactive-card-types.test.ts: PPPPP
  packages/channels/dingtalk/src/permission-card-controller.test.ts: PPPPP
  packages/cli/src/commands/channel/daemon-worker.test.ts: PPPPP
  packages/cli/src/commands/channel/runtime.test.ts: PPPPP
  packages/cli/src/commands/channel/start.test.ts: PPPPP
  packages/cli/src/serve/routes/workspace-channel-management.test.ts: PPPPP

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

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/channels/base/src/ChannelBase.test.ts: P (exit 0)
round 1 · packages/channels/dingtalk/src/DingtalkAdapter.test.ts: P (exit 0)
round 1 · packages/channels/dingtalk/src/interaction-presenter.test.ts: P (exit 0)
round 1 · packages/channels/dingtalk/src/interactive-card-types.test.ts: P (exit 0)
round 1 · packages/channels/dingtalk/src/permission-card-controller.test.ts: P (exit 0)
round 1 · packages/cli/src/commands/channel/daemon-worker.test.ts: P (exit 0)
round 1 · packages/cli/src/commands/channel/runtime.test.ts: P (exit 0)
round 1 · packages/cli/src/commands/channel/start.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/routes/workspace-channel-management.test.ts: P (exit 0)
round 2 · packages/channels/base/src/ChannelBase.test.ts: P (exit 0)
round 2 · packages/channels/dingtalk/src/DingtalkAdapter.test.ts: P (exit 0)
round 2 · packages/channels/dingtalk/src/interaction-presenter.test.ts: P (exit 0)
round 2 · packages/channels/dingtalk/src/interactive-card-types.test.ts: P (exit 0)
round 2 · packages/channels/dingtalk/src/permission-card-controller.test.ts: P (exit 0)
round 2 · packages/cli/src/commands/channel/daemon-worker.test.ts: P (exit 0)
round 2 · packages/cli/src/commands/channel/runtime.test.ts: P (exit 0)
round 2 · packages/cli/src/commands/channel/start.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/routes/workspace-channel-management.test.ts: P (exit 0)
round 3 · packages/channels/base/src/ChannelBase.test.ts: P (exit 0)
round 3 · packages/channels/dingtalk/src/DingtalkAdapter.test.ts: P (exit 0)
round 3 · packages/channels/dingtalk/src/interaction-presenter.test.ts: P (exit 0)
round 3 · packages/channels/dingtalk/src/interactive-card-types.test.ts: P (exit 0)
round 3 · packages/channels/dingtalk/src/permission-card-controller.test.ts: P (exit 0)
round 3 · packages/cli/src/commands/channel/daemon-worker.test.ts: P (exit 0)
round 3 · packages/cli/src/commands/channel/runtime.test.ts: P (exit 0)
round 3 · packages/cli/src/commands/channel/start.test.ts: P (exit 0)
round 3 · packages/cli/src/serve/routes/workspace-channel-management.test.ts: P (exit 0)
round 4 · packages/channels/base/src/ChannelBase.test.ts: P (exit 0)
round 4 · packages/channels/dingtalk/src/DingtalkAdapter.test.ts: P (exit 0)
round 4 · packages/channels/dingtalk/src/interaction-presenter.test.ts: P (exit 0)
round 4 · packages/channels/dingtalk/src/interactive-card-types.test.ts: P (exit 0)
round 4 · packages/channels/dingtalk/src/permission-card-controller.test.ts: P (exit 0)
round 4 · packages/cli/src/commands/channel/daemon-worker.test.ts: P (exit 0)
round 4 · packages/cli/src/commands/channel/runtime.test.ts: P (exit 0)
round 4 · packages/cli/src/commands/channel/start.test.ts: P (exit 0)
round 4 · packages/cli/src/serve/routes/workspace-channel-management.test.ts: P (exit 0)
round 5 · packages/channels/base/src/ChannelBase.test.ts: P (exit 0)
round 5 · packages/channels/dingtalk/src/DingtalkAdapter.test.ts: P (exit 0)
round 5 · packages/channels/dingtalk/src/interaction-presenter.test.ts: P (exit 0)
round 5 · packages/channels/dingtalk/src/interactive-card-types.test.ts: P (exit 0)
round 5 · packages/channels/dingtalk/src/permission-card-controller.test.ts: P (exit 0)
round 5 · packages/cli/src/commands/channel/daemon-worker.test.ts: P (exit 0)
round 5 · packages/cli/src/commands/channel/runtime.test.ts: P (exit 0)
round 5 · packages/cli/src/commands/channel/start.test.ts: P (exit 0)
round 5 · packages/cli/src/serve/routes/workspace-channel-management.test.ts: P (exit 0)

Evidence images

01-ab-feature-base-vs-head

02-ab-race-head-vs-ctrl

03-guard-matrix

04-mutation-matrix

05-targeted-gates

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

Qwen Code · sandboxed verification

The verification gate rejected the previous commit because
packages/cli/vitest.config.ts is test-config machinery this PR never
touched. Restore that file exactly to its pre-round state; the rest of
the rejected commit (the zh always-allow scope-suffix fix and the
per-file test accommodations) is preserved.

The environmental gate-leg blocker the reverted change tried to work
around — the gate's env -i clean child stripping RUNNER_NAME and
QWEN_HOME while passing through a HOME the tests cannot write to — is
escalated to the maintainer in the round summary instead.
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot's only review on 8a96a48a551588fdc414795487e52186a4ba300c is a COMMENTED one, which carries no vote — so it has no verdict of its own on this commit, and main needs two approving reviews: an approval left by another account is a separate vote and does not count as the bot's own. Two different things look like this, and the stage-3 comment above says which: the triage skill deferring on purpose at 3/5 — a fork refactor hitting the approval guardrail, or a core change escalated for maintainer awareness, both normal outcomes — or an earlier approval that a push dismissed, leaving only the comment behind, which needs a fresh review.

⚠️ 机器人在 8a96a48a551588fdc414795487e52186a4ba300c 上唯一的评审是 COMMENTED不带票 —— 因此它在该 commit 上没有自己的裁决,而 main 需要两个批准(其他账号的批准是另一张票)。有两种情况长这样,上方的 stage-3 评论会说明是哪一种:triage skill 在 3/5 时有意 defer(fork refactor 命中审批护栏,或核心改动被升级交由维护者把关,两者都是正常结果);或者更早的批准被一次推送作废、只剩下这条评论,此时需要重新评审。

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

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

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Autofix review round — same-run verification repair (PR #10457)

Round summary: this is a same-run verification repair round. The previous
commit was rejected by the deterministic gate because it modified
packages/cli/vitest.config.ts — test-config machinery in an area the PR
itself never touched. Per the repair directive, the rejected commit
(d8d018d8) is preserved and this round adds exactly one follow-up commit
(d4fc363e) that reverts that file to its exact pre-round state. The
environmental gate-leg blocker the reverted change tried to work around is
escalated to the maintainer below (it cannot be fixed inside this PR without a
maintainer decision). No other findings were implemented this round; the
per-round batch is fully consumed by the repair.

Feedback points and decisions

Deterministic rejection — round expands into test-config machinery — FIXED

The gate rejected the previous round with:
"This round modified CI/verification machinery in area(s) the PR itself never
touched: test-config packages/cli/vitest.config.ts. … Revert these files; if
the feedback genuinely requires them, escalate it to a maintainer as an open
question instead of implementing it."

Fixed: packages/cli/vitest.config.ts is restored byte-for-byte to its
pre-round state (git diff 8a96a48a55 -- packages/cli/vitest.config.ts is
empty), so the round diff
(origin/feat/dingtalk-permission-cards-10388...HEAD) no longer contains any
vitest.config.* file and cannot classify as test-config. Everything else
from the preserved commit stays: the zh always-allow scope-suffix fix
(ChannelBase.ts) and the per-file test accommodations in packages/cli test
files (ordinary test code, not machinery).

[Critical] Promoted blocker — gate test legs fail environmentally (rv:5083609366 / ic:5491209528) — ESCALATED, needs a maintainer decision

This finding genuinely requires changes the bot is not allowed to make, so it
is left UNRESOLVED with this open question, per the gate's own directive.

What is proven (evidence in the workdir):

  1. The gate launches its verification through an env -i clean child whose
    allowlist (qwen-autofix.yml) is PATH, HOME, RUNNER_TEMP, WORKDIR, BRANCH,
    GITHUB_OUTPUT, CI, KISS_AUDIT, FOOTPRINT_ENFORCE. RUNNER_NAME and
    QWEN_HOME are stripped.
  2. packages/cli leg without RUNNER_NAME: reproduced pre-fix on the untouched
    tree as Test Files 10 failed | 51 passed (61) (workdir leg-before3.log).
    Failure classes: Test timed out in 15000ms (global testTimeout ceiling),
    Hook timed out in 10000ms, unwritable-HOME EACCES mkdir ~/.qwen, and
    load-contention assertion flakes (host: 64 cores, load 114–207). The
    previous round fixed this via isSlowTestHost() (RUNNER_NAME prefix OR
    loadavg saturation) in packages/cli/vitest.config.ts — the change the
    gate rejected — plus per-file accommodations; the leg then passed 62/62
    files (workdir leg-final.log).
  3. Post-revert recurrence (this round): the gate-shaped packages/cli leg
    surrogate fails again — fast-path-open.test.ts 4 failures with the exact
    pre-fix signature, and the run aborts on a coverage/.tmp ENOENT race
    (workdir repair-cli-leg.log).
  4. New this round — the same environmental class also breaks the
    packages/channels/base leg.
    Gate-shaped surrogate:
    Test Files 1 failed | 1 passed (2), 6 failures, every one
    EACCES: permission denied, mkdir '/home/github-runner/.qwen/channels/…'
    in PairingStore.ensureDir (workdir repair-channels-legs.log). Root cause:
    with QWEN_HOME stripped, getGlobalQwenDir() (packages/channels/base/src/
    paths.ts) falls back to $HOME/.qwen, and the HOME the gate passes through
    is not writable by the test process. The full channels/base suite passes in
    an environment where QWEN_HOME is set (previous round: 1185/1185), so this
    is purely the stripped-env class, not a code defect.
  5. The channels/dingtalk leg passes gate-shaped (5 files, this round).

Why the bot stops here: the durable fixes live in forbidden areas or need a
policy call. Options for the maintainer:

  • (a) Workflow-side env (recommended, smallest): add RUNNER_NAME and
    QWEN_HOME (pointed at a writable scratch dir, e.g. under RUNNER_TEMP) to
    the gate's env -i allowlist in .github/workflows/qwen-autofix.yml. This
    deterministically fixes the EACCES class for every package and restores the
    existing RUNNER_NAME-keyed slow-host budgets where the variable exists —
    zero PR diff growth. (Note: on hosts where RUNNER_NAME is empty entirely,
    the timeout class remains — see (b).)
  • (b) Authorize the test-config accommodation the gate rejected: the
    loadavg-based isSlowTestHost() fallback in the packages' vitest.config.*
    files. It is the only place a worker-count/timeout policy can cover
    saturated non-ECS hosts (per-file vi.setConfig cannot express
    maxWorkers, and the Session.test.ts load-contention failure needs exactly
    that).
  • (c) Direct the bot to harden individual test files (scratch QWEN_HOME in
    the ChannelBase.test.ts pairing tests; per-file timeout budgets in
    ~5 packages/cli files). In-bounds, but spreads changes across large test
    files unrelated to DingTalk permission cards and still cannot fix the
    maxWorkers contention class — not recommended.

Open question for the maintainer: which of (a)/(b)/(c) do you want for the
gate's clean-child environment? Until then this Critical stays unresolved; the
PR carries no machinery changes.

Findings resolved by the preserved commit — re-verified this round (8)

The preserved rejected commit already implemented these; each was re-verified
against the current tree by reading the code (locations in parentheses), and
each stays green with this round's revert (the revert touches none of these
files):

  • R1-33 (rc:3908905696) zh always-allow labels keep the scope suffix —
    localizedScopedAlwaysLabel (ChannelBase.ts:290), tests assert
    始终允许此项目:git status / 始终允许此用户:run_shell_command for card
    labels and the /approve-always fallback (ChannelBase.test.ts:1903-1998).
  • R1-13 (rc:3908905636) proceed_always_user branch witnessed —
    ChannelBase.test.ts user-scope cases incl. the unsuffixed
    始终允许此用户 case.
  • R1-30 (rc:3908905760) question_id pinned —
    permission-card-controller.test.ts:126.
  • R1-20 (rc:3908905747) checkbox-shaped decision values —
    accepts checkbox-shaped decision submissions
    (permission-card-controller.test.ts:423).
  • R1-21 (rc:3908905753) hasBusinessPayload === false gate —
    permission-card-controller.test.ts:461.
  • R1-22 (rc:3908905793) isCancel disjunct — denies through the template built-in cancel flag alone (permission-card-controller.test.ts:485).
  • R1-9 (rc:3908905784) requestId-shaped action — accepts a submit delivered with the requestId action id (permission-card-controller.test.ts:469).
  • R1-32 (rc:3908905801) per-actor forbidden dedupe — intruder-2 case
    (permission-card-controller.test.ts:277).

All eight are listed in resolved-comments.txt.

Deferred to the next round (22 findings)

R1-11, R1-27, R1-34, R1-26, R1-2, R1-28, R1-12, R1-3, R1-5, R1-1, R1-4, R1-14,
R1-19, R1-6, R1-35, R1-15, R1-8, R1-36, R1-31, R1-23, R1-29, R1-37 — all valid,
none declined; this repair round's single-commit scope leaves no room for a
finding batch. Each has a reply in comment-replies.json and its thread stays
open.

Maintainer-deferral comment (ic:5498743239) — no code action possible

Asks for (a) maintainer sign-off on the shared ChannelBase change and (b) one
real DingTalk verification pass. Both are human actions; carried forward for
the maintainer together with the open question above.

Review gap disclosure

The reviewer noted build-and-test — Integration Tests (CLI, No Sandbox) was
skipped in CI and not run locally. Unchanged this round; flagged for the
maintainer alongside the items above.

Conflict notes

None (--conflict false; the branch already contains current main via the
2026-08-31 merge).

Verification

Commands actually run this round, in order:

  • git restore --source=8a96a48a55 -- packages/cli/vitest.config.ts, then
    git diff 8a96a48a55 -- packages/cli/vitest.config.ts — empty (file
    byte-identical to the pre-round state); round diff verified free of any
    vitest.config.* path.
  • npm run build — passed (exit 0).
  • npm run typecheck — passed (exit 0).
  • npm run lint — passed (exit 0).
  • npx prettier --check packages/cli/vitest.config.ts — clean.
  • Focused vitest, packages/cli src/test-utils/slow-test-host.test.ts
    passed (exit 0); confirms the retained isSlowTestHost module and its
    consumers are unaffected by the revert.
  • Gate-shaped leg surrogate
    env -i PATH HOME CI npm run test --workspace packages/channels/dingtalk --if-present -- --changed origin/main --passWithNoTests
    — passed, 5 files (workdir repair-channels-legs.log).
  • Gate-shaped leg surrogate, same form for packages/channels/base — FAILED,
    environmental: 6 × EACCES mkdir /home/github-runner/.qwen/channels/…
    (QWEN_HOME stripped by the env -i form; full suite is green when QWEN_HOME
    is set). See escalation above (workdir repair-channels-legs.log).
  • Gate-shaped leg surrogate, same form for packages/cli — FAILED,
    environmental: fast-path-open.test.ts 4 failures matching the pre-fix
    signature, run aborted by a coverage/.tmp ENOENT race (workdir
    repair-cli-leg.log). See escalation above.
  • Mutation probes: not applicable — the round's commit is a pure revert adding
    no guard or branch; the reverted guard's witnesses were probed in the
    preserved round and are unaffected by this round.

The two failing legs fail for the stripped-environment classes documented in
the escalation (RUNNER_NAME/QWEN_HOME removal + non-writable passed-through
HOME), not for any code change in this round; the workflow's independent CI
remains the final verification gate.

中文说明

Autofix 评审轮次 — 同轮验证修复(PR #10457

本轮摘要:这是一轮同轮验证修复。上一个提交被确定性门禁拒绝,原因是它修改了
packages/cli/vitest.config.ts——一个本 PR 自身从未触及的领域的 test-config 机制。
按照修复指令,被拒提交(d8d018d8)予以保留,本轮仅追加一个后续提交
d4fc363e),把该文件逐字节恢复到轮前状态。被回退改动试图绕过的门禁测试环节
环境性阻塞项,升级给维护者处理(见下,不经维护者决定无法在本 PR 内修复)。
本轮未实施任何其他发现;每轮批次上限已被修复工作占满。

反馈点与决定

确定性拒绝 — 轮次扩展到 test-config 机制 — 已修复

门禁拒绝上一轮的理由是:「本轮修改了 PR 自身从未触及领域的 CI/验证机制:
test-config packages/cli/vitest.config.ts。……请回退这些文件;若反馈确实需要它们,
请作为开放问题升级给维护者,而不是直接实施。」

已修复:packages/cli/vitest.config.ts 已逐字节恢复到轮前状态
git diff 8a96a48a55 -- packages/cli/vitest.config.ts 为空),因此轮次差异
origin/feat/dingtalk-permission-cards-10388...HEAD)不再包含任何
vitest.config.* 文件,无法再被归类为 test-config。保留提交中的其余内容全部保留:
zh 始终允许范围后缀修复(ChannelBase.ts)以及 packages/cli 测试文件中的按文件
测试适配(普通测试代码,非机制)。

[Critical] 升级阻塞项 — 门禁测试环节因环境原因失败(rv:5083609366 / ic:5491209528)— 已升级,需要维护者决定

该发现确实需要 bot 不被允许做出的改动,因此按门禁自身的指令保持未解决,
并附以下开放问题。

已证实的事实(证据在工作目录):

  1. 门禁通过 env -i 干净子进程启动验证,其白名单(qwen-autofix.yml)为
    PATH、HOME、RUNNER_TEMP、WORKDIR、BRANCH、GITHUB_OUTPUT、CI、KISS_AUDIT、
    FOOTPRINT_ENFORCE。RUNNER_NAME 与 QWEN_HOME 被剥离。
  2. 缺少 RUNNER_NAME 的 packages/cli 环节:修复前在未改动树上复现为
    Test Files 10 failed | 51 passed (61)(工作目录 leg-before3.log)。
    失败类别:Test timed out in 15000ms(全局 testTimeout 上限)、
    Hook timed out in 10000ms、HOME 不可写的 EACCES mkdir ~/.qwen
    以及负载争用断言抖动(主机:64 核,负载 114–207)。上一轮通过在
    packages/cli/vitest.config.ts 中引入 isSlowTestHost()(RUNNER_NAME 前缀
    或负载饱和度)修复——即门禁拒绝的改动——外加按文件适配;随后该环节 62/62
    文件通过(工作目录 leg-final.log)。
  3. 回退后复发(本轮): 门禁形态的 packages/cli 环节替身再次失败——
    fast-path-open.test.ts 4 个失败、与修复前特征完全一致,且运行因
    coverage/.tmp ENOENT 争用中止(工作目录 repair-cli-leg.log)。
  4. 本轮新发现——同一环境类别同样破坏 packages/channels/base 环节。
    门禁形态替身:Test Files 1 failed | 1 passed (2),6 个失败,全部是
    PairingStore.ensureDir 中的
    EACCES: permission denied, mkdir '/home/github-runner/.qwen/channels/…'
    (工作目录 repair-channels-legs.log)。根因:QWEN_HOME 被剥离后,
    getGlobalQwenDir()(packages/channels/base/src/paths.ts)回退到
    $HOME/.qwen,而门禁透传的 HOME 对测试进程不可写。channels/base 全套件
    在设置了 QWEN_HOME 的环境中通过(上一轮:1185/1185),因此这纯属
    剥离环境类别,不是代码缺陷。
  5. channels/dingtalk 环节以门禁形态通过(5 个文件,本轮)。

bot 在此停下的原因: 持久性修复位于禁区或需要政策决定。维护者可选项:

  • (a) 工作流侧环境(推荐,最小改动):
    .github/workflows/qwen-autofix.yml 门禁 env -i 白名单中加入
    RUNNER_NAMEQWEN_HOME(指向可写临时目录,如 RUNNER_TEMP 之下)。
    这确定性地修复所有包的 EACCES 类别,并在该变量存在的地方恢复既有的
    按 RUNNER_NAME 的慢速主机预算——PR 差异零增长。(注意:在 RUNNER_NAME
    完全为空的主机上,超时类别仍在——见 (b)。)
  • (b) 批准门禁拒绝的 test-config 适配: 即在各包的 vitest.config.*
    中使用基于负载的 isSlowTestHost() 回退。这是 worker 数/超时策略能够
    覆盖饱和非 ECS 主机的唯一位置(按文件的 vi.setConfig 无法表达
    maxWorkers,而 Session.test.ts 的负载争用失败恰恰需要它)。
  • (c) 指示 bot 加固单个测试文件(在 ChannelBase.test.ts 配对测试中使用
    临时 QWEN_HOME;在约 5 个 packages/cli 文件中使用按文件超时预算)。
    在允许边界内,但会把改动扩散到与钉钉权限卡片无关的大型测试文件,
    且仍无法修复 maxWorkers 争用类别——不推荐。

给维护者的开放问题: 门禁干净子进程的环境,您选择 (a)/(b)/(c) 中的哪一种?
在此之前该 Critical 保持未解决;PR 不携带任何机制改动。

保留提交已解决的发现 — 本轮重新核对(8 项)

被保留的被拒提交已经实施了这些发现;本轮逐项对照当前代码重新核对
(位置见括号),且它们在本轮回退下保持绿色(回退未触及这些文件中的任何一个):

  • R1-33(rc:3908905696)zh 始终允许标签保留范围后缀——
    localizedScopedAlwaysLabel(ChannelBase.ts:290),测试断言卡片标签与
    /approve-always 回退中的 始终允许此项目:git status /
    始终允许此用户:run_shell_command(ChannelBase.test.ts:1903-1998)。
  • R1-13(rc:3908905636)proceed_always_user 分支有见证——
    ChannelBase.test.ts 用户作用域用例,含无后缀的 始终允许此用户 用例。
  • R1-30(rc:3908905760)question_id 已固定——
    permission-card-controller.test.ts:126。
  • R1-20(rc:3908905747)复选框形态决策值——
    accepts checkbox-shaped decision submissions
    (permission-card-controller.test.ts:423)。
  • R1-21(rc:3908905753)hasBusinessPayload === false 门控——
    permission-card-controller.test.ts:461。
  • R1-22(rc:3908905793)isCancel 析取项——denies through the template built-in cancel flag alone(permission-card-controller.test.ts:485)。
  • R1-9(rc:3908905784)requestId 形态动作——accepts a submit delivered with the requestId action id(permission-card-controller.test.ts:469)。
  • R1-32(rc:3908905801)按演员禁止反馈去重——intruder-2 用例
    (permission-card-controller.test.ts:277)。

八项全部列入 resolved-comments.txt

顺延至下一轮(22 项发现)

R1-11、R1-27、R1-34、R1-26、R1-2、R1-28、R1-12、R1-3、R1-5、R1-1、R1-4、
R1-14、R1-19、R1-6、R1-35、R1-15、R1-8、R1-36、R1-31、R1-23、R1-29、R1-37
——全部有效,无一拒绝;本修复轮的单提交范围没有给发现批次留出空间。
每项均在 comment-replies.json 中回复,线程保持打开。

维护者转交评论(ic:5498743239)— 无可执行的代码动作

要求(a)维护者对共享 ChannelBase 变更签核,(b)执行一次真实钉钉验证。
两者均为人工动作;与上方开放问题一并转交维护者。

审查缺口披露

审查者注明 build-and-test — Integration Tests (CLI, No Sandbox) 在 CI 中被
跳过且未在本地运行。本轮无变化;与上述事项一并提请维护者注意。

冲突说明

无(--conflict false;分支已通过 2026-08-31 的合并包含当前 main)。

验证

本轮实际执行的命令,按顺序:

  • git restore --source=8a96a48a55 -- packages/cli/vitest.config.ts,随后
    git diff 8a96a48a55 -- packages/cli/vitest.config.ts —— 为空(文件与
    轮前状态逐字节一致);确认轮次差异不含任何 vitest.config.* 路径。
  • npm run build — 通过(exit 0)。
  • npm run typecheck — 通过(exit 0)。
  • npm run lint — 通过(exit 0)。
  • npx prettier --check packages/cli/vitest.config.ts — 干净。
  • 聚焦 vitest,packages/cli src/test-utils/slow-test-host.test.ts
    通过(exit 0);确认保留的 isSlowTestHost 模块及其消费者不受回退影响。
  • 门禁形态环节替身
    env -i PATH HOME CI npm run test --workspace packages/channels/dingtalk --if-present -- --changed origin/main --passWithNoTests
    — 通过,5 个文件(工作目录 repair-channels-legs.log)。
  • 门禁形态环节替身,packages/channels/base 同形态 — 失败,环境原因:
    6 × EACCES mkdir /home/github-runner/.qwen/channels/…
    (env -i 形态剥离了 QWEN_HOME;设置 QWEN_HOME 时全套件为绿)。
    见上方升级项(工作目录 repair-channels-legs.log)。
  • 门禁形态环节替身,packages/cli 同形态 — 失败,环境原因:
    fast-path-open.test.ts 4 个失败、与修复前特征一致,运行因
    coverage/.tmp ENOENT 争用中止(工作目录 repair-cli-leg.log)。
    见上方升级项。
  • 突变探针:不适用——本轮提交为纯回退,未新增任何守卫或分支;
    被回退守卫的见证已在保留轮中做过探针,且不受本轮影响。

两个失败环节的失败原因是升级项中记录的剥离环境类别(RUNNER_NAME/QWEN_HOME
被移除 + 透传的 HOME 不可写),而非本轮任何代码改动;工作流的独立 CI 仍是
最终验证门禁。

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

🧵 Resolved all 8 selected review thread(s). · 已关闭全部选中的 8 条评审线程。

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

wenshao pushed a commit to wenshao/qwen-code that referenced this pull request Sep 2, 2026
@wenshao

wenshao commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Local end-to-end verification — Linux, full DingTalk stack, no network

I built a real verification environment for this PR and ran the whole feature against it. Verdict: the feature does what the description says, and I found nothing blocking. 40 live end-to-end checks pass with 0 failures, on the current head d4fc363 (and the run was repeated from scratch after the head moved from 8a96a48). Everything below is measured, not read off the diff.

Since the description lists Linux as N/A, this covers that column.

What was actually run

No mocks in the channel path. The unmodified vendor dingtalk-stream-sdk-nodejs DWClient, inside a real qwen serve --channel dingtalk daemon built from each arm's own bundle, talking to a local stand-in for DingTalk:

Piece How it is real
DingTalk open API /etc/hosts points api.dingtalk.com + oapi.dingtalk.com at 127.0.0.1; a self-signed CA (trusted via NODE_EXTRA_CA_CERTS) serves HTTPS on :443 and answers gettoken, gateway/connections/open, card/instances/createAndDeliver, card/instances (PUT), card/streaming, robot/*
Stream channel a real WebSocket gateway pushes SYSTEM CONNECTED/REGISTERED then CALLBACK frames on /v1.0/im/bot/messages/get and /v1.0/card/instances/callback
Model a scripted OpenAI-compatible server that emits a run_shell_command needing approval (touch PERMIT_10457.txt), then a final answer
Oracles the exact HTTP bodies the daemon sent, and whether the tool's file actually appeared on disk

Arms: base = merge-base 56f92c8, PR = d4fc363, plus a third deliberately broken build used as a negative control (below).

before and after

permission card en and zh

The card renderings above are drawn from the exact cardParamMap the daemon put on the wire — they are the harness's approximation of the DingTalk form card, not screenshots of a DingTalk client.

Coverage against the PR's own reviewer test plan

Test-plan step Result
1. One native card instead of permission-command text Card delivered with the question template; no /approve text is sent at all while the card is live
2. Allow once resumes exactly once / deny rejects exactly once Both settle once, exactly one terminal projection each; touch file appears for allow, never appears for deny
3. Persistent choice only when advertised, with the original scope With an exec confirmation: 3 options incl. Always Allow in project: touch *; selecting it makes a second identical call raise no new card. With permissions.ask forcing ask: 2 options, and a hand-crafted allow_always submission on that card is refused — no escalation to a grant that was never offered
4. Cancel + timeout both deny before any delayed update can race a text approval Cancel → cancelled, timeout (timeoutMs: 6000, fired at ~6031 ms) → expired; tool never ran; a late /approve cannot resurrect the expired request; stopping the run from the status card also cancels the pending permission card
5. Other members, malformed, duplicate, stale cannot settle Non-owner callback refused (and notified once, not twice); 6 malformed shapes refused (empty form, unknown value, two values, extra field, wrong field name, no business payload); unknown outTrackId ignored; a terminal card cannot be resubmitted; a non-owner /approve text command is also refused
6. Cards disabled or delivery failing → text fallback Both paths fall back, and the fallback is byte-for-byte identical to main (247 bytes)
7. Language follows the startup default, no session lookup Verified across 6 language settings through both wiring paths (serve --channel daemon worker and channel start); across the whole run the daemon only ever saw /capabilities, /session, /prompt and /permission — no language lookup

e2e results

I also checked the two things most likely to break by accident: a group-chat run (card delivered to IM_GROUP, group-scoped owner-only notice, other members refused), and ask_user_question — it still renders as a question card and still settles through its own controller, so routing permission callbacks first did not swallow it.

Did the tests earn it?

Mutation matrix on the PR's own suites — 8/8 mutants killed: dropping the owner binding, letting terminal cards be re-claimed, dropping the offered-decisions whitelist, accepting extra form fields, reverting the duplicate-response race fix, ignoring the locale option, dropping the underscore normalisation, and reverting the new scope-suffix preservation.

Stronger: I rebuilt a third full bundle with the owner-binding block physically deleted and re-ran the live check. An outsider's callback then settled the permission and the card went approved — so the live check discriminates the guard rather than merely observing green.

Suites, lint, typecheck (on d4fc363, Linux)

ChannelBase.test.ts 645/645 · packages/channels/dingtalk 512/512 · affected CLI suites 174/174 · eslint --max-warnings 0 clean on every changed source file · tsc --noEmit clean for channels/base and channels/dingtalk. (A repo-wide packages/cli typecheck is noisy in my linked worktree because an optional dep is absent — every error is under src/ui/opentui/, none touches a file this PR changes.)

Non-blocking findings

1. The shipped default language never reaches the card. resolveChannelLocale() reads the raw general.language value, but the CLI's own i18n resolves auto through QWEN_CODE_LANG || LANG and lets QWEN_CODE_LANG override the setting entirely. Measured on a live daemon: with language: "auto" (the schema default) and LANG=zh_CN.UTF-8 the permission card is English, while the CLI's own resolver maps that same value to Chinese (resolveSupportedLanguage('zh_CN.UTF-8') === 'zh'); same divergence with QWEN_CODE_LANG=zh and no setting. Since the stated motivation is "avoid showing English permission chrome inside a Chinese Channel experience", a user who never opened the settings dialog does not get the benefit. Reusing resolveLanguageSetting() + detectSystemLanguage() would close it.

2. zh-TW gets Simplified copy. language.startsWith('zh-') maps Traditional Chinese to the zh strings, though the CLI ships zh-TW as a separate locale (its own resolver keeps zh-TW distinct).

3. The conversation is now mixed-language. Under language: "zh" the permission card is Chinese, but the status card (Running · 0s) and the ask_user_question card (Submitted.) are still English. Out of scope for this PR, but the goal is only half reached.

locale matrix

4. A text approval terminalises the card as "expired". If the owner types /approve while the card is live, the request is approved and the tool runs — but the card is projected as expired with "This permission request is no longer available." Accurate about the card, misleading about the outcome. A "resolved elsewhere" wording, or reusing the approved copy, would read better.

5. Upgrade note worth a line in the description. interactiveCards.permissionCard.enabled defaults to true, so every existing deployment that already configured interactiveCards switches from text commands to cards on upgrade with no config change. That is presumably intended, but it is a behaviour change for current users, not an opt-in.

One open question

The permission card reuses the published question template (c2a6355b-…) and introduces two card_status values the question card never emits — approved and denied (the question card only ever sends pending, submitted, expired, cancelled). The design note says "protocol card states such as pending, approved and expired remain unchanged", but approved/denied are new to that template. My harness cannot answer whether the published template renders them as a terminal, non-actionable card — this is exactly the "real delivery with a published template" item you already listed as not validated. Worth one screenshot from a real workspace before merge, or mapping the two new states onto submitted/cancelled.

Housekeeping

The PR is currently CONFLICTING against main — a single file, packages/cli/src/ui/AppContainer.test.tsx.

For the description's "Tested on" table: 🐧 Linux ✅ (this run).

中文说明

本地端到端验证 —— Linux、完整钉钉链路、全程零外网

我为这个 PR 搭了一套真实验证环境,把整个特性跑了一遍。结论:功能与描述一致,未发现阻塞问题。 40 项端到端实测检查全部通过、0 失败,基于当前 head d4fc363(head 从 8a96a48 变更后我重新完整跑了一遍)。以下结论全部来自实测,不是读 diff 得出的。

描述里 Linux 一栏是 N/A,这轮正好补上。

实际跑的是什么

channel 链路上没有任何 mock。未经修改的官方 dingtalk-stream-sdk-nodejs DWClient,跑在由各臂自己的 bundle 构建出来的真实 qwen serve --channel dingtalk daemon 里,对接本地伪造的钉钉服务:

组件 真实之处
钉钉开放接口 /etc/hostsapi.dingtalk.comoapi.dingtalk.com 指向 127.0.0.1;自签 CA(经 NODE_EXTRA_CA_CERTS 信任)在 :443 提供 HTTPS,实现 gettokengateway/connections/opencard/instances/createAndDelivercard/instances(PUT)、card/streamingrobot/*
流式通道 真实 WebSocket 网关先推 SYSTEM CONNECTED/REGISTERED,再推 /v1.0/im/bot/messages/get/v1.0/card/instances/callbackCALLBACK
模型 脚本化的 OpenAI 兼容服务,先发一个需要审批的 run_shell_commandtouch PERMIT_10457.txt),再给最终回答
判据 daemon 实际发出的 HTTP body 字节,以及工具是否真的在磁盘上落了文件

两臂:base = merge-base 56f92c8PR = d4fc363,另有一个故意改坏的第三份构建作为反向对照(见下)。

上方卡片截图是按 daemon 实际发出的 cardParamMap 渲染的,属于 harness 对钉钉表单卡片的近似还原,不是钉钉客户端截图。

对照 PR 自带的 Reviewer 测试计划

测试步骤 结果
1. 只出现原生卡片而非权限命令文本 卡片以 question 模板下发;卡片存续期间完全没有发送 /approve 文本
2. 允许一次 / 拒绝各只结算一次 均只结算一次、各自只有一次终态投影;允许时 touch 文件出现,拒绝时始终不出现
3. 仅在提供时展示持久授权,且沿用原始范围 exec 确认下为 3 个选项(含 Always Allow in project: touch *),选中后同一会话第二次相同调用不再产生新卡片;用 permissions.ask 强制 ask 时只有 2 个选项,此时手工构造的 allow_always 提交被拒绝——不能提权到从未提供过的授权
4. 取消与超时都在延迟更新可能与文本批准竞争之前拒绝 取消 → cancelled;超时(timeoutMs: 6000,实测约 6031 ms 触发)→ expired;工具从未执行;迟到的 /approve 无法复活已失效请求;从状态卡停止运行同样会取消待处理权限卡
5. 他人 / 畸形 / 重复 / 过期均不能结算 非发起人回调被拒(且只提醒一次,不重复);6 种畸形载荷被拒(空表单、未知值、两个值、多余字段、字段名错误、无业务载荷);未知 outTrackId 被忽略;终态卡片无法重复提交;非发起人的 /approve 文本命令同样被拒
6. 关闭卡片或投递失败时回退文本 两条路径都回退,且回退文案与 main 逐字节一致(247 字节)
7. 语言取启动时默认值,不做 session 查询 覆盖 6 种语言配置,且两条接线路径(serve --channel daemon worker 与 channel start)都验证过;整轮运行中 daemon 只收到 /capabilities/session/prompt/permission,没有任何语言查询

另外我特意验了两处最容易被顺手改坏的地方:群聊运行(卡片投递到 IM_GROUP、群内发出仅发起人可操作的提示、其他成员被拒),以及 ask_user_question——它仍然渲染为 question 卡片、仍由自己的 controller 结算,说明把权限回调放在路由最前面并没有把它吞掉。

测试本身立得住吗

对 PR 自带用例做变异测试,8/8 变异体全部被杀:去掉发起人绑定、允许终态卡片被重新认领、去掉「仅接受已提供选项」白名单、允许表单夹带多余字段、回退重复响应竞态修复、忽略 locale 选项、去掉下划线归一化、回退新增的授权范围后缀保留。

更强的一层:我另外完整构建了第三份 bundle,把发起人绑定那段代码物理删除后重跑实测——外部用户的回调随即结算了权限、卡片进入 approved。这证明我的实测检查是在鉴别那道守卫,而不只是在观察绿色。

套件、lint、typecheck(d4fc363,Linux)

ChannelBase.test.ts 645/645 · packages/channels/dingtalk 512/512 · 受影响 CLI 套件 174/174 · 所有改动源文件 eslint --max-warnings 0 干净 · channels/basechannels/dingtalk tsc --noEmit 干净。(我这套 link 出来的 worktree 里跑全量 packages/cli typecheck 会因缺少一个可选依赖而报错,全部集中在 src/ui/opentui/,没有一条落在本 PR 改动的文件上。)

非阻塞问题

1. 出厂默认语言传不到卡片。 resolveChannelLocale() 读的是 general.language 原始值,而 CLI 自身的 i18n 会通过 QWEN_CODE_LANG || LANG 解析 auto,并且 QWEN_CODE_LANG 优先级高于配置项。实测:language: "auto"(schema 默认值)配合 LANG=zh_CN.UTF-8 时权限卡片是英文,而 CLI 自己的解析器对同一个值给出的是中文(resolveSupportedLanguage('zh_CN.UTF-8') === 'zh');QWEN_CODE_LANG=zh 而未写配置时是同样的分叉。既然动机是「避免中文 Channel 体验里出现英文权限文案」,那么从未打开过设置对话框的用户就享受不到这个改动。复用 resolveLanguageSetting() + detectSystemLanguage() 即可闭合。

2. zh-TW 拿到的是简体文案。 language.startsWith('zh-') 把繁体中文映射到 zh 文案,而 CLI 本身是把 zh-TW 作为独立语言发布的。

3. 会话现在是中英混排。 language: "zh" 下权限卡片是中文,但状态卡(Running · 0s)和 ask_user_question 卡片(Submitted.)仍是英文。这在本 PR 范围之外,但目标只完成了一半。

4. 文本批准会把卡片置为「已失效」。 卡片还在时发起人输入 /approve,请求确实被批准、工具确实执行了,但卡片被投影为 expired,文案是 "This permission request is no longer available."。对卡片而言准确,对结果而言容易误解。改成「已在别处处理」之类的措辞,或复用已授权文案,读起来会更准。

5. 升级影响建议在描述里补一句。 interactiveCards.permissionCard.enabled 默认 true,因此所有已经配置了 interactiveCards 的现网部署,升级后无需改任何配置就会从文本命令切换到卡片。这大概率是有意为之,但对现有用户来说是行为变更,而不是可选开启。

一个待确认的问题

权限卡片复用的是已发布的 question 模板c2a6355b-…),并引入了两个 question 卡片从不发送的 card_status 取值——approveddenied(question 卡片只会发 pendingsubmittedexpiredcancelled)。设计文档写的是「pendingapprovedexpired 等协议卡片状态保持不变」,但对这个模板来说 approved/denied 是新增值。我的 harness 无法回答已发布模板是否会把它们渲染成不可再操作的终态——这正好是你已经列为「未验证」的那条「使用已发布模板的真实投递」。建议合入前补一张真实工作区的截图,或者把这两个新状态映射到 submitted/cancelled

其他

PR 目前对 main 有冲突,只有一个文件:packages/cli/src/ui/AppContainer.test.tsx

描述里的「Tested on」表格可以补上:🐧 Linux ✅(本轮)。

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

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

  • R1-1 canEnvelopeAnswerPendingPermission owner-binding clause unwitnessed — already reported (comment 3908905688)
  • R1-2 tryPresentPermission isUserQuestion exclusion unwitnessed — already reported (comment 3908905657)
  • R1-3 presenter-throws fallback branch untested — already reported (comment 3908905677)
  • R1-4 adapter-to-controller locale wiring no end-to-end witness — already reported (comment 3908905706)
  • R1-5 native-seam deny mapping unwitnessed — already reported (comment 3908905682)
  • R1-6 default-enabled permissionCard not documented in user docs — already reported (comment 3908905727)
  • R1-8 controller rejected/throwing responder branch unwitnessed — already reported (comment 3908905766)
  • R1-11 committed plan-doc snippets contradict the shipped implementation — already reported (comment 3908905623)
  • R1-12 permissionPresented reset on unsupported fallback unwitnessed — already reported (comment 3908905669)
  • R1-14 permission-before-question routing only witnessed permission-wins — already reported (comment 3908905716)
  • R1-15 finishTerminalProjection branch never exercised (no harness passes reserveRunProjection) — already reported (comment 3908905741)
  • R1-19 DM-branch feedback copy unwitnessed — already reported (comment 3908905722)
  • R1-23 updateInstance rejection never tested — already reported (comment 3908905818)
  • R1-26 presentation-contract handled success branch unwitnessed — already reported (comment 3908905651)
  • R1-27 permission-side onSettled closure unwitnessed — already reported (comment 3908905631)
  • R1-28 staleness guards in tryPresentPermission unwitnessed — already reported (comment 3908905663)
  • R1-29 settlement-listener non-resolved branch never exercised — already reported (comment 3908905825)
  • R1-31 expire() state guard unwitnessed — already reported (comment 3908905811)
  • R1-34 zh PERMISSION_COPY tool/parameters/request keys unwitnessed — already reported (comment 3908905643)
  • R1-35 presentPermission DM delivery mapping unwitnessed — already reported (comment 3908905734)
  • …and 2 more (see the run report)

Not reviewed: build-and-test — the packages/cli suite timed out locally under the harness budget on a saturated host (infrastructure result; the failures observed during the timed-out runs are all in files this PR does not touch, and CI checks at the reviewed commit all passed).

Not explored to full depth (tool budget reached): chunk 7: executing src/interactive-card-types.test.ts and src/permission-card-controller.test.ts via vitest — no node_modules in the review worktree or parent checko…; chunk 8: executing permission-card-controller.test.ts (worktree has no node_modules and no built packages/channels/base/dist ; npm ci + build exceeds the tool bud….

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

  • packages/channels/base/src/ChannelBase.ts:3187 — [probe] zh 'Allow All Edits' always-allow label drops the scope qualifier (renders '始终允许' instead of '允许所有编辑')
  • packages/channels/base/src/ChannelBase.test.ts:1841 — [probe] permission-side output-segment handling in tryPresentPermission has no witness (user-input twin is pinned)
  • packages/channels/base/src/ChannelBase.ts:3395 — [probe] card-presented command-answer success path of the new '|| pending.permissionPresented' clause unwitnessed (settlement-reason semantics unpinned)
  • docs/design/2026-08-29-dingtalk-permission-cards.md:18 — [review] design doc says 'single-choice permission field' but the implementation ships a CHECKBOX_GROUP (contradicts its own line 52)
  • packages/channels/dingtalk/src/permission-card-controller.test.ts:287 — [probe] known-but-unadvertised decision kind has no witness against the claim gate (defense-in-depth)
  • packages/channels/base/src/ChannelBase.test.ts:2128 — [probe] second-caller race unwitnessed when the in-flight bridge response rejects (loser path settles 'cancelled' + failure feedback)

[Critical] Promoted blocker from the autofix bot's issue-level comment (comment 5491209528), re-checked this round and still standing: the review-address verification gate's packages/cli test leg fails for environmental reasons — the gate's env -i clean child (.github/workflows/qwen-autofix.yml:5431) allowlists only PATH, HOME, RUNNER_TEMP, WORKDIR, BRANCH, GITHUB_OUTPUT, CI, KISS_AUDIT, and FOOTPRINT_ENFORCE, so RUNNER_NAME and QWEN_HOME are stripped, disabling the RUNNER_NAME-keyed slow-host budgets in packages/cli/vitest.config.ts (testTimeout/hookTimeout/maxWorkers, still keyed to RUNNER_NAME starting with ecs-qwen- at the reviewed commit). This PR's test-side accommodations (isSlowTestHost() loadavg fallback, scratch HOME dirs) mitigate part of the surface, but the config-level accommodation reverted by d4fc363 keeps the leg exposed on saturated non-ECS hosts, as the autofix bot's round-4 repair measured. Escalated to the maintainer in autofix round 4 with options (a)/(b)/(c); unresolved at the reviewed commit.

中文说明

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

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

未审查:build-and-test — the packages/cli suite timed out locally under the harness budget on a saturated host (infrastructure result; the failures observed during the timed-out runs are all in files this PR does not touch, and CI checks at the reviewed commit all passed)。

未探索到全部深度(达到工具调用预算):chunk 7:executing src/interactive-card-types.test.ts and src/permission-card-controller.test.ts via vitest — no node_modules in the review worktree or parent checko…;chunk 8:executing permission-card-controller.test.ts (worktree has no node_modules and no built packages/channels/base/dist ; npm ci + build exceeds the tool bud…

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

[Critical] Promoted blocker from the autofix bot's issue-level comment (comment 5491209528), re-checked this round and still standing: the review-address verification gate's packages/cli test leg fails for environmental reasons — the gate's env -i clean child (.github/workflows/qwen-autofix.yml:5431) allowlists only PATH, HOME, RUNNER_TEMP, WORKDIR, BRANCH, GITHUB_OUTPUT, CI, KISS_AUDIT, and FOOTPRINT_ENFORCE, so RUNNER_NAME and QWEN_HOME are stripped, disabling the RUNNER_NAME-keyed slow-host budgets in packages/cli/vitest.config.ts (testTimeout/hookTimeout/maxWorkers, still keyed to RUNNER_NAME starting with ecs-qwen- at the reviewed commit). This PR's test-side accommodations (isSlowTestHost() loadavg fallback, scratch HOME dirs) mitigate part of the surface, but the config-level accommodation reverted by d4fc363 keeps the leg exposed on saturated non-ECS hosts, as the autofix bot's round-4 repair measured. Escalated to the maintainer in autofix round 4 with options (a)/(b)/(c); unresolved at the reviewed commit.

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

Comment thread packages/channels/dingtalk/src/permission-card-controller.ts Outdated
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 AutoFix ran out of time before finishing (idle-timeout (no output for 1200000ms — the sandbox likely hung at startup)) (attempt 5/100) — it will retry on the next scan.

⚠️ This change was NOT pushed — any commit referenced below was made only in the runner workspace and has been discarded. What the agent reported:
Qwen failed during address-review: idle-timeout (no output for 1200000ms — the sandbox likely hung at startup).

See the Qwen Autofix agent step logs for model/tool output.

中文说明

🤖 AutoFix 在完成前耗尽了时间(idle-timeout (no output for 1200000ms — the sandbox likely hung at startup))(第 5/100 次尝试)—— 将在下次扫描时重试。

Run log: https://github.com/QwenLM/qwen-code/actions/runs/33594258815


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-2026-09-02

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 AutoFix ran out of time before finishing (timeout (3600000ms)) (attempt 5/100) — it will retry on the next scan.

⚠️ This change was NOT pushed — any commit referenced below was made only in the runner workspace and has been discarded. What the agent reported:
Qwen failed during address-review: timeout (3600000ms).

See the Qwen Autofix agent step logs for model/tool output.

中文说明

🤖 AutoFix 在完成前耗尽了时间(timeout (3600000ms))(第 5/100 次尝试)—— 将在下次扫描时重试。

Run log: https://github.com/QwenLM/qwen-code/actions/runs/33601284863


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-2026-09-02

…0457)

A timed-out permission card fired respond('deny') and then suspended on
the terminal card projection, leaving the denial promise without a
handler for the whole window. When the bridge rejected inside it, the
rejection escaped as a process-level unhandledRejection before the
trailing catch ever attached, and Node logged a second
rejectionHandled warning once the late await picked it up.

Attach the handler where the promise is created so the expiry denial is
reported as soon as it fails. The claimed-state assignment stays ahead
of the call, and respond still precedes the projection.
…ion-cards-10388

# Conflicts:
#	packages/cli/src/ui/AppContainer.test.tsx
@wenshao

wenshao commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

…t work (QwenLM#10457)

AppContainer's mount effect runs the real config.initialize() in an
un-awaited IIFE, so extension-store lock work against this suite's
scratch HOME can still be queued when afterAll runs. Deleting the tree
there fails that work with ENOENT, which withLock rethrows as
ExtensionStoreBusyError into a promise nobody awaits: all 171 tests pass
and the packages/cli leg still exits non-zero on one unhandled rejection.

Leave the tree for the OS to reclaim. Deferring the deletion to a process
'exit' handler was measured first and is not an option here — vitest
workers in this repo never run it.
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Autofix round summary — PR #10457 (address-review, same-run verification repair)

One additive follow-up commit on top of the two preserved commits from the
rejected round. No history rewritten, nothing amended:

  • preserved 6c0e806ea3fix(channels): attach the expiry denial handler at creation (#10457)
  • preserved 94e6f4d8daMerge remote-tracking branch 'origin/main'
  • new 0de5699b52test(cli): stop deleting the AppContainer scratch HOME under in-flight work (#10457)

Critical-only mode is active and the round carried a budget warning, so the
batch is the smallest blocking subset: the single defect that made the
deterministic gate reject the previous commit.

The supplied deterministic rejection

tests failed in packages/cli. The gate's own log records no failing test:
Test Files 65 passed (65), Tests 5883 passed | 1 skipped (5884),
Errors 1 error. The non-zero exit came from one unhandled rejection
attributed to src/ui/AppContainer.test.tsx:

ExtensionStoreBusyError: Extension store is busy at /tmp/qwen-appcontainer-home-SWGymV/.qwen/extension-store.
 ❯ ../core/src/extension/extension-store.ts:1494:15
Serialized Error: { code: 'extension_store_busy' }
Caused by: Error: ENOENT: no such file or directory, lstat '/tmp/qwen-appcontainer-home-SWGymV/.qwen'

Root cause — this PR's own test accommodation, measured not inferred

Three facts compose into the failure:

  1. AppContainer.test.tsx points HOME at a scratch directory for the whole
    suite (added by this PR in d8d018d85c), because the gate's env -i clean
    child inherits a HOME the test process cannot write to. Measured in this
    checkout: with HOME=/home/github-runner, touch "$HOME/.wtest-10457"
    Permission denied. The accommodation itself is correct and stays.
  2. makeFakeConfig() returns a real Config, and AppContainer's mount
    effect runs await config.initialize() inside an un-awaited IIFE with no
    catch (AppContainer.tsx:1026-1031, "let errors be handled by the global
    catch"). Every rendered test therefore leaves real initialize() work in
    flight, and that work takes the extension-store lock under
    <scratch HOME>/.qwen/extension-store, serialised behind one per-storeDir
    mutex — so a queue of detached store operations is still draining when
    afterAll runs.
  3. afterAll deleted that tree (rmSync(suiteHome, { recursive: true, force: true })).
    Removing the directories under the in-flight queue makes the store's
    recursive mkdir/lock acquisition fail with ENOENT, which withLock
    rethrows as ExtensionStoreBusyError (extension-store.ts:1494) into a
    promise nobody awaits → unhandled rejection → vitest Errors 1 error
    exit 1. Every test still passes; the run still fails.

Evidence, in the order it was taken:

  • Standalone probe (real ExtensionStore loaded from the built core, six
    detached readSnapshot() callers, scratch HOME deleted while they are in
    flight) reproduces the gate's error field for field:
    {"mode":"midflight","unhandledRejections":1,"rejections":[{"name":"ExtensionStoreBusyError","code":"extension_store_busy","message":"Extension store is busy at /tmp/qwen-probe-home-sI8qAE/.qwen/extension-store.","cause":{"code":"ENOENT","syscall":"lstat","path":"/tmp/qwen-probe-home-sI8qAE"}}]}.
    The same probe with the deletion deferred reports unhandledRejections: 0.
  • Deterministic red/green on the real suite. Temporary instrumentation
    (one detached store read fired at cleanup time, i.e. exactly what the mount
    effect leaves behind) was added, run, and reverted; the two arms differ only
    in the deletion:
    • deletion present (the code as committed at HEAD) → Test Files 1 passed (1),
      Tests 171 passed (171), Errors 1 error, exit 1, with the identical
      ExtensionStoreBusyError and ENOENT: lstat '/tmp/qwen-appcontainer-home-iiPaLo'.
      This is the gate's failure reproduced on demand.
    • deletion removed (this round's fix) → exit 0, 171 passed, no unhandled errors.
  • Why it is intermittent, and why my first reproduction attempt came back
    clean.
    The race needs store work still queued at afterAll. It also needs
    QWEN_HOME to be absent: my first leg run inherited QWEN_HOME from the
    agent shell, so Storage.getGlobalQwenDir() pointed the store away from the
    scratch HOME and nothing raced — it failed instead on 5 unrelated
    src/config/config.test.ts sandbox-image assertions caused by that same
    leaked sandbox environment. Re-run with the gate's env -i allowlist
    (PATH, HOME, RUNNER_TEMP, CI), the leg reproduced the gate's exact
    counts (65 files, 5883 passed, 1 skipped) and exited 0 only because the queue
    happened to drain in time on a less saturated host.
  • Corroborating detail. Three pre-fix runs each left a resurrected
    qwen-appcontainer-home-* tree in /tmp — in-flight store work recreating
    directories after rmSync had removed them, which is also why the gate's
    SWGymV tree is still on disk. The run whose store went to QWEN_HOME
    instead left none.

Correction to the escalated Critical

The promoted Critical (rv:5085748831, from the autofix bot's issue-level
comment) framed this leg as failing "for environmental reasons" — RUNNER_NAME
stripped by the gate's env -i allowlist, deactivating the ECS-keyed budgets in
packages/cli/vitest.config.ts. Two measurements say that is not what failed
the leg at the reviewed commit:

  • The gate's own log shows zero failing tests and zero timeouts — 5883 passed,
    1 skipped, Errors 1 error. A budget problem fails or times out tests; this
    failed the run on a single unhandled rejection after every test passed.
  • Upstream's 09958e08a5 (fix(autofix): clamp gate test load explicitly instead of via RUNNER_NAME (#10671)) is in the merged base — verified with
    git merge-base --is-ancestor 09958e08a5 HEAD — and hands the budgets to
    vitest on the command line (--maxWorkers=25% --testTimeout=60000 --hookTimeout=60000), which outranks the config file.

So the operative cause was this PR's own deletion race, and that is what this
round fixes in code. The residual the escalation named — files that set their
own ceiling through a runtime vi.setConfig, which outranks the CLI flags — is
upstream's declared "separate slice", unchanged here; this PR already improves
one of those files via isSlowTestHost().

Fix

packages/cli/src/ui/AppContainer.test.tsx, +5/−3 and subtractive in
behaviour: the suite no longer deletes its scratch HOME. afterAll still
restores HOME exactly as before; the rmSync call and its now-unused import
are gone, and the existing comment records why the tree has to outlive the
suite.

The alternative I built and then discarded is worth recording, because it looks
like the tidier fix: defer the deletion to process.once('exit', …). Measured
in this repo's vitest workers, an exit handler never runs — a throwaway
probe test registered one that wrote a marker file and removed its scratch dir;
after the run the marker was absent and the dir was still there. Shipping that
would have added a listener that silently never cleans up, i.e. the same leak
plus three lines and a false claim in a comment. A deletion that is genuinely
safe would need a hook running after every worker is torn down (vitest
globalTeardown in packages/cli/vitest.config.ts) — test-config machinery
outside this PR's footprint that the gate already rejected once (reverted in
d4fc363e), so it is not this round's to add.

Cost, stated plainly rather than hidden: one small scratch tree (tens of KB) per
run of this suite is left in the OS temp dir for the tmp reaper. That is not a
new class of leak — the pre-fix code already left exactly such a tree whenever
the race hit (three measured instances above). The fix makes the leftover
unconditional instead of intermittent, in exchange for a leg that no longer
fails the round.

No committed witness test pins this, and the reason is structural rather than
an oversight: the defect only manifests at suite teardown, where vitest reports
it as a run-level Errors count and not as a test failure, so no assertion
inside the suite can observe it. Coverage therefore rests on the mutation probe
above (removing the guard turns the suite red with the gate's exact error) and
on CI itself, which fails loudly if the deletion is ever re-added under load.

Dispositions

  1. [rc:3910875166] Critical R2-1expire() leaves the denial promise
    unhandled → RESOLVED IN CODE, by the preserved commit 6c0e806ea3, and
    re-verified this round rather than taken on trust: the .catch is attached
    at creation (permission-card-controller.ts:219-221), record.state = 'claimed' is still assigned before the respond call as the finding
    constrained (line 218), the respond-before-finalize ordering is intact, the
    trailing unreachable try/catch is gone, and the witness
    keeps a rejecting timeout denial off the unhandled rejection queue
    (permission-card-controller.test.ts:512) is present and green —
    src/permission-card-controller.test.ts (25 tests) in this round's focused
    run. Listed in resolved-comments.txt.
  2. [rv:5085748831] promoted Critical — gate packages/cli leg →
    RESOLVED IN CODE this round. Its actual cause was this PR's deletion
    race (fixed above); the RUNNER_NAME half was closed upstream in the merged
    base. Measured with the gate's exact command and a gate-like clean env:
    65 files passed, 5883 passed | 1 skipped, 0 errors, exit 0.
  3. [rv:5085748831] 22 already-reported Suggestion-level findings → NOT THIS
    ROUND
    . Critical-only mode; the workflow's deterministic filter already
    excluded them from the actionable sections. No code changed, no threads
    touched, nothing silently dropped — they remain open for human follow-up.
  4. [ic:5503374363] @wenshao's live end-to-end verification →
    housekeeping item done, findings deferred, question still escalated.
    The reported CONFLICTING state against main was already resolved by the
    preserved merge commit; git merge-base --is-ancestor origin/main HEAD is
    true and origin/main has not moved since, so no further merge was needed
    and none was performed. Its four verified non-blocking findings stay in the
    deferred queue exactly as the previous round recorded them (carried in
    deferred-findings.carry.json; nothing new was deferred this round and the
    file was not rewritten). Finding 5 concerns the PR description, which the
    workflow owns, so it is surfaced here rather than edited. Its open question —
    whether the published question template (c2a6355b-…) renders the two new
    card_status values approved/denied as a terminal, non-actionable card —
    remains ESCALATED AND UNANSWERED: it needs a real DingTalk workspace or a
    protocol decision to map them onto submitted/cancelled, and neither call
    is mine. It stays open for the maintainer and interacts with deferred
    finding 4, which touches the same card_status vocabulary.
  5. Deferred non-Critical feedback section → audit record, not work. Untouched:
    no code, no thread resolutions, no replies for those items.

Changes

File Change
packages/cli/src/ui/AppContainer.test.tsx Stop deleting the suite's scratch HOME in afterAll; drop the now-unused rmSync import; record why the tree must outlive the suite (+5/−3)

git status --short shows only that file. No .github/, .husky/, .qwen/,
repo-scripts/, root-config, lockfile, patches/, .gitattributes or
workspace-manifest path was edited, and the round stays inside packages/cli,
which the PR already touches.

Verification

Every command below was actually run; the two test legs were run with the gate's
env -i allowlist so the agent shell's own sandbox variables (SANDBOX,
QWEN_HOME, QWEN_CODE_CUSTOM_SANDBOX_IMAGE, …) cannot leak into the tests.

  • npm run buildpassed (exit 0)
  • npm run typecheckpassed (exit 0)
  • npm run lintpassed (exit 0, eslint . --ext .ts,.tsx && eslint integration-tests)
  • npx prettier --check packages/cli/src/ui/AppContainer.test.tsxpassed
  • npx eslint --max-warnings 0 packages/cli/src/ui/AppContainer.test.tsxpassed, 0 problems
  • Focused, touched package (packages/cli, gate-like clean env): npx vitest run src/ui/AppContainer.test.tsx --coverage.enabled=false171 passed (171), exit 0, no unhandled errors
  • Mutation probe, both arms on the real suite file (temporary instrumentation reverted afterwards; git diff then showed only the intended fix):
    • guard removed (deletion restored, as at HEAD) — exit 1, Errors 1 error, ExtensionStoreBusyError caused by ENOENT: lstat '/tmp/qwen-appcontainer-home-iiPaLo' — the gate's failure reproduced
    • guard present (shipped fix) — exit 0, 171 passed, zero unhandled errors
  • Standalone mechanism probe against the real ExtensionStore from the built core — mid-flight deletion: 1 unhandled ExtensionStoreBusyError with the ENOENT/lstat cause; deletion deferred: 0
  • Gate leg, exact command, gate-like clean env (packages/cli): npx vitest run --changed origin/main --passWithNoTests --maxWorkers=25% --testTimeout=60000 --hookTimeout=60000 --coverage.enabled=false65 test files passed (65), 5883 passed | 1 skipped (5884), no Errors line at all, exit 0 in 123.24s. The gate's rejection had these identical counts plus Errors 1 error.
  • Focused, preserved fix (packages/channels/dingtalk, full package): npx vitest run13 files passed, 513 tests passed, exit 0, including src/permission-card-controller.test.ts (25 tests)
  • Git pre-commit hook (scripts/pre-commit.js → lint-staged prettier --write, eslint --fix --max-warnings 0) — passed on the commit; git status --short is empty afterwards
  • process.once('exit') feasibility probe (throwaway test file created and deleted in the same command; git status --short clean afterwards) — marker not written, scratch dir left behindexit handlers do not run in these vitest workers, which is why the deferred-cleanup variant was discarded

Not run, and why: integration tests after npm run bundle — the change is
teardown hygiene inside a unit-test file, and nothing it affects is reachable
only through the bundled CLI or the integration harness.
npm run generate:settings-schema — no settings source changed. The full
packages/channels/base suite was not re-run: this round touched no file in
that package, and the previous round recorded it green at 1189 tests.

One local-only artifact worth recording so nobody chases it: my first leg run,
executed with the agent shell's environment instead of the gate's, failed 5
assertions in src/config/config.test.ts (sandbox image resolution precedence
and the internal sandbox-session-ID flag). Those are caused by the sandbox
variables this agent process itself exports; the same leg under the gate's
env -i allowlist passed all of them, at the reviewed commit and after the fix.
Nothing was changed in response to them.

中文说明

Autofix 轮次总结 —— PR #10457(address-review,同轮验证修复)

在被拒绝那一轮的两个提交之上,新增一个增量提交。未改写历史,未 amend:

  • 保留 6c0e806ea3 —— fix(channels): attach the expiry denial handler at creation (#10457)
  • 保留 94e6f4d8da —— Merge remote-tracking branch 'origin/main'
  • 新增 0de5699b52 —— test(cli): stop deleting the AppContainer scratch HOME under in-flight work (#10457)

当前处于仅处理 Critical 的模式,且本轮带有预算警告,因此批次压缩为最小的阻塞子集:即导致确定性门禁拒绝上一个提交的那一个缺陷。

门禁给出的确定性拒绝

tests failed in packages/cli。门禁自己的日志显示没有任何测试失败Test Files 65 passed (65)Tests 5883 passed | 1 skipped (5884)Errors 1 error。非零退出码来自一个归属于 src/ui/AppContainer.test.tsx 的未处理拒绝:

ExtensionStoreBusyError: Extension store is busy at /tmp/qwen-appcontainer-home-SWGymV/.qwen/extension-store.
 ❯ ../core/src/extension/extension-store.ts:1494:15
Serialized Error: { code: 'extension_store_busy' }
Caused by: Error: ENOENT: no such file or directory, lstat '/tmp/qwen-appcontainer-home-SWGymV/.qwen'

根因 —— 本 PR 自己的测试适配,实测得出而非推断

三个事实叠加成了这次失败:

  1. AppContainer.test.tsx 在整个 suite 期间把 HOME 指向一个临时目录(由本 PR 在 d8d018d85c 中加入),原因是门禁的 env -i clean child 继承了一个测试进程写不进去的 HOME。在当前 checkout 实测:HOME=/home/github-runner 时执行 touch "$HOME/.wtest-10457"Permission denied。这个适配本身是正确的,保留。
  2. makeFakeConfig() 返回的是真实的 Config,而 AppContainer 的挂载 effect 在一个未 await 的 IIFE 里执行 await config.initialize(),且没有 catch(AppContainer.tsx:1026-1031,注释写的是「let errors be handled by the global catch」)。因此每个渲染类测试都会留下仍在进行的真实 initialize() 工作,而这些工作会在 <临时 HOME>/.qwen/extension-store 下获取 extension-store 锁,并被同一个按 storeDir 键的互斥量串行化 —— 于是当 afterAll 运行时,仍有一队列脱管的 store 操作在排空。
  3. afterAll 删除了那棵目录树(rmSync(suiteHome, { recursive: true, force: true }))。在这队列仍在进行时删掉目录,会让 store 的递归 mkdir/锁获取以 ENOENT 失败,withLock 把它重新抛成 ExtensionStoreBusyErrorextension-store.ts:1494),落进一个无人 await 的 promise → 未处理拒绝 → vitest Errors 1 error → 退出码 1。所有测试仍然通过,但整轮运行失败。

证据,按取得顺序:

  • 独立探针(从已构建的 core 加载真实 ExtensionStore,六个脱管的 readSnapshot() 调用方,在其进行中删除临时 HOME)逐字段复现了门禁的错误:{"mode":"midflight","unhandledRejections":1,"rejections":[{"name":"ExtensionStoreBusyError","code":"extension_store_busy","message":"Extension store is busy at /tmp/qwen-probe-home-sI8qAE/.qwen/extension-store.","cause":{"code":"ENOENT","syscall":"lstat","path":"/tmp/qwen-probe-home-sI8qAE"}}]}。同一探针把删除延后后,结果为 unhandledRejections: 0
  • 在真实 suite 上的确定性红/绿。 加入临时插桩(在清理时刻发起一个脱管的 store 读取,也就是挂载 effect 会留下的那种工作),运行后再撤销;两臂之间唯一的差别就是那次删除:
    • 删除存在(HEAD 上已提交的代码)→ Test Files 1 passed (1)Tests 171 passed (171)Errors 1 error、退出码 1,并报出完全相同的 ExtensionStoreBusyErrorENOENT: lstat '/tmp/qwen-appcontainer-home-iiPaLo'。这就是按需复现出来的门禁失败。
    • 删除移除(本轮修复)→ 退出码 0,171 passed,无未处理错误。
  • 为什么它是偶发的,以及我第一次复现为什么是干净的。 这个竞态需要在 afterAll 时仍有 store 工作在排队。它还需要 QWEN_HOME 不存在:我第一次跑测试段时从 agent shell 继承了 QWEN_HOME,于是 Storage.getGlobalQwenDir() 把 store 指向了临时 HOME 之外,什么都没竞争上 —— 那次反而因为同一个泄漏的沙箱环境,在 5 条无关的 src/config/config.test.ts 沙箱镜像断言上失败了。改用门禁的 env -i 白名单(PATHHOMERUNNER_TEMPCI)重跑后,测试段复现出门禁完全相同的计数(65 个文件、5883 通过、1 跳过),并且仅仅因为队列在负载较轻的机器上恰好及时排空,才退出码为 0。
  • 佐证细节。 三次修复前的运行各自在 /tmp 里留下了一棵被重建出来的 qwen-appcontainer-home-* 目录树 —— 是仍在进行的 store 工作在 rmSync 删除之后又重建了目录,这也解释了为什么门禁那次的 SWGymV 目录至今还在磁盘上。而 store 指向 QWEN_HOME 的那次运行一个都没留下。

对被提升为 Critical 的那条的更正

被提升的 Critical(rv:5085748831,来自 autofix bot 的 issue 级评论)把这条测试段描述为「因环境原因失败」—— 门禁的 env -i 白名单剥离了 RUNNER_NAME,使 packages/cli/vitest.config.ts 中以 ECS 为键的预算失效。有两项实测说明:在被审查的那个提交上,失败原因并不是这个:

  • 门禁自己的日志显示零失败测试、零超时 —— 5883 通过、1 跳过、Errors 1 error。预算问题会让测试失败或超时;而这次是在所有测试都通过之后,因为一个未处理拒绝而让整轮运行失败。
  • 上游的 09958e08a5fix(autofix): clamp gate test load explicitly instead of via RUNNER_NAME (#10671))已经在合并进来的基线里 —— 用 git merge-base --is-ancestor 09958e08a5 HEAD 验证 —— 它把预算直接从命令行交给 vitest(--maxWorkers=25% --testTimeout=60000 --hookTimeout=60000),命令行优先级高于配置文件。

因此真正起作用的原因是本 PR 自己的删除竞态,而这正是本轮用代码修掉的东西。该升级条目提到的残留部分 —— 那些通过运行时 vi.setConfig 自行设置上限的文件(其优先级高于命令行参数)—— 属于上游明确称为「另一个独立切片」的工作,本轮未改动;本 PR 其实已经通过 isSlowTestHost() 改善了其中一个文件。

修复

packages/cli/src/ui/AppContainer.test.tsx,+5/−3,行为上是做减法:suite 不再删除自己的临时 HOMEafterAll 仍然像以前一样恢复 HOMErmSync 调用与随之不再使用的 import 被移除,已有注释里补上了这棵树为什么必须比 suite 活得更久。

我先做了、随后又放弃的另一个方案值得记录,因为它看起来更像是「更整洁」的修法:把删除延后到 process.once('exit', …)。在本仓库的 vitest worker 中实测,exit 处理器根本不会执行 —— 一个一次性探针测试注册了一个会写标记文件并删除自己临时目录的处理器;运行结束后标记文件不存在、目录仍在。把它交上去等于加了一个永远不会清理的监听器,也就是同样的泄漏,外加三行代码和注释里一句不实的说法。真正安全的删除需要一个在所有 worker 拆除之后才运行的钩子(packages/cli/vitest.config.ts 里的 vitest globalTeardown)—— 那属于本 PR 足迹之外的测试配置机制,门禁已经拒绝过一次(在 d4fc363e 中被回滚),所以不该由本轮来加。

代价直说,不藏:这个 suite 每跑一次,就会在系统临时目录里留下一棵小的临时目录树(几十 KB),交给 tmp 清理机制处理。这不是一类新的泄漏 —— 修复前的代码只要竞态命中就会留下同样的一棵树(上面实测到三次)。这次修复把「偶发残留」换成了「必然残留」,换来的是一条不再让整轮失败的测试段。

本轮没有提交任何用于固定该行为的测试,原因是结构性的,不是疏忽:该缺陷只在 suite 拆除阶段显现,vitest 把它记为运行级的 Errors 计数而不是测试失败,所以 suite 内部没有任何断言能观察到它。因此覆盖依赖于上面的变异探针(移除该守卫会让 suite 以门禁完全相同的错误变红),以及 CI 本身 —— 一旦有人在负载下把删除加回来,CI 会大声失败。

处置

  1. [rc:3910875166] Critical R2-1 —— expire() 让拒绝授权的 promise 无处理器 → 已在代码中解决,由保留的提交 6c0e806ea3 完成,并且本轮重新核验而非采信:.catch 在创建处附加(permission-card-controller.ts:219-221),record.state = 'claimed' 仍按该发现指定的约束在 respond 调用之前赋值(第 218 行),先 respond 后 finalize 的顺序完好,尾部不可达的 try/catch 已移除,验证用例 keeps a rejecting timeout denial off the unhandled rejection queuepermission-card-controller.test.ts:512)存在且为绿 —— 本轮聚焦运行中的 src/permission-card-controller.test.ts (25 tests)。已列入 resolved-comments.txt
  2. [rv:5085748831] 被提升的 Critical —— 门禁 packages/cli 测试段 → 本轮已在代码中解决。其实际原因是本 PR 的删除竞态(已在上面修复);RUNNER_NAME 那一半已由合并进来的基线在上游关闭。用门禁完全相同的命令与接近门禁的干净环境实测:65 个文件通过、5883 通过 | 1 跳过、0 个错误、退出码 0。
  3. [rv:5085748831] 22 条已报告过的建议级发现 → 本轮不处理。仅处理 Critical 模式;工作流的确定性过滤器已把它们排除在可执行区域之外。未改动代码、未触碰任何 thread,也没有静默丢弃 —— 它们保持开放,留待人工跟进。
  4. [ic:5503374363] @wenshao 的真实端到端验证 → 事务性事项已完成,发现已延后,问题仍处上报状态。 其报告的与 main 冲突状态已由保留的合并提交解决;git merge-base --is-ancestor origin/main HEAD 为真,且此后 origin/main 没有移动,因此不需要也没有再做合并。其四条已核实为非阻塞的发现,按上一轮的记录原样留在延后队列中(由 deferred-findings.carry.json 携带;本轮没有新的延后项,也未重写该文件)。第 5 条针对 PR 描述,而 PR 描述由工作流维护,因此在此提交给维护者而不由我改动。其待确认问题 —— 已发布的 question 模板(c2a6355b-…)是否会把两个新的 card_status 取值 approved/denied 渲染成不可再操作的终态卡片 —— 仍然已上报且未获答复:它需要一个真实的钉钉工作区,或者一个把它们映射到 submitted/cancelled 的协议决定,两个都不该由我裁定。它留给维护者,并与上面第 4 条延后发现有交叉,因为涉及同一套 card_status 词汇。
  5. Deferred non-Critical feedback 一节 → 属于审计记录,不是工作。未触碰:没有改代码、没有解决 thread、没有为这些条目写回复。

改动

文件 改动
packages/cli/src/ui/AppContainer.test.tsx 不再在 afterAll 中删除 suite 的临时 HOME;移除随之不再使用的 rmSync import;记录这棵树为什么必须比 suite 活得更久(+5/−3)

git status --short 只显示这一个文件。未编辑任何 .github/.husky/.qwen/、仓库 scripts/、根级配置、lockfile、patches/.gitattributes 或 workspace manifest 路径,本轮停留在本 PR 已经触碰过的 packages/cli 内。

验证

下面每条命令都是实际执行过的;两条测试段都使用门禁的 env -i 白名单运行,以免 agent shell 自己的沙箱变量(SANDBOXQWEN_HOMEQWEN_CODE_CUSTOM_SANDBOX_IMAGE 等)泄漏进测试。

  • npm run build —— 通过(退出码 0)
  • npm run typecheck —— 通过(退出码 0)
  • npm run lint —— 通过(退出码 0,eslint . --ext .ts,.tsx && eslint integration-tests
  • npx prettier --check packages/cli/src/ui/AppContainer.test.tsx —— 通过
  • npx eslint --max-warnings 0 packages/cli/src/ui/AppContainer.test.tsx —— 通过,0 个问题
  • 聚焦运行,被触碰的包(packages/cli,接近门禁的干净环境):npx vitest run src/ui/AppContainer.test.tsx --coverage.enabled=false —— 171 passed (171),退出码 0,无未处理错误
  • 变异探针,两臂都在真实 suite 文件上(临时插桩事后已撤销;随后 git diff 只显示预期的修复):
    • 守卫移除(恢复删除,即 HEAD 的形态)—— 退出码 1Errors 1 errorExtensionStoreBusyError,cause 为 ENOENT: lstat '/tmp/qwen-appcontainer-home-iiPaLo' —— 门禁失败被复现
    • 守卫存在(本轮修复)—— 退出码 0171 passed,零未处理错误
  • 针对已构建 core 中真实 ExtensionStore 的独立机制探针 —— 进行中删除:1 个未处理的 ExtensionStoreBusyError,带 ENOENT/lstat cause;延后删除:0 个
  • 门禁测试段,完全相同的命令,接近门禁的干净环境(packages/cli):npx vitest run --changed origin/main --passWithNoTests --maxWorkers=25% --testTimeout=60000 --hookTimeout=60000 --coverage.enabled=false —— 65 个测试文件通过(65)、5883 通过 | 1 跳过(5884)、完全没有 Errors 行、退出码 0,耗时 123.24s。门禁那次拒绝的计数与此完全相同,只是多了一行 Errors 1 error
  • 聚焦运行,保留的修复(packages/channels/dingtalk,整包):npx vitest run —— 13 个文件通过,513 个测试通过,退出码 0,其中包含 src/permission-card-controller.test.ts (25 tests)
  • Git pre-commit 钩子(scripts/pre-commit.js → lint-staged 的 prettier --writeeslint --fix --max-warnings 0)—— 提交时通过;随后 git status --short 为空
  • process.once('exit') 可行性探针(一次性测试文件在同一条命令中创建并删除;随后 git status --short 干净)—— 标记文件未写出、临时目录仍留存 → 这些 vitest worker 中 exit 处理器不会运行,这正是延后清理方案被放弃的原因

未运行的命令及原因: 没有在 npm run bundle 之后运行集成测试 —— 本次改动是一个单元测试文件里的拆除阶段卫生问题,其影响范围并非只能通过打包后的 CLI 或集成 harness 才能触达。没有运行 npm run generate:settings-schema —— 没有改动任何 settings 源。没有重跑 packages/channels/base 全套 —— 本轮未触碰该包内任何文件,上一轮已记录其为绿(1189 个测试)。

有一条仅存在于本地的现象值得记录,以免有人去追:我第一次跑测试段时用的是 agent shell 的环境而不是门禁的环境,结果在 src/config/config.test.ts 上有 5 条断言失败(沙箱镜像解析优先级,以及内部 sandbox-session-ID 标志)。它们由本 agent 进程自己导出的沙箱变量造成;在门禁的 env -i 白名单下重跑同一条测试段,无论是在被审查的提交上还是修复之后,这些断言全部通过。我没有为它们改动任何东西。

Deferred non-Critical feedback

Critical-only mode is active: 5 change-producing rounds are complete. The workflow excluded the non-Critical feedback below from this round's actionable sections; the items remain open for human follow-up. Maintainer feedback is deferred only after its author has used 2 regular feedback batches in this window's Critical-only tail; authors at that budget, if any, are named below. (@qwen-code /retry starts a fresh counting window.)

中文说明

已进入仅处理 Critical 的模式:已完成 5 个产生改动的轮次。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。维护者反馈仅在其本人于本窗口 Critical-only 阶段已使用 2 批常规反馈预算后才会延后;达到预算的作者(如有)在下方点名。(评论 @qwen-code /retry 可开启新的计数窗口。)

Base-conflict check · 基分支冲突检查: conflicted with main — resolved in this push. · 与 main 有冲突——已在本次推送中解决。

🧵 Resolved all 1 selected review thread(s). · 已关闭全部选中的 1 条评审线程。

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-2026-09-02

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code did not run conflict resolution for this request.

PR #10457 does not currently have merge conflicts with main.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review was cancelled before a review could be posted. Nothing failed and nothing is retried automatically: the run was cancelled — by an operator, an upstream event, or the job exceeding its execution time limit. If you still want a review of this PR, request one with @qwen-code /review. See workflow logs.

@BenGuanRan BenGuanRan removed the autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) label Sep 2, 2026

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

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

  • R1-2 tryPresentPermission eligibility gate no negative test — already reported (comment 3908905657)
  • R1-8 controller accepted===false/'expired' responder branch unwitnessed — already reported (comment 3908905766)
  • R1-1 canEnvelopeAnswerPendingPermission permissionPresented owner-binding no non-owner witness — already reported (comment 3908905688)
  • R1-3 presenter-throws fallback branch untested — already reported (comment 3908905677)
  • R1-4 adapter-to-controller locale wiring no end-to-end zh witness — already reported (comment 3908905706)
  • R1-11 language-plan copy-constant snippets contradict shipped constants (items 4+5) — already reported (comment 3908905623)
  • R1-29 settlement-listener non-resolved branch unwitnessed — already reported (comment 3908905825)
  • R1-23 projectTerminal updateInstance-rejection guard unwitnessed — already reported (comment 3908905818)

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

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

  • docs/design/2026-08-29-dingtalk-permission-cards.md:76 — [probe] terminal-state table says every outside resolution projects 'expired', but the code projects 'cancelled' when the outside resolution rejected (owner /deny text command)
  • packages/cli/src/commands/channel/runtime.ts:31 — [probe] resolveChannelLocale canonicalizes fewer Chinese aliases than the CLI's resolveSupportedLanguage ('Traditional Chinese', '繁體中文', 'zh.UTF-8' resolve Chinese for the UI but English cha…
中文说明

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

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

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

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

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

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(dingtalk): present tool permission requests with native interactive cards

4 participants