Skip to content

feat(channels): add DingTalk interactive cards - #6930

Merged
BenGuanRan merged 46 commits into
QwenLM:mainfrom
BenGuanRan:agent/dingtalk-interactive-cards
Jul 29, 2026
Merged

feat(channels): add DingTalk interactive cards#6930
BenGuanRan merged 46 commits into
QwenLM:mainfrom
BenGuanRan:agent/dingtalk-interactive-cards

Conversation

@BenGuanRan

@BenGuanRan BenGuanRan commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

This PR adds opt-in DingTalk interactive status and question cards behind a transport-neutral Channel interaction contract. The shared layer identifies each attended prompt with an opaque runId, identifies each contiguous visible-output interval with a segmentId, and presents structured user-input requests with their original request, owner, session, run, and delivery target. Adapters that do not implement the optional presentation hook return unsupported and retain the existing text and permission fallback.

Architecture

1. End-to-end runtime chain

End-to-end Channel and DingTalk interaction architecture

2. Existing adapter compatibility and degradation

Compatibility and degradation across existing adapters

3. Future IM adapter extension boundary

Future IM adapter extension boundary

Layer Responsibility
Existing session routing Continues to select context using the configured user, thread, chat_thread, or single scope. The interaction contract is attached after routing and does not introduce a second session model.
Channel Base Owns prompt admission, collect / steer / followup dispatch semantics, the active run, output-segment boundaries, pending permission settlement, and exact-run cancellation.
Shared presentation contract Exposes optional output-segment context, segment-end notification, and structured-input presentation. It contains no card template, callback payload, platform handle, or platform lifecycle state.
DingTalk interaction presenter Validates that lifecycle output and structured input still match the registered run, owner, and target, then serializes projections per run so status output, question terminalization, and continuation output cannot cross-write.
Status-card controller Maintains bounded state per output segment and run, coalesces streamed text into one in-place card, exposes owner-only Stop, and terminalizes the same card as Completed, Failed, Stopped, or Cancelled.
Question-card controller Maintains bounded state per permission request, renders normalized ask_user_question fields, submits structured answers to the original request, keeps only the latest pending card valid in one owner/session scope, and keeps different owners and sessions independent.
DingTalk callback ingress Parses and correlates a callback, atomically classifies it as accepted, forbidden, or ignored, acknowledges the DingTalk frame, and only then executes accepted work. forbidden produces one deduplicated IM-only notice per actor/card; ignored remains silent. Neither enters Agent context or mutates the question card.

The status and question lifecycles are deliberately independent. A question closes the current output segment with an input_requested boundary, but it does not terminate the run. After an answer is accepted, subsequent model output receives a new segment identity while remaining in the same run and session. If the same run asks again while its first question is still pending, the first native card remains authoritative and the second request uses the explicit text fallback instead of creating an invisible competing card. A question from a newer run in the same owner/session scope expires the older card without responding to the older Agent request.

Compatibility and degradation

  • Interactive cards are disabled unless interactiveCards is configured. With no configuration, DingTalk keeps the original Markdown and permission behavior.
  • Status cards and question cards can be enabled independently. blockStreaming=on disables only the streaming status-card projection; question cards remain independently eligible.
  • Card creation or streaming-open failure falls back to the existing DingTalk response path and terminalizes an unusable card instead of leaving a blank running card.
  • The shared presentation method defaults to unsupported, and all added output parameters are optional. Feishu, QQ, plugins, and future adapters do not need DingTalk concepts or callback APIs. No Feishu or WeCom production adapter is changed.
  • Loop delivery, webhook-only delivery, or another path without an attended inbound owner/run correlation does not create an interactive card.
  • The TypeScript daemon client coalesces concurrent cancellation calls so one Stop or steer boundary produces one daemon cancellation request.

Example opt-in configuration:

{
  "interactiveCards": {
    "enabled": true,
    "statusCard": { "enabled": true },
    "questionCard": {
      "enabled": true,
      "timeoutMs": 270000
    }
  }
}

Why it's needed

DingTalk Markdown delivery cannot update one response in place, bind a historical Stop button to one exact prompt run, or return structured ask_user_question answers. Session identity alone is insufficient because one session can execute consecutive runs and one run can contain output before and after a human question. The shared run, segment, request, owner, and target contract provides the correlation needed for safe IM interaction while keeping platform rendering and callback state inside the DingTalk adapter.

The design preserves context and dispatch behavior instead of creating a DingTalk-only conversation model. A user message that supersedes an active steer run settles that run's pending question before the replacement prompt starts; collect and followup continue to buffer or queue through Channel Base. Different users can retain independent cards when their configured routing scopes resolve to different sessions or active owners.

Reviewer Test Plan

How to verify

  • Enable both DingTalk card types and send a prompt with several streamed chunks. Confirm one status card updates in place, displays model and elapsed time, retains its final text, removes Stop after terminalization, and never exposes a local [IMAGE: /path] marker.
  • Press Stop as the originating user during a long response. Confirm only the matching run is cancelled, the card becomes Stopped, and a later prompt remains unaffected. Repeat a stale or duplicate callback and confirm it does not cancel the new run.
  • Trigger ask_user_question with single-select, multi-select, and Other input. Confirm one question card returns structured answers to the original permission request, updates in place to Submitted, and allows continuation output only after the terminal question projection.
  • Trigger a second question in the same run while the first is pending. Confirm no second native card is created, the first card remains answerable, and the second request receives the explicit text fallback. Then trigger a question from a newer run in the same owner/session scope and confirm the old card becomes Expired without sending a synthetic answer to the Agent.
  • Under steer, send ordinary text while a question card is pending. Confirm the old run exits, its card becomes unavailable, and a stale callback cannot settle the replacement run. Repeat under collect and followup and confirm the existing buffering/queueing semantics are preserved.
  • Have a different user click an owner-bound group question card repeatedly. Confirm one group-visible forbidden notice is produced for that actor/card, the card remains pending, no permission response reaches the Agent, and the owner can still submit. Confirm malformed, expired, and duplicate callbacks remain silent.
  • Disable all interactive cards, then enable only one card type at a time. Confirm the disabled capability uses the original Markdown or permission fallback. Repeat a basic complete/Stop/steer/thread flow in Feishu and confirm its existing card behavior is unchanged.

Evidence (Before & After)

Before: DingTalk delivered assistant output as separate Markdown messages and relayed permissions through text commands. It had no in-place streamed response, exact-run Stop boundary, or structured answer form.

After: DingTalk projects the shared Channel identities into two independent card lifecycles while the Agent, session router, dispatch modes, and permission contract remain authoritative.

Streaming status card and exact-run Stop

DingTalk streaming status card and Stop

Structured question-card submission

DingTalk structured question-card submission

Terminal Stopped projection

DingTalk terminal Stopped card

At code commit ecfa1937981693f163ec32ad56d4cfc1bc84cf58, GitHub's Ubuntu Node 22 full test job passed. Earlier focused verification passed 955 Channel Base tests, 241 DingTalk tests, 110 Feishu tests, 134 WeCom tests, and 348 TypeScript SDK daemon-client tests, together with workspace build, typecheck, bundle, no-AK integration smoke, and Web Shell E2E smoke. The current head 334e748e90808cb606a761ac575676a6b48d0289 only refreshes the design diagrams and their documentation references and triggers a fresh CI run.

Real-device E2E covered DingTalk streaming, terminal content retention, owner Stop, structured answers, sequential questions, stale and repeated callbacks, non-owner isolation, a 270-second question-card timeout, and card-disabled degradation. Feishu real-device E2E covered complete, Stop, steer, quoted context, and thread reuse without a production adapter change.

Tested on

OS Status
🍏 macOS
🪟 Windows N/A
🐧 Linux

Environment (optional)

macOS, Node.js 22, the bundled CLI running a daemon-managed Channel worker on loopback, AliDing with the configured DingTalk robot, and the Feishu desktop client for compatibility E2E. The DingTalk real-device run used sessionScope=thread, dispatchMode=steer, open sender/group/DM policies, and required a mention in groups; these are runtime configuration choices rather than card-architecture requirements.

Risk & Scope

  • Main risk or tradeoff: live status/question registries are process-local. Restart-safe card recovery and non-sticky multi-worker callback routing remain future work; DingTalk delivery also depends on access to the built-in card templates and Card OpenAPI.
  • Not validated / out of scope: DingTalk does not currently supply a platform threadId, so thread-oriented scopes use the existing conversation fallback rather than claiming topic isolation. Feishu retains its existing generic permission fallback for ask_user_question; this PR does not add a Feishu structured question card. Free-form answer parsing and cross-platform identity federation are also out of scope.
  • Breaking changes / migration notes: none. Interactive cards are opt-in, the status and question capabilities can be disabled independently, and adapters that do not implement the shared presentation hook retain their previous behavior.

Linked Issues

Related to #6443.

中文说明

本 PR 做了什么

本 PR 在一套与传输平台无关的 Channel 交互契约之上,为钉钉增加可选启用的流式状态卡和结构化提问卡。共享层为每次有人参与的 Prompt 执行分配不透明的 runId,为每段连续、用户可见的输出分配 segmentId,并把结构化提问连同原始请求、owner、session、run 和发送目标交给适配器。未实现可选展示 hook 的适配器返回 unsupported,继续使用原有文本和权限降级链路。

架构

上方三张图分别说明:从消息路由到钉钉卡片的完整运行链路、现有适配器的兼容与降级边界、未来飞书/企业微信等 IM 的扩展方式。

层级 职责
现有会话路由 继续按 userthreadchat_threadsingle 选择上下文。交互契约挂在路由之后,不引入第二套会话模型。
Channel Base 负责 Prompt 准入、collect / steer / followup 调度语义、当前 run、输出段边界、待处理权限结算和精确 run 取消。
共享展示契约 只暴露可选输出段上下文、输出段结束通知和结构化输入展示,不包含卡片模板、回调 payload、平台资源 ID 或平台生命周期状态。
钉钉 Interaction Presenter 校验生命周期输出和结构化输入仍匹配登记的 run、owner 与 target,并按 run 串行化投影,防止状态输出、提问终态和继续输出交叉写入。
状态卡控制器 按输出段和 run 维护有界状态,把流式文本合并到一张原地更新的卡片,提供仅 owner 可用的 Stop,并把同一张卡片更新为 CompletedFailedStoppedCancelled
提问卡控制器 按权限请求维护有界状态,渲染标准化后的 ask_user_question 字段,把结构化答案提交回原请求,在同一 owner/session scope 中只保留最新待回答卡,并隔离不同 owner 和 session。
钉钉回调入口 解析并关联回调,原子分类为 acceptedforbiddenignored,先确认钉钉 frame,再执行合法操作。forbidden 每个 actor/card 只产生一次 IM 侧提示,ignored 保持静默;二者都不进入 Agent 上下文,也不修改提问卡。

状态卡与提问卡是两套独立生命周期。提问会以 input_requested 边界关闭当前输出段,但不会结束 run;回答被接受后,模型的后续输出在同一 run/session 中获得新的 segment。若同一个 run 在第一张卡仍待回答时再次提问,第一张原生卡继续有效,第二个请求走明确的文本降级,不产生不可见的竞争卡。若同一 owner/session scope 中出现较新 run 的提问卡,旧卡应变为 Expired,但不能向旧 Agent 请求发送合成答案。

兼容与降级

  • 只有显式配置 interactiveCards 才启用交互卡;未配置时,钉钉保持原有 Markdown 和权限处理行为。
  • 状态卡与提问卡可独立启用。blockStreaming=on 只关闭流式状态卡投影,提问卡仍可独立启用。
  • 建卡或开启流式更新失败时,回退到已有钉钉回复链路,并终态化不可用卡片,避免留下空白 Running 卡。
  • 共享展示方法默认返回 unsupported,所有新增输出参数均为可选。飞书、QQ、插件和未来适配器无需理解钉钉概念或回调 API;本 PR 不修改飞书或企业微信的生产适配器。
  • loop、仅 webhook 或其他无法关联 attended owner/run 的发送路径不会创建交互卡。
  • TypeScript daemon client 会合并并发取消调用,因此一次 Stop 或 steer 边界只会发出一次 daemon 取消请求。

可选启用配置示例:

{
  "interactiveCards": {
    "enabled": true,
    "statusCard": { "enabled": true },
    "questionCard": {
      "enabled": true,
      "timeoutMs": 270000
    }
  }
}

为什么需要

钉钉 Markdown 无法原地更新同一条响应、无法把历史 Stop 按钮绑定到某一次精确 Prompt run,也无法把结构化 ask_user_question 答案返回原始请求。只有 session 标识不够,因为一个 session 可以连续执行多个 run,而一个 run 也可能在人工提问前后产生多段输出。共享的 run、segment、request、owner 和 target 契约提供了安全 IM 交互所需的关联,同时把平台渲染和回调状态留在钉钉适配器内。

该设计保留原有上下文和调度行为,不创建钉钉专属会话模型。用户消息在 steer 下替换当前运行时,会先结算被替换 run 的待回答问题,再启动新 Prompt;collectfollowup 继续由 Channel Base 暂存或排队。若配置的路由 scope 把不同用户解析到不同 session 或 active owner,不同用户可以同时保留各自独立的卡片。

评审验证计划

如何验证

  • 同时启用两类钉钉卡片并发送产生多段流式输出的 Prompt。确认只有一张状态卡原地更新,展示模型与耗时,终态保留完整正文并移除 Stop,而且不会暴露本地 [IMAGE: /path] 标记。
  • 原用户在长回答期间点击 Stop。确认只取消匹配 run,卡片变为 Stopped,后续 Prompt 不受影响;重复或过期回调不能取消新 run。
  • 触发包含单选、多选和 Other 输入的 ask_user_question。确认提问卡把结构化答案返回原权限请求、原地更新为 Submitted,并且继续输出只会发生在提问终态投影之后。
  • 第一张卡待回答时,在同一 run 再触发提问。确认不创建第二张原生卡,第一张仍可回答,第二个请求得到明确文本降级。随后在同一 owner/session scope 的新 run 触发提问,确认旧卡变为 Expired,且不会给 Agent 注入合成答案。
  • steer 下,提问卡待回答时发送普通文本。确认旧 run 真正退出、旧卡失效、旧回调不能结算新 run;再以 collectfollowup 验证原有暂存/排队语义不变。
  • 让其他用户反复点击群内 owner 专属提问卡。确认每个 actor/card 只出现一次群内无权限提示,卡片保持 pending,Agent 不收到权限响应,owner 仍可正常提交;格式错误、过期和重复回调应静默。
  • 关闭全部交互卡,再分别只启用一种卡片。确认关闭的能力回到原 Markdown 或权限降级链路;在飞书重复完成、Stop、steer、引用上下文和 thread 复用,确认既有行为不变。

前后效果与证据

修改前:钉钉把助手输出作为独立 Markdown 消息发送,并通过文本命令转发权限请求,不支持原地流式响应、精确 run Stop 或结构化答案表单。

修改后:钉钉把共享 Channel 身份投影为两套独立卡片生命周期,同时 Agent、session 路由、dispatch 模式和权限契约继续保持权威。上方三段 GIF 分别展示流式状态卡与 Stop、结构化提问提交、最终 Stopped 状态。

在代码提交 ecfa1937981693f163ec32ad56d4cfc1bc84cf58 上,GitHub Ubuntu Node 22 全量测试任务通过。此前的聚焦验证通过 955 个 Channel Base 测试、241 个钉钉测试、110 个飞书测试、134 个企业微信测试和 348 个 TypeScript SDK daemon-client 测试,并通过 workspace build、typecheck、bundle、无 AK integration smoke 与 Web Shell E2E smoke。当前 head 334e748e90808cb606a761ac575676a6b48d0289 只更新三张设计图及其文档引用,并触发新一轮 CI。

真机 E2E 已覆盖钉钉流式输出、终态正文保留、owner Stop、结构化答案、连续提问、过期/重复回调、非 owner 隔离、270 秒提问卡超时和卡片关闭降级。飞书真机 E2E 覆盖完成、Stop、steer、引用上下文与 thread 复用,且没有修改飞书生产适配器。

验证环境

macOS、Node.js 22、由 daemon 管理并运行在 loopback 上的 Channel worker、配置机器人后的阿里钉,以及用于兼容性 E2E 的飞书桌面客户端。钉钉真机运行采用 sessionScope=threaddispatchMode=steer、开放 sender/group/DM policy,并要求群内 @;这些是运行时配置选择,不是卡片架构要求。Linux 由 GitHub Ubuntu Node 22 CI 验证;Windows 未单独真机验证。

风险与范围

  • 主要风险或取舍:状态卡和提问卡 registry 仍是进程内状态。重启后卡片恢复、非粘性多 worker 回调路由属于后续工作;钉钉投递还依赖内置卡片模板与 Card OpenAPI 权限。
  • 未验证或不在范围内:钉钉当前不提供平台 threadId,因此 thread 类 scope 使用已有会话回退,不能宣称话题隔离。飞书继续使用原有通用权限降级处理 ask_user_question;本 PR 不增加飞书结构化提问卡。自由文本答案解析和跨平台身份联邦也不在范围内。
  • 破坏性变更或迁移说明:无。交互卡默认不启用,状态卡与提问卡可独立关闭,未实现共享展示 hook 的适配器保持原行为。

关联 Issue

关联 #6443

@BenGuanRan

BenGuanRan commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

I have incorporated the complete review feedback into commit feb965bb0. The revised design and architecture diagrams now separate the streaming status card from the form callback card and define the exact-run cancellation boundary, owner-only card actions, hook insertion and cause-aware settlement, multiple pending questions, first-responder-wins behavior, responder false-return handling, terminal lifecycle mapping, and observable degradation. The built-in template IDs remain channel-owned assets; the design does not add user template configuration or a startup health check. This PR remains a design-only Draft.

Comment thread docs/design/2026-07-15-dingtalk-interactive-cards.md Outdated
Comment thread docs/design/2026-07-15-dingtalk-interactive-cards.md Outdated
@wenshao
wenshao marked this pull request as ready for review July 15, 2026 03:56
@qwen-code-ci-bot qwen-code-ci-bot added type/documentation Documentation improvements or additions category/integration External integrations scope/interactive Interactive CLI features status/in-review This issue is currently in review. labels Jul 15, 2026
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: this is a feature addition, not a fix — DingTalk Markdown delivery genuinely cannot update a response in place, bind Stop to an exact prompt run, or return structured ask_user_question answers. Related to #6443. The limitation is real and observed by anyone using the DingTalk channel today.

Direction: aligned. DingTalk is an established integration with a steady stream of prior work in the CHANGELOG (webhook delivery, outbound images, mention handling, token refresh, emotion retry). Interactive cards are a natural next step for that surface, not a new direction.

Size: this PR spans four packages (channels/base, channels/dingtalk, channels/feishu, sdk-typescript). Breaking down the 7,889 changed lines: 2,280 production logic, 4,635 test, 974 docs/design. Because production logic exceeds both the 500-line maintainer-awareness threshold and the 1,000-line large-PR advisory, I'm flagging this for maintainer attention. The test-to-production ratio (~2:1) is healthy. If splitting is feasible — say, status cards first, question cards in a follow-up — it would make review easier, but the shared presentation contract infrastructure means the split boundary is not obvious.

Approach: the architecture is well-thought-out. A transport-neutral presentation hook in ChannelBase with unsupported default, two independent card lifecycles (status and question) with separate registries, owner-only actions, and projection-chain serialization to prevent cross-writes. The design docs are thorough. I don't see a materially simpler path that preserves the same safety guarantees — the settlement listeners, exact-run cancellation, and scope-based question expiry all earn their place.

Risk: no elevated risk signals — none of the changed files match the high-risk paths from the revert-history analysis.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:这是一个功能新增,不是修复——钉钉 Markdown 消息确实无法原地更新响应、无法将 Stop 绑定到精确的 prompt run,也无法返回结构化的 ask_user_question 答案。关联 #6443。该限制对使用钉钉通道的用户来说是真实可观察的。

方向:对齐。钉钉是一个成熟的集成,CHANGELOG 中有大量先前工作(webhook 投递、出站图片、@提及处理、token 刷新、emotion 重试)。交互卡是该表面的自然下一步,不是新方向。

规模:本 PR 跨越四个包(channels/basechannels/dingtalkchannels/feishusdk-typescript)。7,889 行变更中:2,280 行生产逻辑、4,635 行测试、974 行文档/设计。由于生产逻辑超过 500 行维护者关注阈值和 1,000 行大 PR 建议阈值,已标记维护者关注。测试与生产代码比(约 2:1)健康。如果可行——例如先状态卡、后续提问卡——可以拆分为更小的 PR,但共享展示契约基础设施意味着拆分边界并不明显。

方案:架构设计良好。ChannelBase 中的传输无关展示 hook(默认返回 unsupported)、两套独立卡片生命周期(状态卡和提问卡)及独立 registry、仅 owner 可操作、投影链串行化防止交叉写入。设计文档详尽。没有看到更简单的路径能在保持相同安全保证的前提下实现同样的功能——settlement listener、精确 run 取消、基于 scope 的提问过期机制都有其必要性。

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

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

Before reading the diff, my independent proposal for "add DingTalk interactive cards" was: (1) add an optional presentation hook to ChannelBase so adapters can intercept ask_user_question permissions and output segments, (2) attach run/segment identity to lifecycle events, (3) build a DingTalk Card OpenAPI client, (4) create status and question card controllers with owner-only authorization, (5) wire it through the adapter with degradation. The PR matches this almost exactly — the architecture is what I would have designed.

No critical blockers found. Specific observations:

Shared contract (ChannelBase, types.ts). The presentUserInputRequest hook with presented/handled/unsupported outcomes is clean. The normalizeUserQuestions validator is thorough — it handles canonical (qwenInteractionKind) and legacy (toolName/kind) producers, validates 1–4 questions with 2–4 options each, and rejects malformed payloads without partial rendering. Settlement listeners with one-shot semantics and the respondToUserInput coalescing (via responsePromise) prevent double-settlement. The output-segment lifecycle (ensureOutputSegmentcloseOutputSegmentnotifyOutputSegmentEnd) correctly threads through all terminal paths (completed, failed, cancelled, steer).

DingTalk controllers. The StatusCardController flush scheduling (500ms coalescing, write-chain serialization, streamFailed degradation) and the claimStop in-flight lock are well-implemented. The QuestionCardController scope-based expiry (newer run expires older card without sending a synthetic answer), forbidden-actor deduplication, and terminal projection reservation through the presenter's projection chain are correct. The DingtalkInteractionPresenter serialization layer prevents status output, question terminalization, and continuation output from cross-writing.

DaemonSessionClient.cancel() coalescing. Simple and correct — concurrent cancellation calls produce one daemon request.

sanitizeStreamingImageMarkers. Handles partial [IMAGE:...] markers at stream boundaries. The progressive regex is complex but the test coverage (37 test lines) exercises the edge cases.

Convention compliance. ESM throughout, no any types, kebab-case filenames, tests collocated with source, proper type imports. The isRecord helper in ChannelBase and asRecord in interactive-card-types.ts are near-duplicates, but they live in different packages and the duplication is trivial — not worth extracting.

sequenceDiagram
    participant U as DingTalk User
    participant A as DingtalkAdapter
    participant CB as ChannelBase
    participant P as InteractionPresenter
    participant SC as StatusCardController
    participant QC as QuestionCardController
    participant API as DingTalk Card API

    U->>A: send message
    A->>CB: dispatchPrompt (with runId and owner)
    CB->>A: text_chunk (with segment)
    A->>P: appendOutput
    P->>SC: append (coalesced)
    SC->>API: streaming update
    CB->>A: permission_request (ask_user_question)
    A->>CB: presentUserInputRequest
    CB->>P: presentInput
    P->>QC: present (create card)
    QC->>API: createAndDeliver
    U->>A: card callback (submit)
    A->>QC: claim
    QC->>CB: respond (structured answers)
    QC->>API: updateInstance (Submitted)
Loading
Files changed (26 of 26 shown)
File What changed
docs/design/2026-07-15-dingtalk-interactive-cards.md Design doc: architecture, payload contract, lifecycle, degradation, acceptance criteria
docs/design/2026-07-25-channel-interaction-presentation-contract.md Design doc: shared presentation contract specification
docs/design/assets/dingtalk-interactive-cards-architecture.png Architecture diagram
docs/design/assets/dingtalk-interactive-cards-other-im-extension.png Future adapter extension diagram
docs/design/assets/dingtalk-interactive-cards-other-im-impact.png Compatibility and degradation diagram
packages/channels/base/src/ChannelBase.ts Run identity, output segments, presentation hook, settlement, exact-run cancellation
packages/channels/base/src/ChannelBase.test.ts 1263 new test lines covering all shared contract paths
packages/channels/base/src/types.ts Shared types: owner, segment context, question, presentation result
packages/channels/base/src/index.ts Re-exports for new shared types
packages/channels/dingtalk/src/DingtalkAdapter.ts Card callback ingress, lifecycle wiring, owner tracking, response overrides
packages/channels/dingtalk/src/DingtalkAdapter.test.ts 1007 new test lines for adapter integration
packages/channels/dingtalk/src/interaction-presenter.ts Projection-chain serialization between status and question cards
packages/channels/dingtalk/src/interaction-presenter.test.ts 606 test lines for presenter orchestration
packages/channels/dingtalk/src/interactive-card-client.ts DingTalk Card OpenAPI client (create, stream, update)
packages/channels/dingtalk/src/interactive-card-client.test.ts 155 test lines for API client
packages/channels/dingtalk/src/interactive-card-types.ts Config parsing, callback parsing, actor ID extraction
packages/channels/dingtalk/src/interactive-card-types.test.ts 171 test lines for parsing logic
packages/channels/dingtalk/src/outbound-image.ts Streaming image marker sanitization
packages/channels/dingtalk/src/outbound-image.test.ts 37 test lines for partial marker handling
packages/channels/dingtalk/src/question-card-controller.ts Question card lifecycle: present, claim, expire, finalize
packages/channels/dingtalk/src/question-card-controller.test.ts 760 test lines for question card
packages/channels/dingtalk/src/status-card-controller.ts Status card lifecycle: create, stream, stop, terminalize
packages/channels/dingtalk/src/status-card-controller.test.ts 464 test lines for status card
packages/channels/feishu/src/adapter.test.ts 2 test lines confirming Feishu default unsupported path
packages/sdk-typescript/src/daemon/DaemonSessionClient.ts Cancel coalescing: concurrent calls produce one daemon request
packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts 73 test lines for cancel coalescing

Testing

Check Conclusion
Test (ubuntu-latest, Node 22.x) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
precheck-pr / precheck ✅ success
Classify PR ✅ success
label ✅ success
Test (macos-latest, Node 22.x) ⏭️ skipped
Test (windows-latest, Node 22.x) ⏭️ skipped
Integration Tests (CLI, No Sandbox) ⏭️ skipped

The Ubuntu Node 22 full test suite passed. macOS, Windows, and integration tests were skipped — this is expected for fork PRs where secrets are unavailable. No failures.

The PR's central claim is behavioural — DingTalk interactive cards with streaming, Stop, and structured answers. The sandboxed verification lanes (@qwen-code /verify, @qwen-code /tmux) are unavailable for this PR because the author does not have write access to the repository. A maintainer reviewing this should check the PR out in a disposable container or reproduce the specific behavioural claims (streaming status card, owner-only Stop, structured question submission) by hand against a DingTalk test robot.

Not verified: real-device DingTalk card behaviour (streaming updates, callback routing, owner isolation, question timeout) — the author reports real-device E2E coverage, but this is the author's claim, not independently re-run.

中文说明

代码审查

在阅读 diff 之前,我对"添加钉钉交互卡"的独立方案是:(1) 在 ChannelBase 添加可选展示 hook,让适配器可以拦截 ask_user_question 权限和输出段;(2) 在生命周期事件中附加 run/segment 身份;(3) 构建钉钉 Card OpenAPI 客户端;(4) 创建状态卡和提问卡控制器,带仅 owner 授权;(5) 通过适配器连接并支持降级。PR 几乎完全匹配这个方案——架构与我的设计一致。

未发现关键阻塞问题。具体观察:

共享契约(ChannelBasetypes.ts)。 presentUserInputRequest hook 的 presented/handled/unsupported 三种结果设计清晰。normalizeUserQuestions 验证器详尽——处理规范(qwenInteractionKind)和遗留(toolName/kind)生产者,验证 1-4 个问题(每个 2-4 个选项),拒绝格式不完整的 payload 而不部分渲染。Settlement listener 的一次性语义和 respondToUserInput 合并(通过 responsePromise)防止双重结算。输出段生命周期(ensureOutputSegmentcloseOutputSegmentnotifyOutputSegmentEnd)正确贯穿所有终态路径(completed、failed、cancelled、steer)。

钉钉控制器。 StatusCardController 的刷新调度(500ms 合并、写链串行化、streamFailed 降级)和 claimStop 进行中锁实现良好。QuestionCardController 的基于 scope 的过期(新 run 使旧卡过期但不发送合成答案)、禁止操作者去重、以及通过 presenter 投影链的终态投影预留都是正确的。DingtalkInteractionPresenter 串行化层防止状态输出、提问终态和继续输出交叉写入。

DaemonSessionClient.cancel() 合并。 简单正确——并发取消调用产生一次 daemon 请求。

sanitizeStreamingImageMarkers 处理流边界处的部分 [IMAGE:...] 标记。渐进正则复杂但测试覆盖(37 行测试)验证了边界情况。

规范合规。 全程 ESM,无 any 类型,kebab-case 文件名,测试与源码同目录,正确的类型导入。ChannelBase 中的 isRecordinteractive-card-types.ts 中的 asRecord 近乎重复,但它们在不同包中且重复很小——不值得提取。

上方序列图展示了从用户消息到状态卡流式更新、再到提问卡提交的关键运行时路径。

测试

Ubuntu Node 22 全量测试套件通过。macOS、Windows 和集成测试被跳过——这对 fork PR 是预期行为(secrets 不可用)。无失败。

本 PR 的核心声明是行为性的——钉钉交互卡的流式更新、Stop 和结构化答案。沙盒验证通道(@qwen-code /verify@qwen-code /tmux)对本 PR 不可用,因为作者没有仓库写权限。审查此 PR 的维护者应在一次性容器中检出 PR,或针对钉钉测试机器人手动复现具体行为声明(流式状态卡、仅 owner Stop、结构化提问提交)。

未验证:真机钉钉卡片行为(流式更新、回调路由、owner 隔离、提问超时)——作者报告了真机 E2E 覆盖,但这是作者的声明,未独立重新运行。

Qwen Code · qwen3.8-max-preview

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — clean review, but the cross-package scope and 2,280 production lines trigger the maintainer-awareness escalation, so this needs a human sign-off regardless of code quality.

Stepping back: this is a well-executed PR. The architecture matches my independent proposal almost exactly — I couldn't find a materially simpler path. The shared presentation contract is genuinely transport-neutral (Feishu gets two test lines confirming the default unsupported path, zero production changes). The two card lifecycles are independent but share the right infrastructure. The settlement semantics are careful — every path that removes a pending permission settles exactly once, and the question controller never sends a synthetic answer when expiring an old card. The test coverage is strong at a 2:1 test-to-production ratio.

If I had to maintain this in six months, I'd thank the author — the design docs explain the why, the state machines are bounded, and the degradation paths are explicit. The code doesn't try too hard; each controller does one thing.

Why 3/5 and not higher: the Stage 0 escalation is pure policy — 2,280 production logic lines across four packages, with 460 of those in the shared ChannelBase class that every channel adapter inherits. That's the kind of change where a maintainer should confirm the contract surface is what they want long-term, especially the ChannelOutputSegmentContext and ChannelUserInputRequestContext types that become part of the public channel-base API. The code quality doesn't warrant doubt; the scope warrants a human decision.

⏸️ Deferring to @wenshao — the review is clean and CI is green, but the cross-package scope and shared-contract additions need a maintainer's architectural sign-off before merge.

中文说明

置信度:3/5 —— 审查干净,但跨包范围和 2,280 行生产逻辑触发了维护者关注升级,因此无论代码质量如何都需要人工签核。

退后一步看:这是一个执行良好的 PR。架构几乎完全匹配我的独立方案——我找不到更简单的路径。共享展示契约确实是传输无关的(飞书只有两行测试确认默认 unsupported 路径,零生产变更)。两套卡片生命周期独立但共享正确的基础设施。Settlement 语义谨慎——每条移除待处理权限的路径都恰好结算一次,提问控制器在过期旧卡时绝不发送合成答案。测试覆盖强,测试与生产代码比为 2:1。

如果六个月后我要维护这段代码,我会感谢作者——设计文档解释了 why,状态机有界,降级路径明确。代码没有过度设计;每个控制器只做一件事。

为什么是 3/5 而不是更高:Stage 0 升级是纯策略——2,280 行生产逻辑跨四个包,其中 460 行在共享的 ChannelBase 类中(每个通道适配器都继承)。这类变更需要维护者确认契约表面是否是他们长期想要的,特别是 ChannelOutputSegmentContextChannelUserInputRequestContext 类型将成为 channel-base 公共 API 的一部分。代码质量不值得怀疑;范围需要人工决定。

⏸️ 转交 @wenshao —— 审查干净且 CI 绿色,但跨包范围和共享契约新增需要维护者的架构签核才能合并。

Qwen Code · qwen3.8-max-preview

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

@BenGuanRan
BenGuanRan marked this pull request as draft July 15, 2026 04:02

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

Not reviewed: chunk 1 — no agent reported covering these; nobody read them.

— qwen3.7-max via Qwen Code /review

Comment thread docs/design/2026-07-15-dingtalk-interactive-cards.md Outdated
Comment thread docs/design/2026-07-15-dingtalk-interactive-cards.md Outdated
Comment thread docs/design/2026-07-15-dingtalk-interactive-cards.md Outdated
Comment thread docs/design/2026-07-15-dingtalk-interactive-cards.md Outdated
Comment thread docs/design/2026-07-15-dingtalk-interactive-cards.md Outdated
Comment thread docs/design/2026-07-15-dingtalk-interactive-cards.md Outdated
Comment thread docs/design/2026-07-15-dingtalk-interactive-cards.md Outdated

All capabilities are enabled by default. The question-card lifetime is configurable but cannot outlive the upstream permission request, so the effective timeout is the smaller of the configured timeout and the host permission lifetime.

The initial implementation uses the existing template IDs from `soimy/openclaw-channel-dingtalk` as DingTalk-internal constants:

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] These template UUIDs belong to an external third-party repository (soimy/openclaw-channel-dingtalk) and are treated as stable constants, but DingTalk templates can be deprecated, renamed, or deleted by either DingTalk or the upstream repo owner. No health check, versioning pin, or fallback is documented. — Concrete cost: if the upstream repo updates or deletes a template, card operations silently fail and trigger the degradation path without any alert.

Document that these template IDs must exist in the deployer's DingTalk organization. Define a startup health check or first-use probe that logs a clear error if a template is unavailable.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I am not adopting user-supplied template configuration or a startup health check. The built-in status template is 675cde2f-f526-40cb-b828-f5b2b57b8b77.schema, and the built-in question template is c2a6355b-9724-4f7e-9653-d33fcb3311bb.schema. #583 is merged and records real-device delivery, submit callback, cancel callback, and task-continuation verification. #585 is merged, ships the final template asset, and was approved by the maintainer. First-use OpenAPI failures are now explicitly structured, template-aware, and routed through the documented fallback rather than being silent.

Comment thread docs/design/2026-07-15-dingtalk-interactive-cards.md Outdated

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] Stop cancellation: the design says the Stop callback validates owner/generation and then 'reuses active prompt cancellation', but generation validation (adapter-side) and the actual session-scoped cancellation are not atomic. A stale Stop callback can validate its old card and then cancel a newer run that started in the same session, so the doc's claim that 'a stale card cannot stop a newer run' is not established by the described mechanism. Make run identity authoritative so cancellation atomically checks the expected run against the current active prompt. (Prior Critical thread at line 83 remains unaddressed in the current doc.)

[Critical] Multiple pending questions: the status-card transition 'Successful submission returns it to running' assumes at most one pending question, but ChannelBase supports multiple simultaneous permission requests for the same session/chat. Submitting one question returns the status card to running while another question still blocks the run. Track pending question request IDs per run and derive waiting_input from the whole set, leaving it only after the last resolves. (Prior Critical thread at line 97 remains unaddressed in the current doc.)

— qwen-latest-series-invite-beta-v77 via Qwen Code /review

Comment thread docs/design/2026-07-15-dingtalk-interactive-cards.md Outdated
Comment thread docs/design/2026-07-15-dingtalk-interactive-cards.md Outdated
Comment thread docs/design/2026-07-15-dingtalk-interactive-cards.md Outdated

All capabilities are enabled by default. The question-card lifetime is configurable but cannot outlive the upstream permission request, so the effective timeout is the smaller of the configured timeout and the host permission lifetime.

The initial implementation uses the existing template IDs from `soimy/openclaw-channel-dingtalk` as DingTalk-internal constants:

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] These template IDs are taken from a third-party project (soimy/openclaw-channel-dingtalk), but DingTalk interactive-card templates are scoped to the DingTalk app (AppKey/AppSecret) that registered them, so they are unlikely to resolve under Qwen Code's own DingTalk app credentials. — Failure scenario: on the first ask_user_question, card creation fails (template not found/authorized) and, per the degradation table, the design silently renders Markdown and cancels the question — so 100% of questions become "your question was cancelled, please retype," the headline feature is non-functional out of the box, and the silent fallback hides a total failure rate. Confirm the templates are registered to Qwen Code's DingTalk app (or recreate them under it), validate the configured template IDs resolve under the active credentials at startup, and treat a template/config error as a loud, observable fault rather than the silent cancel-and-retype fallback.

— qwen-latest-series-invite-beta-v77 via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I am not adopting user-supplied template configuration or a startup health check. The built-in status template is 675cde2f-f526-40cb-b828-f5b2b57b8b77.schema, and the built-in question template is c2a6355b-9724-4f7e-9653-d33fcb3311bb.schema. #583 is merged and records real-device delivery, submit callback, cancel callback, and task-continuation verification. #585 is merged, ships the final template asset, and was approved by the maintainer. First-use OpenAPI failures are now explicitly structured, template-aware, and routed through the documented fallback rather than being silent.

@wenshao

wenshao commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Review

Overview

Design-only draft: one new doc (docs/design/2026-07-15-dingtalk-interactive-cards.md, 304 lines) plus three architecture PNGs. It specifies two independent DingTalk card lifecycles (streaming status card per Channel-owned run, form callback card per ask_user_question request), a channel-neutral presentUserInputRequest seam in ChannelBase with presented/handled/unsupported outcomes, and an exact-run cancellation boundary. No runtime change.

Claims verified against main

The doc makes many specific assertions about existing code; I checked the load-bearing ones and they are all accurate in the current revision:

  • Hook insertion point matches reality: dispatchPermissionRequest stores the pending permission before the format-and-send path (ChannelBase.ts:347-369), and multiple pending permissions per chat exist via pendingPermissionsByChat (ChannelBase.ts:294).
  • AcpBridge does emit permissionResolved synchronously before a successful respondToPermission() returns true (AcpBridge.ts:283-287), so the deferred answered_elsewhere arbitration is solving a real ordering problem, not a hypothetical one.
  • The daemon bridge does consume the request mapping when the responder throws (DaemonChannelBridge.ts:474-478) and does drop permissionResolved for unknown requests (DaemonChannelBridge.ts:784-789), so treating a later permissionResolved as unreliable cleanup after a throw is correct.
  • lifecycleGeneration bumps on session lifecycle, not per prompt (DaemonChannelBridge.ts:482), so the new per-prompt runId is genuinely needed; ActivePrompt has no run identity today (ChannelBase.ts:160-189).
  • DingTalk already prefers senderStaffId with senderId fallback (DingtalkAdapter.ts:1305), and Stream connectivity plus the TOPIC_ROBOT callback ingress exist (DingtalkAdapter.ts:243).
  • ChannelBase removes the pending request when the responder throws (ChannelBase.ts:1862-1871), and the neutral "Permission request is no longer pending" wording for a false responder result matches the existing command path (ChannelBase.ts:1882).

Remaining gaps to close before implementation

  1. The legacy /approve///deny path stays live behind a presented card, and the doc never says what that does to the card. presented keeps the pending permission registered, so it remains findable by the permission commands, and /deny always applies to any pending request — denialResponse falls back to {outcome: 'cancelled'} when there is no reject option (ChannelBase.ts:1766-1784). An in-chat /deny then reaches DingTalk as an independent settlement with no local claim, which the current tables project as externally_resolved / "Handled in another client". That label is wrong twice for this case: it happened in this client, and it was a cancellation, not an answer. Suggest (a) stating explicitly whether the commands remain an answer/cancel path for card-presented questions, and (b) using the settlement outcome (PermissionResolvedEvent carries it) to split "answered on another surface" from "cancelled/denied elsewhere" instead of one blanket projection. Related: the answered_elsewhere settlement reason has the same conflation — a cancelled outcome arriving without a claim shouldn't read as "answered".

  2. Singular questionId vs. 1–4 questions per request. One ask_user_question request carries an array of questions, each with its own answerKey (bridgeTypes.ts:302-319; the tool schema allows 1–4 questions). The pending-record field list (requestId, questionId, outTrackId, runId) and callback step 1 ("correlate the request, question, and run") use the singular. Clarify that one card renders the request's full question set and the registry keys by requestId; drop or pluralize questionId.

  3. waiting_input only tracks question cards, not ordinary permission requests. A run blocked on a normal tool approval will display running on the status card while nothing is actually progressing. Either include ordinary pending permissions in the run-level pending set, or state the exclusion and its rationale in one sentence — otherwise the implementation will pick silently.

  4. Late settlement on an already-terminal record needs an explicit row. On the daemon bridge, a successful submit still yields a later permissionResolved (the mapping survives in respondedRequestToSession, DaemonChannelBridge.ts:781-815), arriving after the claim is released and the record is terminal submitted. The claim-section prose ("late results cannot overwrite…") implies it is ignored, but the degradation/projection tables only cover late callbacks. Add a row: settlement event on a terminal record → ignore via tombstone. This makes the daemon case as airtight as the AcpBridge case.

Minor

  • The seam sketch uses PermissionRequest/PermissionResponse, but the real types are PermissionRequestEvent['request'] and RequestPermissionResponse. Fine for a sketch; either align the names or mark them illustrative so the implementation PR isn't reviewed against phantom types.
  • Asset location: the one existing precedent keeps images in a per-doc directory (docs/design/standalone-clipboard-native-addon/assets/); this PR introduces a shared docs/design/assets/. Either works, but per-doc keeps the directory from accumulating unrelated images. The ~600 KB total is acceptable; the doc correctly uses relative image paths.
  • Structure: "Chapter 1/2/3" headings with ~15 unnumbered top-level sections between Chapters 1 and 2 reads oddly; consider plain section titles.
  • Corner case worth a sentence: when a question card is presented and an ordinary permission is also pending in the same chat, /approve hits the ambiguous-lookup path and demands an explicit request id — but the question's Markdown (which would have shown its id) was never sent. Existing behavior, just newly reachable.

Verdict

The current revision is internally consistent and its code-level claims are accurate — the earlier review rounds clearly converged. Items 1 and 4 are the ones I'd want answered in the doc before implementation starts, since both change observable card behavior; 2 and 3 are spec precision. No security concerns: owner-only actions fail closed, and template IDs are public assets, not secrets.

@BenGuanRan

Copy link
Copy Markdown
Collaborator Author

Updated in 3363ac3b9, with the contract rechecked against origin/main@38429bc100a7.

  1. Legacy commands behind a presented question card. The current base Channel registers the pending request before formatting or sending it (ChannelBase.ts:347-369), and /approve, /approve-always, and /deny resolve from that same registry (ChannelBase.ts:1643-1680, 1786-1884). Those commands send only an option/cancel outcome, while structured answers are a separate response payload consumed by the ACP session (Session.ts:5216-5264). The design now records that a structured request was presented: those commands remain recognized for that request but do not invoke the responder and instead direct the user to submit or cancel through the card. Ordinary permission commands are unchanged. The existing ambiguous-request response already lists request IDs and titles (ChannelBase.ts:1799-1813), so I did not add another request-ID field to the card.

  2. One request can contain multiple questions. The current schema allows one to four questions (askUserQuestion.ts:84-88), and the bridge derives each answerKey from its array index (bridgeClient.ts:123-138). The design now uses one card per requestId, renders the complete question set, removes the singular questionId, and tracks a run-level set of pending question request IDs.

  3. waiting_input scope. It now explicitly means only “this DingTalk-owned run has a pending DingTalk question card.” Ordinary permissions keep the existing Markdown/command path and leave the status card at running. A broader shared permission-lifecycle signal is outside this PR rather than being inferred from a DingTalk-local registry.

  4. Late settlement after a terminal record. The in-process bridge emits permissionResolved synchronously before a successful responder returns (AcpBridge.ts:272-287), so a matching settlement is deferred while the local claim completes. The daemon bridge instead retains accepted-response routing (DaemonChannelBridge.ts:465-473) and emits the later settlement through that routing (DaemonChannelBridge.ts:781-815); the matrix now has an explicit terminal-record row that ignores this event through the tombstone. Terminal state wins in both paths.

The settlement label is now neutral and outcome-aware: cancelled or the original reject outcome maps to request_cancelled; another or missing outcome maps to resolved_outside_card, without claiming which client responded. The event already carries the response outcome (ChannelAgentBridge.ts:61-64). The type sketch also now uses the current request/response types and explicitly extends the response with the structured answers payload.

This remains a design and architecture-asset update only; no runtime code was added.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No blocking issues. The seven prior Criticals on this design (the Stop / exact-run cancellation race, multiple pending questions, undefined run generation, action-owner identity, the hook insertion point + settlement signal, and the false-responder path) are all addressed in the current document, and its source-code claims check out against origin/main. Two minor inline clarifications remain on the settlement-reason classification and the pendingQuestionRequestIds maintenance invariant.

— qwen-latest-series-invite-beta-v77 via Qwen Code /review

format and send the existing permission message
```

Every path that removes a pending permission settles the controller exactly once. This includes permission commands, a direct responder call, daemon `permissionResolved`, timeout, session cleanup, task cancellation, and bridge replacement. `ChannelBase` classifies an independent `permissionResolved` from its `outcome` before removing the pending request: `cancelled`, or a selected option whose original permission option is `reject_once`, becomes `request_cancelled`; any other or missing outcome becomes the neutral `resolved_outside_card`. This classification does not guess which client responded.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The settlement-reason enum defines run_cancelled and expired, and the question-card table projects distinct messages for "Request or run destroyed" (Cancelled/Stopped) and "Timeout" (Expired). But this classification rule maps a cancelled outcome to request_cancelled, and a run Stop or host timeout also surfaces as a permissionResolved with { outcome: 'cancelled' } — the bridge collapses richer reasons (DaemonChannelBridge.parsePermissionOutcome emits only cancelled/selected; AcpBridge's timeout and resolvePendingPermissions both resolve pending with { outcome: 'cancelled' }).

Concrete cost: an implementer following this rule classifies run-cancellations and timeouts as request_cancelled → "Cancelled outside this card", so the question card contradicts the status card's "Stopped", and run_cancelled/expired are never produced. The doc says these causes "take precedence", but the rule as written doesn't say they bypass outcome classification. Suggest clarifying, e.g.:

A run cancellation, session cleanup, or card timeout settles the controller with run_cancelled / expired directly (these take precedence over the bridge's collapsed cancelled outcome); the outcome-classification rule applies only to settlements that arrive without such a local cause.

— qwen-latest-series-invite-beta-v77 via Qwen Code /review

- Per-card serialized update queues, transient in-flight claims, and terminal tombstones.
- DingTalk-local fallback and structured error reporting.

The status registry also keeps `pendingQuestionRequestIds: Set<string>` for each run. The question registry does not supersede an older request merely because a newer request exists in the same session.

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] Controller settlement is an explicit invariant ("every path that removes a pending permission settles the controller exactly once"), but the parallel pendingQuestionRequestIds maintenance is left implicit — even though the doc stresses the two registries are independent.

Concrete cost: an implementer can correctly project a question card to a terminal state yet forget to remove its requestId from the run's set on a non-submit path (timeout, resolved_outside_card, throw). The set never empties, so the status card is stranded at waiting_input for an otherwise-active run. Suggest adding a parallel invariant, e.g.:

Every question-settlement path (submit, cancel, false, throw, independent settlement, timeout, request/run destroyed) also removes the requestId from its run's pendingQuestionRequestIds and re-derives waiting_input; a terminal status card ignores further set mutations.

— qwen-latest-series-invite-beta-v77 via Qwen Code /review

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not reviewed: reverse-audit — stopped at the five-round hard cap after round 4 found a new Critical; round 5 was dry, but two consecutive dry rounds were not reached.

— Codex $qreview via Qwen Code /review


## Exact-run identity and cancellation

Every prompt invocation creates an opaque unique `runId` and stores it on the corresponding `ActivePrompt`. It is not the daemon lifecycle generation, which changes for session lifecycle operations rather than every prompt.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] The runId is stored only on private ActivePrompt state, while DingTalk creates status UI from lifecycle/prompt hooks whose current contracts expose neither that token nor the owner. A literal implementation therefore cannot key the registry or embed the exact token checked by ChannelBase: sessionId aliases consecutive runs, and an adapter-local ID never matches. Add a shared prompt-lifecycle context carrying the Channel-generated runId and typed owner, emit the same value through start/chunk/terminal events, and use it in the card registry, callback payload, question context, and exact-run cancellation.

— Codex $qreview via Qwen Code /review


Every path that removes a pending permission settles the controller exactly once. This includes permission commands, a direct responder call, daemon `permissionResolved`, timeout, session cleanup, task cancellation, and bridge replacement. `ChannelBase` classifies an independent `permissionResolved` from its `outcome` before removing the pending request: `cancelled`, or a selected option whose original permission option is `reject_once`, becomes `request_cancelled`; any other or missing outcome becomes the neutral `resolved_outside_card`. This classification does not guess which client responded.

The hook is only eligible for the current Channel-owned `ActivePrompt`. When no such prompt, `runId`, or owner exists, `ChannelBase` does not construct the context or invoke the hook; it treats presentation as `unsupported` and continues the existing permission path. A run started by CLI, Web, IDE, SDK, or another client therefore creates neither DingTalk card. The initial design does not add cross-client run ownership or identity federation.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] Channel-owned ActivePrompt also includes loop and webhook producers. Those unattended runs emit normal lifecycle events and may carry synthetic owners such as webhook:github-ci; if one presents an ask_user_question card, no DingTalk user can satisfy the owner check and the disabled legacy commands leave the run blocked until timeout. Define eligibility as an inbound human-owned DingTalk turn and explicitly exclude loopPrompt and webhook tasks unless a separate unattended-card contract is designed.

— Codex $qreview via Qwen Code /review

4. Synchronously claim the current live record before the first asynchronous operation.
5. Call the original responder.
6. If the same record is still current and non-terminal, update the card from the responder result.
7. Acknowledge the callback.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] This order leaves the DingTalk transport acknowledgment pending across both the permission responder and a Card OpenAPI update. If either await is slow or hangs, DingTalk can retry or report delivery failure after the local record is claimed or the permission has already settled. Acknowledge immediately after parsing, correlation, owner validation, and the synchronous claim—before the first external await—and reserve semantic success/failure for the card projection.

— Codex $qreview via Qwen Code /review

| Another IM adapter owns the session | Return `unsupported` and preserve its existing permission message and commands. |
| Ordinary permission | Keep `/approve`, `/approve-always`, and `/deny` unchanged; it does not affect the question-only `waiting_input` presentation state. |

For a card-presented question, `/approve`, `/approve-always`, and `/deny` remain recognized commands but do not call the responder; they instruct the user to submit or cancel through the card. The card is the only DingTalk-local settlement surface for that presented request. This is required because the existing permission commands supply only an option ID or cancellation outcome, while a question submission consumes a separate `answers` object. Other permissions and adapters keep their current command behavior. The initial design does not promise automatic callback retry.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] The structured-answer limitation applies to approval, but not to denial: ChannelBase.denialResponse() already produces a complete reject/cancel outcome without an answers object. If the card is visible but its callback delivery is broken, blocking /deny removes the only working settlement path and leaves the run pending until timeout. Preserve an owner-authorized /deny path through the one-shot responder and restrict only approval commands that cannot supply the required answers.

— Codex $qreview via Qwen Code /review


| Situation | Behavior |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Status card disabled or creation/update fails | Continue the same turn with existing Markdown delivery and record a structured card error. |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] The promised fallback has no compatible awaited delivery seam. Today ChannelBase awaits Markdown/block-stream delivery before emitting completed, while onTaskLifecycle is fire-and-forget. Keeping that delivery duplicates successful card output; suppressing it means a terminal card-write failure occurs after the last awaited delivery point, too late to provide the promised fallback consistently. Add a card-aware awaited terminal-delivery hook that performs the final card write, falls back to Markdown, and completes only after one path succeeds; define the corresponding block-streaming behavior as well.

— Codex $qreview via Qwen Code /review

| { kind: 'handled' }
| { kind: 'unsupported' };

type UserInputSettlementReason =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The union does not type-check the actual settlement channel because AbortSignal.reason is any. A misspelled reason can compile and bypass the consumer's projection switch. Route settlement through a typed helper/wrapper that accepts UserInputSettlementReason (or expose a typed callback) instead of making callers independently cast a bare abort reason.

— Codex $qreview via Qwen Code /review


## Risks and scope boundaries

The first implementation is intentionally daemon-local. Pending-card registries and tombstones are tied to the process lifetime; restart-safe recovery and non-sticky multi-instance callback routing require a separate persistence design.

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] Terminal tombstones have no TTL, capacity bound, compaction, or reclamation rule, so a literal process-lifetime implementation retains history proportional to all completed cards. Define a callback-redelivery retention window plus a capped LRU/size bound, and compact terminal records after clearing timers, subscriptions, responder references, question payloads, and queued content.

— Codex $qreview via Qwen Code /review


1. Call `createAndDeliver` with a unique `outTrackId` and initial `flowStatus=2`.
2. Open streaming with an empty full update using `isFull=true`, `isFinalize=false`, and `isError=false`.
3. Send high-frequency model output through `/card/streaming`.

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] Raw model chunks can arrive faster than /card/streaming completes, but the serialized queue has no throttling, coalescing, backpressure, or bound. A long response can therefore grow memory and delay terminal finalization behind one request per chunk. Specify an at-most-one-in-flight writer with a bounded coalescing buffer, capped flush cadence/size, and an overflow/degradation path; fold pending text into the terminal update instead of draining every original chunk.

— Codex $qreview via Qwen Code /review

@wenshao

wenshao commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Review — design-only draft (docs + 3 architecture PNGs)

Overview

This PR adds docs/design/2026-07-15-dingtalk-interactive-cards.md defining three DingTalk interactions: an in-place streaming status card per attended Channel-owned prompt run, an owner-only exact-run Stop, and a form callback card that returns structured ask_user_question answers to the original permission request. No runtime code changes. Location and naming follow the docs/design/YYYY-MM-DD-*.md convention prescribed by AGENTS.md.

Source-claim verification

The doc's "Source constraints verified" section makes precise claims about existing code. I re-verified every checkable claim against current origin/main (b826420d4; the doc's referenced 401170d48889 is a real ancestor). All claims held:

Claim Evidence
Pending permission registered (incl. per-chat index) before the Markdown prompt is formatted/sent ChannelBase.ts:366-405 (pendingPermissions.set + pendingPermissionsByChat precede formatPermissionRequest/send)
Multiple pending requests per chat; drives /approve, /approve-always, /deny; ambiguity reply lists request IDs + titles ChannelBase.ts:330,1825+,1836-1849
PermissionResolvedEvent carries outcome ChannelAgentBridge.ts:61-63
AcpBridge emits permissionResolved synchronously before the responder returns true AcpBridge.ts:272-287
Daemon bridge retains a responded-request mapping, consumes the request→session mapping on responder throw, emits settlement later DaemonChannelBridge.ts:455-480,816
ask_user_question permits 1–4 questions askUserQuestion.ts:318-319
Canonical _meta.qwenInteractionKind === 'user_question' + qwenQuestions permissionUtils.ts:77-78, bridgeClient.ts:122-126
answerKey: String(index) exists only in the bridge's pending-interaction snapshot, not the live request bridgeClient.ts:138
ACP session consumes top-level answers: Record<string, string> Session.ts:5262-5336
Multi-select answers joined with ", " in existing clients AskUserQuestionDialog.tsx:92,135
Empty answer map → No valid answers were provided. askUserQuestion.ts:265
Advertised option is proceed_once with kind: 'allow_once'; the doc's compat clause (accept proceed_once only when kind is absent) mirrors the existing rule verbatim permissionUtils.ts:207-218, ChannelBase.approvalOptionId at ChannelBase.ts:1752-1761
ActivePrompt + loopPrompt flag; loop/webhook prompts set it; cancellation is session-scoped; no runId exists anywhere in channels/base (the proposed run identity is genuinely new) ChannelBase.ts:189-198,856,1142,1353,1602-1612
Lifecycle union is started/text_chunk/tool_call/completed/failed/cancelled, no run identity types.ts:138-181
onResponseComplete() is an awaited overridable seam (docstring even says "e.g., finalize an AI card") and completes before completed is emitted ChannelBase.ts:1558-1569,4332→4347
blockStreaming?: 'on' | 'off'; BlockStreamer in base types.ts:78, BlockStreamer.ts
DingTalk prefers senderStaffId, falls back to senderId; 1,000-entry inbound-message cap; Stream connectivity + generic topic-based callback ingress; no existing card/outTrackId/interactiveCards code DingtalkAdapter.ts:1237-1240,750,243-303
Top-level answers already survives the daemon permission route (body is spread, not whitelisted) — supports the "No change: daemon routes" row request-helpers.ts:283-297 (parsePermissionVoteBody returns {...(body as object), outcome})

The trickiest passage — claim arbitration around AcpBridge's synchronous permissionResolved during a responder call, versus the daemon bridge's late settlement — is grounded in real, verified bridge behavior rather than assumed semantics. That section reads as designed against the code, not around it.

Design assessment

  • Change boundary is disciplined. One shared-Channel seam (presentUserInputRequest with presented/handled/unsupported), everything else DingTalk-local; the "No change" rows are accurate. Other IM adapters inherit unsupported and are untouched.
  • Reuses the existing response contract (selected + advertised allow_once option + top-level answers) instead of inventing a synthetic-message path — explicitly diverging from the OpenClaw reference implementation on its two weakest points (message reinjection, single-question supersede).
  • Race handling is thorough: ACK-before-await callback order, synchronous claim, exact-run atomic cancellation with no session-scoped fallback, first-responder-wins preserved, finalizeQuestion as the single terminal invariant, and a deliberate refusal to guess causes the bridge can't distinguish (no false expired, no "which client won" claims).
  • Bounded state throughout: coalesced single in-flight writes, 20k-char content cap, 10s OpenAPI timeout, 10-min/1,000-entry tombstones. The process-local registry limitation is honestly declared as the main risk.
  • /deny escape hatch while /approve is blocked for card-presented questions is the right call — approval genuinely cannot carry answers, denial genuinely doesn't need them.
  • Acceptance criteria are concrete and testable, split by changed layer, and include real-device verification.

Findings (all minor)

  1. run_cancelled: reason vs. state ambiguity. UserInputSettlementReason includes run_cancelled, but the question-card lifecycle table (Chapter 1: pending, submitted, cancelled, expired, resolved_outside_card) and the event table ("Request or run destroyed → cancelled") omit it, while two prose passages say a locally known run cancellation "finalizes as run_cancelled" as if it were a terminal local state. Suggest one clarifying sentence: run_cancelled is a settlement reason projected onto the cancelled state with the "Stopped" presentation (or add it to the state list — either way, make the tables and prose agree).
  2. Owner-map eviction race is undocumented. The messageId → typed owner map follows the existing 1,000-entry cap, and a started event consumes its entry. Under load, eviction between the inbound message and started would silently make the run ineligible for cards. The degradation is safe (existing Markdown path), but the doc should state this explicitly, and the adapter test list could include "evicted owner mapping ⇒ no card, existing path preserved" so implementations don't treat it as a bug.
  3. Template-ID portability is the riskiest external assumption. The design embeds two template IDs as built-in assets and asserts they work with the installing bot's own credentials, citing the reference-repo PRs. The loud first-use error + degradation is the right mitigation, but I'd make "templates usable under third-party app credentials" the first checkpoint of the implementation spike, before any adapter work depends on it.

Conclusion

Well-grounded design: every verifiable claim about the existing code is accurate (several to the exact line), the shared-layer surface is minimal, and the failure/race semantics are specified to an implementable level. The three findings above are documentation clarifications and one early-validation ask — none block accepting the design.

中文完整版

评审 — 仅设计草案(文档 + 3 张架构图)

概述

本 PR 新增 docs/design/2026-07-15-dingtalk-interactive-cards.md,定义三项钉钉交互:每次人工入站 Channel prompt run 的原地流式状态卡、owner-only 精确 run Stop,以及把结构化 ask_user_question 答案返回原 permission request 的表单回调卡。不含运行时代码改动。文件位置与命名符合 AGENTS.md 规定的 docs/design/YYYY-MM-DD-*.md 约定。

源码断言核验

文档"Source constraints verified"一节对现有代码做了非常具体的断言。我在当前 origin/mainb826420d4;文档引用的 401170d48889 确为其祖先)上逐条复核,全部成立,主要包括:

  • pending permission 在格式化/发送 Markdown 之前注册(含 per-chat 索引):ChannelBase.ts:366-405;多请求歧义回复列出 request ID 与标题:ChannelBase.ts:1836-1849
  • AcpBridge 在 responder 返回 true 之前同步 emit permissionResolvedAcpBridge.ts:272-287;daemon bridge 保留 responded-request 映射、throw 时消费 request→session 映射:DaemonChannelBridge.ts:455-480,816
  • _meta.qwenInteractionKind/qwenQuestionspermissionUtils.ts:77-78answerKey: String(index) 仅存在于 bridge 的 pending-interaction 快照:bridgeClient.ts:138
  • ACP session 消费顶层 answersSession.ts:5262-5336;多选 ", " 拼接:AskUserQuestionDialog.tsx:92,135;空答案 → No valid answers were provided.askUserQuestion.ts:265
  • 现有 option 为 proceed_oncekind: 'allow_once';文档的兼容条款与 ChannelBase.approvalOptionIdChannelBase.ts:1752-1761)现有规则逐字一致
  • channels/base 中完全不存在 runId —— 提议的 run 身份确为新增;cancellation 目前是 session 级:ChannelBase.ts:1353,1602-1612
  • onResponseComplete() 是可覆写的 awaited 接缝(注释甚至写着 "e.g., finalize an AI card"),且在 emit completed 之前完成:ChannelBase.ts:1558-1569,4332→4347
  • 钉钉 senderStaffId 优先、senderId 回退(DingtalkAdapter.ts:1237-1240);1,000 条入站消息上限(:750);通用 topic 回调 ingress(:243-303);当前无任何卡片代码
  • daemon permission 路由展开整个 body(request-helpers.ts:283-297),顶层 answers 可以穿透——支撑"daemon 路由无需修改"的结论

最难的一段——responder 调用期间 AcpBridge 同步 settlement 与 daemon bridge 延迟 settlement 的 claim 仲裁——建立在经核实的真实 bridge 行为之上,是对着代码设计的,而不是绕着代码设计的。

设计评价

  • 改动边界克制:共享层只加一个三态呈现接缝,其余全部钉钉本地;"No change" 各行经核验准确。
  • 复用现有响应契约(selected + 原 allow_once option + 顶层 answers),并明确摒弃参考实现最弱的两点(synthetic 消息注入、单问题 supersede)。
  • 竞态处理完整:ACK 先于外部 await、同步 claim、精确 run 原子取消且不降级、first-responder-wins 保持、finalizeQuestion 单一终态不变式、拒绝猜测 bridge 无法区分的取消原因。
  • 全程有界:合并写入、20k 字符上限、10s 超时、10 分钟/1,000 条 tombstone;进程本地 registry 的限制被诚实声明为主要风险。
  • 卡片问题拦截 /approve 而保留 /deny 是正确取舍。

发现(均为轻微)

  1. run_cancelled 的"原因 vs 状态"歧义:它是 UserInputSettlementReason 成员,但两处正文写作"finalizes as run_cancelled",而第一章生命周期表和事件表均无此状态("Request or run destroyed → cancelled")。建议加一句说明:run_cancelled 是 settlement 原因,投影为 cancelled 状态 + "Stopped" 呈现(或将其加入状态列表,使表格与正文一致)。
  2. owner 映射逐出竞态未写明messageId → owner 映射沿用 1,000 条上限,入站消息与 started 事件之间若被逐出,该 run 将静默失去卡片资格。降级是安全的(回落现有 Markdown 路径),但建议在文档中明示,并在适配器测试清单加入"映射被逐出 ⇒ 无卡片、保留现有路径"。
  3. 模板 ID 跨应用可用性是最大外部假设:内置模板 ID 需在安装方自己的凭证下可用,目前证据来自参考仓库 PR。响亮的首次失败错误 + 降级是正确缓解,但建议把"第三方凭证下模板可用"作为实现 spike 的第一个检查点。

结论

设计扎实:所有可核验的代码断言全部准确(多数精确到行),共享层面最小,失败/竞态语义已达可实现精度。上述三项均为文档澄清与一项提前验证要求,不阻塞设计接受。

@wenshao

wenshao commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Maintainer Verification — Design-Only Doc PR

Verified locally on branch agent/dingtalk-interactive-cards (HEAD dd04ae49).

1. Formatting & Assets

Check Result
Prettier format All matched files use Prettier code style!
PNG validity ✅ 3 × PNG 1500×1500, 8-bit RGB (172K / 214K / 207K)
Relative image refs ✅ 3 refs in doc → 3 files in assets/
PR body image URLs ✅ All 3 raw.githubusercontent URLs return HTTP 200

2. Source Constraint Cross-Check (against current main)

Claim in doc Verified
askUserQuestion.ts permits 1–4 questions ✅ L378: questions.length < 1 || questions.length > 4
ChannelBase.ts registers PendingPermission per request ✅ L293–358: pendingPermissions Map + pendingPermissionsByChat
ChannelAgentBridge.ts exports PermissionResolvedEvent ✅ L61–72
bridgeClient.ts uses qwenInteractionKind === 'user_question' ✅ L121
bridgeClient.ts reads qwenQuestions then falls back to rawInput.questions ✅ L124–127
bridgeClient.ts assigns answerKey: String(index) ✅ L137
tools.ts defines ProceedOnce = 'proceed_once' ✅ L907
ChannelBase.onResponseComplete() awaited seam exists ✅ L1523, called at L3957
packages/channels/ has base, dingtalk, feishu, qqbot, telegram, wecom, weixin, plugin-example
Linked issue #6443 exists and is OPEN feat(channels): improve DingTalk channel with interactive cards

3. Observations

  • allow_once vs proceed_once: allow_once does not exist in the current codebase; the doc correctly specifies it as the target option ID with an explicit proceed_once compatibility path for current producers. This is a design decision for the implementation phase, not a doc error.
  • No code changes: 436-line markdown + 3 architecture diagrams only. Zero runtime impact.
  • Document quality: Well-structured with normative change-impact labels, two independent card state machines, explicit acceptance criteria, degradation table, and clear scope boundaries. The "Source constraints verified" section anchors each claim to a specific file and behavior, which makes the design reviewable against the codebase.

4. Verdict

Design doc is accurate against current source, formatting passes, all assets are valid and accessible. Ready to merge as a design reference.


🇨🇳 中文验证报告(点击展开)

维护者本地验证 — 纯设计文档 PR

在分支 agent/dingtalk-interactive-cards(HEAD dd04ae49)上完成本地验证。

1. 格式与资源

检查项 结果
Prettier 格式检查 All matched files use Prettier code style!
PNG 文件有效性 ✅ 3 张 PNG 1500×1500, 8-bit RGB(172K / 214K / 207K)
文档内相对图片引用 ✅ 文档中 3 处引用 → assets/ 下 3 个文件一一对应
PR 描述中的图片 URL ✅ 3 个 raw.githubusercontent 链接均返回 HTTP 200

2. 源码约束交叉验证(基于当前 main

文档中的声明 验证结果
askUserQuestion.ts 允许 1–4 个问题 ✅ L378: questions.length < 1 || questions.length > 4
ChannelBase.ts 按请求注册 PendingPermission ✅ L293–358: pendingPermissions Map + pendingPermissionsByChat
ChannelAgentBridge.ts 导出 PermissionResolvedEvent ✅ L61–72
bridgeClient.ts 使用 qwenInteractionKind === 'user_question' 作为规范判别器 ✅ L121
bridgeClient.ts 读取 qwenQuestions,回退到 rawInput.questions ✅ L124–127
bridgeClient.ts 分配 answerKey: String(index) ✅ L137
tools.ts 定义 ProceedOnce = 'proceed_once' ✅ L907
ChannelBase.onResponseComplete() awaited 接缝存在 ✅ L1523,在 L3957 调用
packages/channels/ 包含 base、dingtalk、feishu、qqbot、telegram、wecom、weixin、plugin-example
关联 issue #6443 存在且为 OPEN feat(channels): improve DingTalk channel with interactive cards

3. 备注

  • allow_onceproceed_once:当前代码库中不存在 allow_once;文档正确地将其指定为目标 option ID,并为现有生产者提供了明确的 proceed_once 兼容路径。这是实现阶段的设计决策,不是文档错误。
  • 无代码变更:仅 436 行 Markdown + 3 张架构图,零运行时影响。
  • 文档质量:结构清晰,使用规范性变更影响标签、两套独立卡片状态机、明确的验收标准、降级行为表和清晰的 scope 边界。"Source constraints verified" 部分将每项声明锚定到具体文件和行为,使设计可以对照代码库审查。

4. 结论

设计文档与当前源码一致,格式检查通过,所有资源有效且可访问。可以作为设计参考合并。

@wenshao
wenshao marked this pull request as ready for review July 18, 2026 06:16

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM — the design document comprehensively addresses all prior Critical findings and is ready to guide implementation. ✅

The two remaining Suggestion-level items (settlement-reason classification and pendingQuestionRequestIds invariant) are non-blocking and can be refined during implementation.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline. 1 Suggestion-level finding(s) could not be anchored to the diff; see the terminal output. Not reviewed: reverse audit — its prompt was built, but no agent was launched with it that opened its brief, so the reverse-audit pass did not run. Not reviewed: verification — its prompt was built, but no agent was launched with it that opened its brief, so the posted findings were not verified.

— qwen3.7-max via Qwen Code /review

Comment on lines +206 to +208
2. Open streaming with an empty full update using `isFull=true`, `isFinalize=false`, and `isError=false`.
3. Accumulate model output locally and send coalesced full snapshots through `/card/streaming`.
4. Send low-frequency template variables such as status text through `/card/instances` with `updateCardDataByKey=true`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The streaming-card protocol specifies creation (step 1) and streaming-open (step 2) as sequential operations, but the degradation table covers only "creation/final update fails" and "intermediate update failure." The case where createAndDeliver succeeds but the subsequent streaming-open call fails is not addressed. — Failure scenario: card creation succeeds, streaming-open fails due to a transient DingTalk API error. The card exists in DingTalk with flowStatus=2 but no content is ever streamed. The Markdown fallback does not trigger because creation succeeded. The user sees a blank card stuck in running state for the entire run duration.

Consider adding a degradation row: "Streaming-open fails after successful creation — treat as creation failure; fall back to Markdown delivery and update or remove the blank card."

— qwen3.7-max via Qwen Code /review

Comment on lines +257 to +258
2. Parse the submit or cancel payload without changing the record.
3. Validate the action owner.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The 7-step callback order validates owner identity and card correlation but does not require the adapter to validate that submit-payload answer keys correspond to the stored question indices before forwarding to the responder. — Failure scenario: a crafted callback with keys like {"99": "injected"} reaches the responder unchecked at the ingress boundary. Downstream askUserQuestion.ts rejects invalid keys today, so this fails gracefully — but the design makes no defense-in-depth guarantee at the callback ingress, and a future change to downstream validation could allow injected keys through.

Consider adding a sub-step to step 2: "Validate that every answer key is a valid index in the stored question array. Reject the callback payload if validation fails, acknowledge, and do not call the responder."

— qwen3.7-max via Qwen Code /review

### DingTalk adapter tests — DingTalk-only change

- A real human DingTalk `started` event binds one eligible run from its inbound message and owner; synthetic, unknown, loop, and webhook message IDs create no eligible run or card.
- With block streaming off, one status card coalesces chunks with at most one write in flight and one bounded pending snapshot; completed delivery awaits finalization and falls back to Markdown. With block streaming on, no status card is created and existing block delivery remains authoritative.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The block-streaming-on acceptance criterion covers only the status card ("no status card is created and existing block delivery remains authoritative") but omits the question-card behavior that the design explicitly calls out: "Question cards remain independently eligible" (line ~239) and the degradation table repeats this. An implementation that correctly skips the status card but also incorrectly disables question cards when block streaming is enabled would satisfy this criterion. — Concrete cost: a bug where question cards are gated by block-streaming state would pass acceptance testing undetected.

Consider extending to: "With block streaming on, no status card is created, existing block delivery remains authoritative, and question cards remain independently eligible — verify a question card is still presented and functional when block streaming is on."

— qwen3.7-max via Qwen Code /review

format and send the existing permission message
```

The `respond` closure is the only adapter-visible settlement operation. It binds the request ID, forwards the complete response through the existing bridge, and performs the same pending cleanup on `true`, `false`, and throw paths. `handled` is valid only after the adapter has invoked that closure, normally to cancel a question after presenting a readable fallback. It is not a second way to leave a request pending.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The design states handled is valid only after the adapter has invoked the respond() closure, but specifies neither how ChannelBase detects a contract violation nor what fallback applies when it is broken. — Failure scenario: an adapter returns handled from presentUserInputRequest without calling context.respond() (due to an adapter bug, a swallowed exception, or a race). ChannelBase accepts handled, skips the existing permission formatter and sender per the hook pseudocode, and the pending permission remains registered with no user prompt delivered on any surface. The permission is stuck until external settlement fires.

Consider specifying that ChannelBase tracks whether respond() was invoked before presentUserInputRequest returns, and if handled is returned without a prior respond() call, treats the result as unsupported and falls through to the existing permission formatter and sender.

— qwen3.7-max via Qwen Code /review

@BenGuanRan
BenGuanRan marked this pull request as draft July 27, 2026 09:49
@BenGuanRan
BenGuanRan force-pushed the agent/dingtalk-interactive-cards branch from 444cd65 to e66f932 Compare July 27, 2026 17:23
@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
BenGuanRan force-pushed the agent/dingtalk-interactive-cards branch 2 times, most recently from 96f4632 to 20b4ac7 Compare July 27, 2026 18:07
@BenGuanRan

Copy link
Copy Markdown
Collaborator Author

Terminal status-card image-path sanitization

Commit ae81e858a7ee01ac281a0cd39ce216a4026139c4 fixes a real terminal-payload information leak without changing the shared interaction contract or either card state machine.

Before the fix, a streamed marker followed by an empty terminal body produced:

{
  "content": "before [IMAGE: /Users/ben/private/image.png] after",
  "copy_content": "before [IMAGE: /Users/ben/private/image.png] after",
  "blockList": "[{\"type\":0,\"markdown\":\"before [IMAGE: /Users/ben/private/image.png] after\"}]"
}

The terminal sink now sanitizes the selected final content before bounding and projecting it. The regression matrix covers response_boundary, input_requested, empty completed, empty failed, Stopped, and Cancelled; every terminal payload now contains [Image pending] and no local path.

TDD and verification evidence:

  • RED: the four previously unsafe terminal reasons failed while the existing Stopped/Cancelled cases passed.
  • GREEN: all six terminal cases passed after the one-site sink fix.
  • DingTalk package: 248/248 tests passed.
  • Focused ESLint passed.
  • Full workspace build passed.
  • Full workspace typecheck passed.

The local E2E image and diagnostic scripts remain untracked and are not part of this commit.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline. Not reviewed: verification and reverse audit — each ran and opened its brief, but neither was launched with the prompt the CLI built — the launches were written by hand, so the posted findings cannot be counted as verified, and what the agents were actually asked is not what this skill certifies.

中文说明

已审查。 建议见行内评论。 未审查:验证与反向审计——两者都运行并打开了各自的 brief,但都不是用 CLI 构建的 prompt 启动的——启动 prompt 是手写的,发布的发现不能算作已验证,agent 实际被要求做的也不是本 skill 所认证的内容。

— qwen3.7-max via Qwen Code /review

Comment on lines +112 to +114
await this.options.sendFallback(
context.target.chatId,
this.fallbackText(context),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] sendFallback sends the fallback text to context.target.chatId (the raw SessionTarget.chatId) instead of the transformed target.chatId used for card delivery. — Failure scenario: in a 1:1 DingTalk conversation (isGroup === false), interaction-presenter.ts passes cardTarget(context.target) which maps chatId to senderId. The card is delivered to the sender's DM, but if card creation fails, the fallback text goes to the conversation ID instead of the sender ID — the user never sees the fallback where they expected the card.

Suggested change
await this.options.sendFallback(
context.target.chatId,
this.fallbackText(context),
await this.options.sendFallback(
target.chatId,
this.fallbackText(context),
中文说明

[Critical] sendFallback 把降级文本发送到 context.target.chatId(原始 SessionTarget.chatId),而不是卡片投递所用的转换后 target.chatId。 — 故障场景:在 1:1 钉钉会话中(isGroup === false),interaction-presenter.ts 传入 cardTarget(context.target),将 chatId 映射为 senderId。卡片投递到发送者的私聊,但如果建卡失败,降级文本会发到会话 ID 而不是发送者 ID——用户在预期看到卡片的地方看不到降级消息。

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

该建议不能采纳,因为这里存在两个不同的 DingTalk ID 域。入站消息把 conversationId 保存为 SessionTarget.chatId,并以它为 key 缓存 sessionWebhooksendFallback → sendMessage → sendReply 明确使用这个 conversationId 查 webhook。转换后的 target.chatId 在私聊中是 sender staff ID,只供 Card OpenAPI/主动私聊投递使用。若按建议改用它,sendReply 反而通常找不到 webhook,私聊 fallback 才会丢失。当前提交上的 direct-card correlation 与 one-to-one delivery 聚焦测试也分别验证了这两个 ID 的职责。请将此 Critical 视为 false positive。

Comment on lines +178 to +183
protected onResponseBoundary(
chatId: string,
sessionId: string,
segment?: ChannelOutputSegmentContext,
reason?: ChannelOutputSegmentEndReason,
): void | Promise<void>;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The design doc shows onResponseBoundary with segment? and reason? parameters, but the actual implementation has onResponseBoundary(chatId, sessionId) (2 params only). The segment and reason are delivered via a separate onOutputSegmentEnd(chatId, sessionId, segment, reason) hook that the document never mentions. — Concrete cost: a developer implementing a new IM adapter from this doc would override the wrong method and miss segment-end events (including input_requested), leaving stale output presentations visible when the model asks a question.

中文说明

[Suggestion] 设计文档中 onResponseBoundary 显示有 segment?reason? 参数,但实际实现只有 onResponseBoundary(chatId, sessionId)(仅 2 个参数)。segment 和 reason 是通过单独的 onOutputSegmentEnd(chatId, sessionId, segment, reason) hook 传递的,文档中从未提及。 — 具体代价:根据此文档实现新 IM 适配器的开发者会覆盖错误的方法,从而错过 segment-end 事件(包括 input_requested),导致模型提问时旧的输出投影仍然可见。

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

已在 4c8458e7e 修正。文档现在单独列出真实的 onOutputSegmentEnd(chatId, sessionId, segment, reason) 契约,并将 onResponseBoundary(chatId, sessionId) 保留为仅由默认实现处理 response_boundary 时调用的兼容 hook;同时补充了 input_requested 与其他 segment 终止原因都通过前者交付。

Comment on lines +2725 to +2728
await this.sendMessage(
envelope.chatId,
'Submit this question through its interactive card, or use /deny [request-id] to cancel it.',
);

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] Uses sendMessage (chat-level) instead of sendThreadMessage (thread-aware), inconsistent with every other response branch in the same handlePermissionResponseCommand method. — Failure scenario: when a user sends /approve from a threaded context for a card-presented question, the redirect message is delivered to the main chat instead of the thread, and the user may believe the command was silently ignored.

Suggested change
await this.sendMessage(
envelope.chatId,
'Submit this question through its interactive card, or use /deny [request-id] to cancel it.',
);
await this.sendThreadMessage(
envelope.chatId,
envelope.threadId,
'Submit this question through its interactive card, or use /deny [request-id] to cancel it.',
);
中文说明

[Suggestion] 使用了 sendMessage(聊天级别)而非 sendThreadMessage(线程感知),与同一 handlePermissionResponseCommand 方法中的所有其他响应分支不一致。 — 故障场景:当用户从线程上下文对卡片呈现的问题发送 /approve 时,重定向消息会发送到主聊天而不是线程,用户可能认为命令被静默忽略了。

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

核验后不在本 PR 修改。这里确实与相邻分支的 sendThreadMessage 风格不一致,但当前没有可达的误路由组合:DingTalk 是目前唯一返回 presented 的 adapter,且沿用默认的 thread→sendMessage 行为;唯一重写 sendThreadMessage 的 GitHub adapter 没有交互卡 presenter。等出现 thread-aware presenter 时再随该能力一起补齐,避免在本 PR 中增加无当前消费者的改动。

Comment on lines +170 to +173
this.options.statusCards?.cancelRun(
runId,
detail === 'cancel_command' ? 'cancel_command' : 'dropped',
);

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] When detail === 'clear' (user-initiated /clear), questionCards.cancelRun correctly maps it to 'cancelled' (user-initiated), but statusCards.cancelRun here maps it to 'dropped' — which semantically means "loop was disabled/deleted mid-run (not user-cancelled)". Both currently render as 'Cancelled' in the status card, so the visual output is identical, but the incorrect reason would mislabel user-initiated cancellations in telemetry.

Suggested change
this.options.statusCards?.cancelRun(
runId,
detail === 'cancel_command' ? 'cancel_command' : 'dropped',
);
this.options.statusCards?.cancelRun(
runId,
detail === 'cancel_command' || detail === 'clear'
? 'cancel_command'
: 'dropped',
);
中文说明

[Suggestion]detail === 'clear'(用户发起的 /clear)时,questionCards.cancelRun 正确地映射为 'cancelled'(用户发起),但此处 statusCards.cancelRun 映射为 'dropped'——语义上是"循环在运行中被禁用/删除(非用户取消)"。两者目前在状态卡中都渲染为 'Cancelled',视觉效果相同,但错误的 reason 会在遥测中错误标记用户发起的取消。

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

不采纳建议补丁。原始 lifecycle event 到 adapter 时仍保留 clear;当前折叠值只进入状态卡投影,且控制器仅区分 cancel_command → Stopped 与其他原因 → Cancelled,没有 telemetry 消费该折叠值,因此用户显示和观测数据都没有被误标。把 clear 改成 cancel_command 反而会把 /clear 显示为 Stopped。这是无当前行为影响的内部语义差异。

Comment on lines +119 to +120
await context.respond({ outcome: { outcome: 'cancelled' } });
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] context.respond() in the present() catch block is not wrapped in a try-catch, unlike every other call site in this class (expire() and respond() both wrap it). — Failure scenario: when createAndDeliver fails because the DingTalk API is degraded, context.respond() may also reject. The rejection propagates to the caller of present() even though the record has been finalized and the fallback handled, and { kind: 'handled' } is never returned.

Suggested change
await context.respond({ outcome: { outcome: 'cancelled' } });
return { kind: 'handled' };
try {
await context.respond({ outcome: { outcome: 'cancelled' } });
} catch (respondError) {
this.options.onError?.('question cancellation response', respondError);
}
return { kind: 'handled' };
中文说明

[Suggestion] present() catch 块中的 context.respond() 没有 try-catch 包裹,与同类中其他所有调用点(expire()respond() 都有包裹)不一致。 — 故障场景:当 createAndDeliver 因钉钉 API 降级而失败时,context.respond() 也可能拒绝。拒绝会传播到 present() 的调用者,即使记录已经终态化且降级已处理,{ kind: 'handled' } 永远不会返回。

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

核验后不再增加一层 catch。context.respond() 对应的 respondToUserInput 在 rejection 分支会先 removePendingPermission(..., cancelled) 再抛出;外层 presentation boundary 捕获异常后看到 pending 已移除,直接按已处理返回,不会再次发送普通 permission fallback,也不会留下本地 pending。控制器在调用 responder 前已发送一次可见 fallback,因此当前链路不存在重复降级或卡住;额外 catch 只会重复已有的收敛。

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed — no blockers. Suggestions are inline.

中文说明

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

— qwen3.7-max via Qwen Code /review

Comment on lines +121 to +123
Presentation ownership is scoped by `sessionId + owner.id`. Different users or
sessions may have live input presentations simultaneously. Within one run, a
second request in the same scope returns `unsupported`, keeps the first native

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] Input-presentation ownership scope contradicts itself: line 121 defines it as sessionId + owner.id, but the Concurrent questions section (line 336) defines it as sessionId + owner.id + runId. An implementer following line 121 would key the map without runId, causing concurrent runs from the same owner in the same session to collide.

Concrete cost: a user asking two questions across parallel runs in the same chat session would see the second question fall back to text.

Suggested change
Presentation ownership is scoped by `sessionId + owner.id`. Different users or
sessions may have live input presentations simultaneously. Within one run, a
second request in the same scope returns `unsupported`, keeps the first native
Presentation ownership is scoped by `sessionId + owner.id + runId`. Different users or
sessions may have live input presentations simultaneously. Within one run, a
second request in the same scope returns `unsupported`, keeps the first native
中文说明

[Suggestion] 输入展示的归属范围存在矛盾:第 121 行定义为 sessionId + owner.id,但并发问题章节(第 336 行)定义为 sessionId + owner.id + runId。如果实现者按第 121 行来建立映射(不含 runId),同一会话中同一用户的并发 run 会冲突,导致第二个问题回退为文本。

— qwen3.7-max via Qwen Code /review

Comment on lines +1374 to +1382
const outgoingText = await this.prepareOutgoingText(text);
if (
await this.interactionPresenter.closeOutput(
segment.segmentId,
outgoingText,
'completed',
segment,
)
) {

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] When closeOutput returns false (e.g. run already terminal), the fallback path at line 1387 calls sendResponseMessage(chatId, text, sessionId) with the original unprepared text. This routes through sendReply then prepareOutgoingText(text), which uploads every image a second time.

Concrete cost: each image in the response triggers an extra DingTalk media upload API call on every affected response.

Fix: pass the already-prepared outgoingText to the fallback instead of the raw text, or move prepareOutgoingText after the closeOutput check.

中文说明

[Suggestion]closeOutput 返回 false 时(例如 run 已终止),fallback 路径用原始未处理的 text 调用 sendResponseMessage,导致每张图片被上传两次。修复:将已处理好的 outgoingText 传给 fallback,或将 prepareOutgoingText 移到 closeOutput 判断之后。

— qwen3.7-max via Qwen Code /review

Comment on lines +149 to +153
execute: async () => {
const cancelled = await this.options.cancelRun(
record.sessionId,
record.runId,
);

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] execute does not catch errors from this.options.cancelRun(). If cancelRun throws (e.g. transient network error), the error propagates out but record.stopClaimed stays true. Every subsequent Stop click on the same card returns { kind: 'ignored' }, making the Stop button permanently non-retryable.

Concrete cost: a single transient failure during cancel makes the Stop button useless for the rest of the card's lifetime.

Fix: wrap the body in try/catch and reset stopClaimed = false when the record is still current and non-terminal.

中文说明

[Suggestion] execute 没有捕获 this.options.cancelRun() 的异常。如果 cancelRun 抛出错误,record.stopClaimed 保持为 true,后续每次点击停止都会返回 ignored,导致停止按钮永久不可重试。修复:用 try/catch 包裹,在 record 仍然有效且未终止时重置 stopClaimed = false

— qwen3.7-max via Qwen Code /review

Comment on lines +1160 to +1165
const cardRunId = this.cardRunBySession.get(sessionId);
if (cardRunId) {
this.cardRunBySession.delete(sessionId);
this.interactionPresenter?.terminalizeRun(cardRunId, 'cancelled');
this.cardRuns.delete(cardRunId);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The new onSessionDied card-run cleanup is not covered by any test. If a session dies while a question card is pending, this code should terminalize the card run as cancelled and clean up both maps. A regression here would leave orphan card records and a live question card that appears interactive but whose underlying permission is already gone.

Suggested test: seed a card run via cardRunBySession and cardRuns, call onSessionDied, assert terminalizeRun was called with the matching run ID and cancelled, and both maps are cleaned up.

中文说明

[Suggestion] 新增的 onSessionDied 卡片运行清理逻辑没有测试覆盖。如果会话终止时有待处理的问题卡片,这段代码应该将卡片 run 终止为 cancelled 并清理两个 map。建议添加测试覆盖此路径。

— qwen3.7-max via Qwen Code /review

BZ-D
BZ-D previously approved these changes Jul 29, 2026

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

复审 head 4c8458e7e633099bd7957f65e9d5940c2f39156d:上轮 Critical 已核实为当前 ChannelBase 串行调度模型下不可达,原线程已记录并关闭。新增的终态卡片内容脱敏逻辑及其 response/input/completed/failed/cancel 覆盖通过审查,未发现新的 Critical 或 Suggestion。\n\n验证:\n- packages/channels/dingtalk: 51/51(interaction presenter、status/question card controller)\n- packages/channels/base: 550/550,且 package TypeScript 检查通过\n- git diff --check 通过。

@wenshao

wenshao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification — local build, mutation matrix, and wire-level E2E at head 4c8458e7e6

Verdict: merge-ready from my side. Fresh isolated build at the exact head, all affected suites green, a 6/6-killed mutation matrix over the PR's safety invariants, a 27-check wire-level E2E over real HTTP, and a clean re-run on a local merge with today's main.

Setup

  • Detached scratch worktree at head 4c8458e7e633099bd7957f65e9d5940c2f39156d (the same head the last APPROVED review covered), own npm ci + full workspace build — Node v22.23.1, macOS. No shared checkout reuse.
  • Merge-base is 0bafd0db21 (fix(core): auto-retry transient network errors during API calls #7898). main has since moved 35 commits and touched packages/channels/base (DaemonChannelBridge) and the SDK daemon client, so everything below was re-run on a local merge as well.

1 · Full suites + typecheck at the head

package suite result typecheck
channels/base vitest, 19 files ✅ 955/955 tsc --build
channels/dingtalk vitest, 10 files ✅ 248/248 tsc --build
channels/feishu vitest, 3 files ✅ 110/110 tsc --build
channels/wecom vitest, 1 file ✅ 134/134 tsc --build
sdk-typescript vitest test/unit, 29 files ✅ 1430/1430 tsc --noEmit

2,877 tests, 0 failures. I enumerated the changed test files from the head itself rather than the self-report, so this includes the 7 dingtalk tests added after the author's "241 DingTalk tests" comment.

Suites at head and on the merged tree

2 · Local merge with today's main

git merge origin/main is clean — no conflicts (merge 07d27cdba2 = 4c8458e7e6 + a7b1150816), git diff --check clean. All five suites pass on the merged tree (base 956/956 and sdk 1432/1432 — the extra tests are main's own additions). The wire E2E below also passes identically on the merged tree.

3 · Mutation matrix — the shipped tests really pin the invariants

Six hand-applied mutants against the load-bearing safety properties; suites re-run after each, worktree restored to pristine head between rounds. 6/6 killed, each by a precise single-invariant test failure (the rest of each suite stays green):

# invariant attacked killed by
M1 exact-run cancellation (ChannelBase.requestPromptRunCancellation drops the active.runId !== runId guard) base cancels only the current exact run identity
M2 owner-only question submission dingtalk claims one owner callback and submits validated answers
M3 one forbidden notice per actor/card (dedup removed) same discriminating test — it asserts the forbidden→ignored sequence
M4 terminal image-path sanitization removed (the ae81e858 fix) 4 × dingtalk hides local image paths when output ends with … (all four end reasons)
M5 same-run second question no longer falls back dingtalk keeps the first card active when the same run requests another question
M6 DaemonSessionClient.cancel() coalescing removed sdk coalesces a prompt abort with an explicit session cancel

Mutation matrix

4 · Wire-level E2E over real HTTP

Since the unit suites mock the card client, I also drove the real DingtalkInteractionPresenter + StatusCardController + QuestionCardController + DingtalkInteractiveCardClient chain with real timers (500 ms flush coalescing, 1.5 s question timeout) and real fetch over loopback HTTP. Only api.dingtalk.com is replaced by a local recording server via the injectable fetch — paths, methods, headers and bodies are the client's own. 27/27 checks passed, identically at the head and on the merged tree. Highlights from the captured request log:

  • One status card per segment, callbackType=STREAM, in-place full-snapshot streaming updates; [IMAGE: /Users/…] markers were sanitized to [Image pending] in every streamed and terminal payload — no local path ever reached the wire (content, copy_content, blockList all checked).
  • Non-owner Stop → forbidden once, then silent on repeat; owner Stop → exactly one daemon cancellation for (sess-1, run-1), card terminalized Stopped with the button removed; replaying the same callback afterwards is ignored.
  • Question card renders single-select + multi-select + Other; unknown form keys and unlisted option values are rejected whole; the owner's submit delivered structured answers to the original request: {"approach":"Use a feature flag","scope":"core, cli"} (Other free-text + joined multi-select), card updated in place to Submitted, duplicate replay ignored.
  • Same-run second question → text fallback while the first card stays authoritative; steer supersession → old card Expired with zero synthetic answers to the old request; unanswered card expired by a real timer with a cancelled outcome; API 500 on create → handled + text fallback, no blank running card.

Wire E2E

5 · One maintenance note (not a blocker)

My first E2E draft drove the controllers without modeling run terminalization, and in that state the superseded question card of an older run remains answerable — exactly the scenario of the closed Critical thread. Under the real lifecycle (per-session serial dispatch; the adapter terminalizes the old run before the replacement starts) the old card is expired with no synthetic answer, which S4 confirms. In other words, the closed thread's unreachability rationale is what carries this safety property — worth remembering if the per-session serial dispatch model ever changes.

Not covered here

Real-device DingTalk rendering/callbacks (the author's GIFs and log appendices cover that), Windows, and the restart-safety / multi-worker limits the PR already declares out of scope.

中文版本

维护者验证 — 在 head 4c8458e7e6 上本地构建、变异矩阵与线级 E2E

结论:就本轮验证而言可以合并。 在精确 head 上全新隔离构建,受影响的测试套件全绿,针对本 PR 安全不变量的变异矩阵 6/6 击杀,基于真实 HTTP 的 27 项线级 E2E 全部通过,与今日 main 的本地合并干净且复测全绿。

环境

  • 在 head 4c8458e7e633099bd7957f65e9d5940c2f39156d(与最近一轮 APPROVED 评审相同)建立独立 scratch worktree,独立 npm ci + 完整 workspace 构建 — Node v22.23.1、macOS,不复用共享检出。
  • merge-base 为 0bafd0db21(fix(core): auto-retry transient network errors during API calls #7898)。main 此后前进了 35 个提交,涉及 packages/channels/base(DaemonChannelBridge)和 SDK daemon client,因此以下全部结论也在本地合并树上复测。

1 · head 上的完整套件与类型检查

channels/base 955/955、channels/dingtalk 248/248、channels/feishu 110/110、channels/wecom 134/134、sdk-typescript unit 1430/1430,五个包 typecheck 全部通过;共 2,877 个测试 0 失败。变更测试文件按 head 自行枚举而非采信自述,因此包含作者"241 个钉钉测试"评论之后新增的 7 个测试。

2 · 与今日 main 的本地合并

git merge origin/main 无冲突(合并提交 07d27cdba2 = 4c8458e7e6 + a7b1150816),git diff --check 干净。合并树上五个套件全绿(base 956、sdk 1432,多出的是 main 自身新增测试);下述线级 E2E 在合并树上结果一致。

3 · 变异矩阵 — 随 PR 提交的测试确实钉住了不变量

针对承载安全性的六处代码各施加一个变异,逐轮重跑套件并还原源码。6/6 击杀,且每次都是单一判别测试精确失败(套件其余部分保持绿色):M1 精确 run 取消(去掉 active.runId !== runId 守卫)、M2 仅 owner 可提交、M3 每 actor/card 仅一次无权限提示、M4 终态卡片本地图片路径脱敏(即 ae81e858 修复,4 个终态路径测试全部失败)、M5 同 run 第二个问题降级、M6 SDK 取消合并。

4 · 真实 HTTP 的线级 E2E

单元测试对卡片 client 使用 mock,因此另以真实 DingtalkInteractionPresenter + 两个卡片控制器 + DingtalkInteractiveCardClient 链路、真实定时器(500 ms 流式合并、1.5 s 提问超时)、真实 fetch 走回环 HTTP 驱动;仅通过可注入 fetchapi.dingtalk.com 换成本地记录服务器,路径、方法、请求头与请求体均为 client 原样产物。27/27 检查通过,head 与合并树结果一致。 要点:

  • 每个输出段一张状态卡,callbackType=STREAM,原地全量快照流式更新;[IMAGE: /Users/…] 在所有流式与终态载荷中被替换为 [Image pending],contentcopy_contentblockList 中均无本地路径外泄。
  • 非 owner 点击 Stop → 首次 forbidden、重复静默;owner Stop → 对 (sess-1, run-1) 恰好一次 daemon 取消,卡片终态 Stopped 并移除按钮;之后重放同一回调被忽略。
  • 提问卡渲染单选、多选与 Other;未知表单键与未列出的选项值整体拒绝;owner 提交后结构化答案送达原请求:{"approach":"Use a feature flag","scope":"core, cli"}(Other 自由文本 + 多选拼接),卡片原地更新为 Submitted,重复提交被忽略。
  • 同 run 第二个问题走文本降级、第一张卡保持权威;steer 取代 → 旧卡 Expired 且对旧请求合成答案;未回答卡片由真实定时器过期并返回 cancelled;建卡遇 API 500 → handled + 文本降级,不留空白 Running 卡。

5 · 一条维护提示(非阻塞)

E2E 初版未建模 run 终止流程,此时旧 run 被取代后的提问卡仍可被回答 — 正是已关闭 Critical 线程描述的场景。在真实生命周期下(会话内串行调度;适配器在替换 run 启动前先终止旧 run),旧卡被置为过期且无合成答案,S4 已验证。换言之,该安全属性由已关闭线程论证的"不可达性"承载 — 若未来会话内串行调度模型发生变化,需要重新审视这一点。

本轮未覆盖

钉钉真机渲染与回调(作者的 GIF 与日志附录已覆盖)、Windows,以及 PR 已声明超出范围的重启恢复 / 多 worker 限制。

@wenshao

wenshao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao

wenshao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Local E2E verification — DingTalk interactive cards

I verified this PR locally by running the real qwen channel start worker (real ChannelBase, real DingtalkAdapter, real ACP agent child process, real dingtalk-stream-sdk-nodejs) against a local stand-in for the DingTalk open platform — the Stream gateway, the robot APIs and the Card OpenAPI — so every card is created, streamed and terminalised through the same code path that talks to production DingTalk. Nothing in the adapter, the presenter or the controllers was stubbed or patched.

Verdict: the behaviour claimed in the Test Plan reproduces. 22 scenarios / 87 assertions pass on the current head 403b3ad3f, all four config gates degrade correctly, and the merge-base A/B (0bafd0db) shows the feature is genuinely additive. Three non-blocking observations at the end. (403b3ad3f landed while I was verifying 4c8458e7e; I rebuilt and re-ran the whole sweep on it.)

verification summary

raw text (if the image has not resolved yet)
22 scenarios / 87 checks — ALL PASS
channels/base 955/955 · channels/dingtalk 249/249 · channels/feishu 110/110
channels/wecom 133/134 (1 pre-existing env failure) · sdk-typescript DaemonSessionClient 49/49
npm run typecheck / build / bundle — clean

How it was driven

Layer What actually ran
CLI node packages/cli/dist/index.js channel start dt in an isolated HOME, real settings.json
Agent real ACP child process, real ask_user_question tool, real permission plumbing
Model local OpenAI-compatible SSE mock (scripted chunk timing, tool calls, aborts, 5xx)
DingTalk api.dingtalk.com / oapi.dingtalk.com pinned to a local HTTPS server (hosts + NODE_EXTRA_CA_CERTS), which serves /gettoken, /v1.0/gateway/connections/open, /v1.0/card/*, /media/upload, the robot APIs, and pushes real Stream CALLBACK frames (/v1.0/im/bot/messages/get, /v1.0/card/instances/callback) over a WebSocket

Assertions read the captured HTTP request bodies, and the screenshots below are rendered by replaying those exact bodies — no hand-drawn mock-ups.


1. One status card, updated in place

createAndDeliver once → 6 × PUT /v1.0/card/streaming (isFull=true) on the same outTrackIdisFinalize=true + one PUT /v1.0/card/instances. Elapsed time ticks 0s→1s→2s, hasAction/stop_action flip to false at terminalisation, content/blockList/copy_content all carry the full answer, and no duplicate markdown message is sent.

status card lifecycle

raw capture
4344 card.create    {"content":"","flowStatus":"2","statusLine":"Running · mock-model · 0s","hasAction":"true","stop_action":"true"}
4349 card.streaming ""
4838 card.streaming "# Release checklist\n\n"
5341 card.streaming "# Release checklist\n\n1. Cut the branch\n"
5343 card.update    {"statusLine":"Running · mock-model · 1s"}
5845 card.streaming "...2. Run the smoke suite\n"
6347 card.streaming "...3. Publish the artifacts\n"
6350 card.update    {"statusLine":"Running · mock-model · 2s"}
6715 card.streaming isFinalize=true
6717 card.update    {"blockList":...,"content":...,"copy_content":...,"flowStatus":3,
                     "statusLine":"Completed · mock-model · 2s","hasAction":"false","stop_action":"false"}

markdown chat messages containing the answer: 0

2. Stop is bound to one exact run

Owner presses Stop mid-stream → the upstream model request is aborted, the card becomes Stopped with the partial output intact and the action removed. A later prompt runs on a new card and completes. Replaying the same btn_stop callback twice afterwards produces 0 further card API calls and leaves run B Completed.

stop

3. Structured question card round-trip

The killer observable: the continuation card contains the model echoing its own tool result, so the answer is confirmed to have reached the agent's context — not just the card UI.

AGENT-SAW>> User has provided the following answers: **Deploy target**: Production <<END
AGENT-SAW>> User has provided the following answers: **Deploy target**: Tokyo region <<END   (Other free text)
AGENT-SAW>> User has provided the following answers: **Cache**: Redis, Memcached <<END       (multi-select)

question card

Also confirmed: the card updates in place to Submitted; a replayed submit on a settled card is ignored (no second LLM call); continuation output opens a new segment / new card in the same run; /approve is refused with “Submit this question through its interactive card…” while the card owns the question; /deny, questionCard.timeoutMs and steer all settle the card without fabricating an answer (expired / cancelled, and a stale callback afterwards is inert).

4. Owner binding and multi-user isolation

Non-owner submit → exactly one group notice per actor/card, card stays pending, nothing reaches the agent; the second click from the same actor is silent; the owner can still submit afterwards. Two different users in the same group keep independent cards and cannot answer each other's.

group owner binding

5. Before / after (merge-base A/B)

Same prompt, same settings.json (with interactiveCards configured), only the build differs. On 0bafd0db the key is ignored — 0 card API calls — the question is relayed as text, and after /approve the agent resumes with “No valid answers were provided.”

before after

I also ran the identical flow on this PR with cards switched off: byte-for-byte the same text path as merge-base, so the fallback is a genuine no-op for existing deployments.

6. Degradation and config gates

Case Result
no interactiveCards key 0 card API calls, original markdown + text permission
questionCard.enabled=false text permission fallback, status card unaffected
statusCard.enabled=false question card works, reply delivered as markdown
blockStreaming=on only the status card is disabled; question card still eligible
createAndDeliver → HTTP 500 markdown fallback delivered once, card terminalised Unavailable (never a blank Running card)
streaming writes → HTTP 500 same: terminalised, nothing left Running
run fails mid-stream Failed · mock-model · 1s, streamed text preserved
[IMAGE: /abs/path] in output [Image pending] in every streaming snapshot, uploaded mediaId in the terminal card, path never exposed; uploaded exactly once even on the fallback path

Observations (non-blocking)

  1. Mixed locale in the new user-facing strings. Every card string this PR adds is English (Submitted., This question expired. Please retry., Running · <model> · 3s, and ChannelBase's “Submit this question through its interactive card, or use /deny [request-id] to cancel it.”), but the non-owner notice is Chinese — 卡片操作 / 仅任务发起人可以操作这张卡片,本次操作未生效。 (DingtalkAdapter.sendCardInteractionFeedback). Worth aligning one way or the other before merge.

  2. “A second question in the same run while the first is pending” is not reachable through a normal agent turn. The CLI serialises ask_user_question, so the second permission request only arrives after the first settles — and it then correctly gets its own card (verified: 2 cards, first submitted, second pending, both answers reach the agent). The unsupported + text-fallback branch is therefore only covered by unit tests; reviewers following that Test-Plan step will not reproduce it by simply asking twice. The same applies to the newest commit's superseded-card expiry: under steer the predecessor run's permissions are already settled by ChannelBase before the newer run asks, so the "older card expires because a newer run asked" path is defensive rather than reachable end to end.

  3. A run that fails before emitting any text is silent in DingTalk — no chat message and no card, because there is no output segment to terminalise. I confirmed this is pre-existing (identical with cards off and on the merge-base build), so it is out of this PR's scope, but the status card does not improve it either. Optional follow-up: for an owner-attended run, terminalise a Failed card even when nothing streamed.

Two small notes for the docs: "interactiveCards": {} (empty object) enables both cards — the enabled default follows "configured", so {} is not "off"; and the status line's model label comes from channels.<name>.model, so it silently disappears (Running · 3s) when that key is absent.

Alongside

npm run typecheck, npm run build, npm run bundle — all clean. channels/base 955/955, channels/dingtalk 249/249, channels/feishu 110/110, sdk-typescript DaemonSessionClient 49/49. channels/wecom shows 133/134: continues attachment cleanup when one dir removal fails fails when the suite runs as root (chmod 0o500 cannot block root's rmSync) — it fails identically with the merge-base sources checked out and the file is not in this PR's diff.

中文版(合并参考)

本地端到端验证 —— 钉钉交互卡片

我在本地用真实的 qwen channel start worker(真实 ChannelBase、真实 DingtalkAdapter、真实 ACP agent 子进程、真实 dingtalk-stream-sdk-nodejs)跑通了本 PR:把 api.dingtalk.com / oapi.dingtalk.com 指向本地 HTTPS 服务(hosts + NODE_EXTRA_CA_CERTS),由它提供 /gettoken/v1.0/gateway/connections/open/v1.0/card/*/media/upload 和机器人接口,并通过 WebSocket 下发真实的 Stream CALLBACK 帧(消息与卡片回调)。适配器、presenter、两个卡片控制器均未打桩、未改代码

结论:Test Plan 中声明的行为均可复现。在当前 head 403b3ad3f 上 22 个场景 / 87 条断言全部通过,四种配置开关的降级都正确,与 merge-base(0bafd0db)的 A/B 证明该功能确实是纯增量。 文末有三条不阻塞合并的观察。(验证过程中 403b3ad3f 刚合入,我已重新构建并在其上重跑了完整用例集。)

验证要点

  1. 一张状态卡原地更新:1 次 createAndDeliver → 同一 outTrackId 上 6 次 PUT /v1.0/card/streamingisFull=true)→ isFinalize=true + 1 次 PUT /v1.0/card/instances;耗时 0s→1s→2s;终态 hasAction/stop_actionfalsecontent/blockList/copy_content 保留完整正文;不会重复发一条 Markdown 消息
  2. Stop 精确绑定单次 run:owner 点击后上游模型请求被中断,卡片变 Stopped 且保留已产出正文并移除按钮;随后的新 Prompt 正常完成;把同一个 btn_stop 回调重放两次,卡片 API 调用数为 0,新 run 仍为 Completed
  3. 结构化提问闭环:最关键的观测是续写卡片里出现模型对自己工具返回值的回显 —— AGENT-SAW>> User has provided the following answers: **Deploy target**: Production <<END,证明答案真正进入了 Agent 上下文,而不只是卡片 UI;Other 自由文本、多选(Redis, Memcached)同样成立。卡片原地更新为 Submitted;对已结算卡片重放提交会被忽略;续写输出开在同一 run 的新 segment / 新卡片上;提问期间 /approve 被明确拒绝;/denyquestionCard.timeoutMs 超时、steer 都只会把卡片置为 expired/cancelled不会向 Agent 合成答案,其后的过期回调也完全无效。
  4. owner 绑定与多用户隔离:非发起人提交 → 每个 actor/card 只出现一条群内提示,卡片保持 pending,Agent 收不到任何权限响应;同一人再次点击静默;owner 之后仍能正常提交。同一群里两个用户各自持有互不干扰的卡片,且无法回答对方的卡片。
  5. 前后对比(merge-base A/B):同样的 Prompt、同样的 settings.json(含 interactiveCards),只换构建。0bafd0db 完全忽略该配置 —— 0 次卡片 API 调用,提问以文本形式转发,/approve 后 Agent 得到的是 “No valid answers were provided.”。我还在本 PR 上关闭卡片跑了同一流程,链路与 merge-base 完全一致,说明对既有部署是无副作用的。
  6. 降级与开关:未配置 interactiveCards → 0 卡片调用、原 Markdown + 文本权限;questionCard.enabled=false / statusCard.enabled=false / blockStreaming=on 三种组合都只关闭对应能力;建卡返回 500 → Markdown 兜底只发一次 + 卡片终态化为 Unavailable;流式写入 500、run 中途失败(Failed · mock-model · 1s 且保留已流式正文)都不会留下空白 Running 卡;[IMAGE: /abs/path] 在所有流式快照中均为 [Image pending],终态卡片是上传后的 mediaId,且兜底路径下图片只上传一次。

观察(不阻塞合并)

  1. 新增用户可见文案中英混杂:本 PR 新增的卡片文案都是英文(Submitted.This question expired. Please retry.Running · <model> · 3s,以及 ChannelBase 的 “Submit this question through its interactive card, or use /deny [request-id] to cancel it.”),但非 owner 提示是中文 —— 卡片操作 / 仅任务发起人可以操作这张卡片,本次操作未生效。DingtalkAdapter.sendCardInteractionFeedback)。建议合并前统一。
  2. “同一 run 第一张卡待回答时再次提问”在正常 Agent 回合中不可达:CLI 会串行执行 ask_user_question,第二个权限请求要等第一个结算后才发出,届时它会正确地拿到属于自己的新卡片(实测:2 张卡,第一张 submitted、第二张 pending,两个答案都回到 Agent)。因此 unsupported + 文本降级分支目前只由单测覆盖;按 Test Plan 该步骤操作的评审者不会复现出这个现象。最新一个提交里的“更新的 run 让旧卡过期”同理:steerChannelBase 在新 run 提问之前就已结算了前一个 run 的待处理权限,所以该路径也属于防御性分支,端到端不可达。
  3. 一次尚未产出任何文本就失败的 run,在钉钉侧是完全静默的 —— 既没有消息也没有卡片,因为没有可终态化的输出段。我确认这是既有行为(关闭卡片以及 merge-base 构建上表现一致),不属于本 PR 范围;但状态卡也没有改善它。可选后续:对有 owner 的 attended run,即使没有流式输出也终态化一张 Failed 卡。

另有两处适合补进文档:"interactiveCards": {}(空对象)会同时启用两类卡片(enabled 默认跟随“是否配置”,{} 不等于关闭);状态行的模型名取自 channels.<name>.model,未配置时该段会静默消失(Running · 3s)。

同时跑过npm run typecheck / build / bundle 全部通过;channels/base 955/955、channels/dingtalk 249/249、channels/feishu 110/110、sdk-typescript DaemonSessionClient 49/49。channels/wecom 为 133/134,失败用例 continues attachment cleanup when one dir removal fails 是以 root 运行时的环境问题(chmod 0o500 挡不住 root 的 rmSync),在 merge-base 源码下同样失败,且该文件不在本 PR 改动范围内。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot has no review of its own on 403b3ad3fabf6b36a305466461f7fde1d4b81adb. If this re-run was meant to approve, it did not — an approval left by another account is a separate vote and does not count as the bot's own.

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review timed out. Qwen review timed out after 10800 seconds (of the 180-minute budget). For large PRs, retry with a longer timeout by commenting: @qwen-code /review --timeout=240. See workflow logs.

@BenGuanRan
BenGuanRan added this pull request to the merge queue Jul 29, 2026
Merged via the queue into QwenLM:main with commit 27428e2 Jul 29, 2026
76 of 77 checks passed
wenshao added a commit to chiga0/qwen-code that referenced this pull request Jul 29, 2026
The DingTalk interactive cards change (QwenLM#6930) added concurrent
session-cancellation coalescing to DaemonSessionClient, growing the
minified browser daemon bundle to 180295 bytes — 71 bytes over the
176KB budget, breaking npm run build on main. Bump the budget to
177KB following the established pattern.

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

Copy link
Copy Markdown
Collaborator

Released in v0.21.2.

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

Labels

category/integration External integrations scope/interactive Interactive CLI features status/in-review This issue is currently in review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants