docs/design/2026-07-15-dingtalk-interactive-cards.md
Final implementation contract for #6443. This document fixes the implementation boundary, payload contract, state ownership, degradation behavior, and acceptance criteria followed by the accompanying runtime implementation.
The DingTalk channel can already deliver Markdown, receive task lifecycle events, relay permission requests, and cancel an active prompt. It does not provide an in-place running-status card, an exact-run Stop action, or a form card that can return structured ask_user_question answers to the original request.
The design adds those DingTalk interactions without teaching the model, tools, ACP schema, or other channel adapters about DingTalk templates and callback payloads.
The architecture has four ownership layers:
ChannelBase owns pending-request registration, settlement, and exact-run cancellation.There are two card types, not one generic card lifecycle:
| Card | Business object | DingTalk protocol | Local lifecycle |
|---|---|---|---|
| Streaming status card | One visible output segment | createAndDeliver, /card/streaming, /card/instances | running, completed, failed, stopped, cancelled |
| Form callback card | One Channel-owned user-question request | createAndDeliver, card callback, /card/instances | pending, submitted, cancelled, expired, resolved_outside_presenter |
They share authentication and callback ingress, but they keep independent registries and state machines.
ask_user_question already defines questions, options, and multi-select behavior.ChannelBase already supports multiple pending permission requests for the same chat.started, text chunks, tool calls, completed, failed, and cancelled./cancel.The behavioral constraints below were rechecked against origin/main during implementation:
packages/channels/base/src/ChannelBase.ts registers each pending permission, including its request and chat index, before formatting or sending the existing Markdown prompt. The same registry supports multiple requests in one chat and drives /approve, /approve-always, and /deny lookup.packages/channels/base/src/ChannelAgentBridge.ts includes the permission outcome on PermissionResolvedEvent. packages/channels/base/src/AcpBridge.ts emits that event synchronously before a successful responder returns, while packages/channels/base/src/DaemonChannelBridge.ts retains a responded-request mapping and can emit the event later.packages/core/src/tools/askUserQuestion.ts permits one to four questions. The live permission_request carries the ordered questions but does not guarantee a render-ready answerKey on each one. packages/acp-bridge/src/bridgeClient.ts adds index-based answer keys only to its pending-interaction status snapshot. The Channel seam must therefore derive the same String(index) keys when it normalizes the live request.answers: Record<string, string> in addition to the permission outcome. Multi-select answers remain comma-and-space joined strings for compatibility with the existing TUI and Web clients.ask_user_question through the current Channel path therefore resumes it with an empty answer map and produces No valid answers were provided. The card-presented path must not reuse /approve.The labels in this document are normative:
ChannelBase or Channel-owned public types.| Layer or surface | Impact | Required work |
|---|---|---|
packages/channels/base/src/ChannelBase.ts | Change required — shared Channel | Add run identity, exact-run cancellation, semantic question normalization, presentation settlement, and structured-question command handling. |
packages/channels/base/src/types.ts and exports | Change required — shared Channel | Add semantic-input types plus optional public lifecycle runId and owner; attended events emitted by ChannelBase always populate both. |
packages/channels/dingtalk | DingTalk-only change | Add card configuration, Card OpenAPI access, callback parsing, owner checks, two registries, bounded coalesced projections, degradation, and tests. |
| This design document | Change required — documentation only | Record the final payload, ownership, change-impact, lifecycle, degradation, and acceptance contracts. |
| Architecture assets | Documentation only | Show the runtime chain, compatibility and degradation matrix, and future adapter extension boundary without introducing platform fields into the shared contract. |
packages/core, ask_user_question, and ToolConfirmationPayload | No change | Continue producing semantic questions and consuming answers. |
ACP agent session, ACP schema, acp-bridge, permission mediator, daemon routes, and daemon SDK | No change | Continue carrying toolCall, permission options, outcomes, and top-level answers. |
ChannelAgentBridge, AcpBridge, DaemonChannelBridge, daemon worker, and SessionRouter | No change | Continue relaying complete permission requests, routing by owning sessionId, and returning permission responses. No separate userQuestionRequest bridge event is introduced. |
| CLI/TUI, Web/Desktop, IDE, SDK clients | No change | Continue using their existing native question UIs and permission transports. |
| Feishu, WeCom, QQ, Telegram, Weixin, and plugin adapters | No direct change | Inherit the default unsupported presentation result and retain existing permission Markdown and commands. Their known inability to return structured Channel answers remains explicit. |
The optional public lifecycle runId and owner avoid forcing third-party adapters or test fixtures that synthesize lifecycle events to change immediately. runId is not optional inside ChannelBase: every Channel-owned prompt has one, and every lifecycle event emitted for that prompt includes it. An attended inbound prompt also carries the normalized Channel owner; loop and webhook prompts deliberately omit it. DingTalk creates no interactive card if the required identity is absent.
ChannelBase gains one semantic presentation hook with three explicit outcomes:
type UserInputPresentationResult =
| { kind: 'presented' }
| { kind: 'handled' }
| { kind: 'unsupported' };
type UserInputSettlementReason =
| 'resolved_outside_presenter'
| 'cancelled'
| 'run_cancelled';
type ChannelUserInputResponse = RequestPermissionResponse & {
answers?: Record<string, string>;
};
interface ChannelUserQuestion {
answerKey: string;
header: string;
question: string;
options: Array<{
label: string;
description: string;
}>;
multiSelect: boolean;
}
interface ChannelPromptOwner {
kind: 'channel_user';
id: string;
}
interface ChannelUserInputRequestContext {
requestId: string;
sessionId: string;
runId: string;
owner: ChannelPromptOwner;
target: SessionTarget;
questions: ChannelUserQuestion[];
submitOptionId: string;
onSettled(listener: (reason: UserInputSettlementReason) => void): () => void;
respond(response: ChannelUserInputResponse): Promise<boolean>;
}
protected presentUserInputRequest(
context: ChannelUserInputRequestContext,
): Promise<UserInputPresentationResult>;
onSettled is a typed one-shot subscription rather than an AbortSignal, whose public reason is any. ChannelBase is the only settlement writer; it calls each listener with a UserInputSettlementReason, and the returned function unregisters only that listener. The shared ChannelPromptOwner is deliberately adapter-neutral: it identifies the human Channel user that started the run without exposing DingTalk callback payloads or identity field names. The context contains no template ID, action ID, outTrackId, or mutable bridge object. submitOptionId is the original permission option advertised as allow_once; for compatibility with current producers, an option whose ID is proceed_once and whose kind is absent is treated the same way. The adapter never invents an option ID.
ChannelBase owns one normalizer so adapters do not independently reinterpret the ACP payload:
toolCall._meta.qwenInteractionKind === 'user_question'.toolCall._meta.qwenQuestions.toolCall.rawInput.questions is accepted only when the canonical tool name or tool kind also identifies AskUserQuestion. A different tool that happens to accept a questions argument is not semantic user input.multiSelect to false, and assigns answerKey: String(index).The hook is inserted after the pending permission and its settlement controller are stored, but before the existing permission formatter and sender:
store PendingPermission + settlement controller
active = current attended Channel-owned ActivePrompt for event.sessionId
normalize semantic question + compatible allow_once option
if valid question and active has runId + submitOptionId:
construct context from active and normalized questions
result = presentUserInputRequest(context)
presented -> mark structured input as presented, keep pending, and return
handled -> only valid if the adapter synchronously invoked context.respond
unsupported -> continue
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. ChannelBase records whether it was invoked before the presentation hook resolves. handled without that invocation is a contract violation and falls through to the existing permission message; it is not a second way to leave a request pending.
Every path that removes a pending permission settles the controller exactly once. This includes permission commands, the context responder, daemon permissionResolved, session cleanup, task cancellation, and bridge replacement. A locally known run cancellation settles with run_cancelled before a later collapsed bridge outcome can overwrite it. An independent permissionResolved with a cancelled outcome, or with the original reject option, becomes the neutral cancelled; another or missing outcome becomes resolved_outside_presenter. The bridge does not preserve enough cause information to infer timeout versus denial versus cleanup, so this classification never labels an unknown cancellation as expired and never guesses which client responded. The DingTalk-local question timer owns the distinct expired projection before it calls the responder.
The hook is only eligible for the current attended Channel-owned ActivePrompt. loopPrompt === true is ineligible; that excludes both scheduled loop jobs and webhook producers, whose message IDs and senders are synthetic rather than human DingTalk input. When no eligible active 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. The adapter independently requires the same real DingTalk inbound-message ownership record for the run. A run started by CLI, Web, IDE, SDK, another client, a loop, or a webhook therefore creates neither card-bound interaction. The initial design does not add cross-client identity federation.
The default hook returns unsupported. Other IM adapters therefore retain their current permission formatting and commands.
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.
ChannelTaskLifecycleBase exposes runId?: string and owner?: ChannelPromptOwner for source compatibility. ChannelBase includes the concrete run ID on every started, text_chunk, tool_call, and terminal event it emits. Attended prompts include the same owner on every event; loop and webhook prompts omit it. A consumer receiving an event without the required identity may continue its existing behavior but cannot create a card action.
A status-card Stop callback carries that runId into a new protected ChannelBase exact-run cancellation entry point. The method reads the current active prompt once and atomically checks the expected ID before entering the existing cancellation path. A missing active prompt or a missing, stale, or mismatched ID returns false; the card-bound path never falls back to session-only cancellation. Existing /cancel behavior remains session-scoped and unchanged.
The accepted Stop sequence is:
ChannelBase to cancel the exact expected run.true, block new status-card chunks, close streaming, and commit the Stopped presentation.false and the same record is still current and non-terminal, release the claim, keep the card active, and allow a retry.The claim is an adapter-local in-flight lock, not a lifecycle state. An asynchronous result can update or release only the same still-current, non-terminal record; a timeout, settlement, or terminal lifecycle event that wins during the await cannot be overwritten. This prevents an old card from cancelling a newer prompt, prevents duplicate callbacks from racing, and avoids claiming success before cancellation succeeds without adding a public processing state.
Card-action authorization is stricter than shared-session message authorization. Stop, submit, and cancel are always owner-only regardless of sessionScope.
At inbound-message time, DingTalk already prefers senderStaffId and falls back to senderId for the envelope sender. Before handing a real inbound turn to ChannelBase, the adapter records messageId -> DingTalkOwnerKey. The map follows the existing inbound-message cap of 1,000 entries. A matching started lifecycle event consumes and removes that mapping, creates a DingTalk-local run/status record, and binds the same Channel-generated runId to the typed owner. Loop and webhook message IDs never enter the map. Terminal run cleanup removes the run/status record after finalizing its questions. The callback router normalizes the callback's userId, senderStaffId, or senderId into the same typed domain and requires an exact match. If no comparable identity is available, the action fails closed.
A foreign-user callback is acknowledged but cannot mutate a run, permission request, or card. When the live card belongs to a group, the controller returns the original group target with the forbidden result and the adapter sends a generic “only the task owner can operate this card” notice to that group after the callback ACK. This notice uses the outbound group-message path directly: it is not converted into an inbound message and never enters Agent context. A failed notice is logged and does not fall back to permission settlement, card mutation, or Agent delivery. Direct-card forbidden feedback retains the existing direct-message path.
ignored remains distinct from forbidden. Duplicate, stale, malformed, and unrecognized callbacks are acknowledged and safely discarded without group feedback, preventing repeated or forged callbacks from flooding a group. The distinction is an adapter-internal callback disposition, not a visible DingTalk card state.
Only the DingTalk adapter reads interactiveCards and registers the card callback topic. It owns:
runId, with an optional status-card outTrackId.requestId and outTrackId.Question presentation is scoped by sessionId + owner.id. Different users and sessions may own live cards independently. If the same run already has a pending native question in that scope, another request returns unsupported: ChannelBase keeps the first card answerable and sends the second request through the existing text permission fallback. It does not expire the first card or synthesize a permission response. Run termination still expires or cancels every card owned by that run.
The status card represents one visible output segment inside a Channel-owned run. Runs initiated by CLI, Web, IDE, SDK, or another client can still affect shared session state, but they do not create a DingTalk status card.
Creation and streaming follow DingTalk's streaming-card protocol:
createAndDeliver with a unique outTrackId and initial flowStatus=2.isFull=true, isFinalize=false, and isError=false./card/streaming./card/instances with updateCardDataByKey=true.Raw chunks never become one network request each. Each status record allows at most one Card OpenAPI write in flight and one replaceable pending full snapshot. A fixed 500 ms minimum flush interval coalesces newer chunks into that pending snapshot. Visible content is capped at 20,000 characters; overflow drops the oldest content and inserts a truncation marker rather than growing memory. Every Card OpenAPI call has a 10-second timeout. An intermediate timeout or failure records a structured error, stops further streaming writes for that card, and retains the latest bounded text for the awaited final delivery path.
Status cards are lazy and segment-scoped. A direct question creates no status card. Text before a question closes its segment before the question card is presented, and later continuation text opens a new segment:
first visible text -> running
running -> completed
running -> failed
running -> stopped | cancelled
question settlement + later text -> a new running segment
The core lifecycle remains cancelled; no stopped event is introduced. A cancellation with reason cancel_command may be presented as “Stopped” in DingTalk, while other cancellation reasons may be presented as “Cancelled”.
For blockStreaming !== 'on', DingTalk overrides the existing awaited onResponseComplete() seam. That method consumes the latest accumulated text, cancels a pending flush timer, waits for the single in-flight write within its timeout, performs the completed final instance update, and falls back to the existing Markdown sender if card creation or finalization did not succeed. ChannelBase therefore emits completed only after one awaited delivery path finishes. No new shared terminal-delivery hook is added.
When blockStreaming === 'on', DingTalk does not create a status card and does not consume raw lifecycle chunks for card delivery; the existing BlockStreamer remains the only response-delivery path. Question cards remain independently eligible. onTaskLifecycle records terminal causes and may make best-effort failed/cancelled projections, but it is not treated as an awaited delivery guarantee.
Terminal status-card updates follow one bounded order:
isFinalize=true.flowStatus=3 with one /card/instances update.Completed, failed, and cancelled all project to DingTalk flowStatus=3; the final content and status text distinguish them. Once terminal, the per-outTrackId writer rejects late streaming updates.
The question card represents one permission request containing the complete normalized question array. The tool schema allows one to four questions, and ChannelBase derives the same index-based answerKey convention used by daemon pending-interaction snapshots. One card therefore renders and submits the full set; there is no per-question registry or card lifecycle. It is created with card_status=pending and does not call /card/streaming. All presentation changes use /card/instances with updateCardDataByKey=true.
Each pending record contains:
requestId, outTrackId, and runId.submitOptionId.reserved, pending, or claimed state; terminalization
replaces the record with a compact tombstone.The lifecycle follows the latest OpenClaw delivery-race discipline without copying its persistence or synthetic-message continuation:
reserved inserted and subscribed before createAndDeliver
pending activated only after successful delivery while still reserved
claimed atomically claimed by one valid callback
terminal first settlement wins; live payload is compacted
If settlement or run cancellation makes a reserved record terminal while
createAndDeliver is in flight, a later successful delivery cannot reactivate
it. The adapter disables that delivered card best-effort and returns without
calling the responder again.
The callback order is authoritative:
outTrackId and correlate the request and run.pending record as claimed before the first asynchronous operation.Submit encodes the form using the existing cross-client contract:
{
"outcome": {
"outcome": "selected",
"optionId": "<advertised allow_once option>"
},
"answers": {
"0": "Beijing staging",
"1": "Logs, Metrics"
}
}
Single-select values and custom input are strings. Multi-select values are joined with ", " to match the current TUI and Web behavior. Cancel sends only a cancelled or advertised reject outcome and no answers. The adapter never sends a synthetic prompt or inbound message.
The card never displays submission success before the responder accepts the answer:
| Event | Local state | Card projection |
|---|---|---|
Submit responder returns true | submitted | Submitted and disabled |
Cancel responder returns true | cancelled | Cancelled and disabled |
respond(...) === false | expired | Non-interactive card_status=expired, “Question no longer available” |
respond(...) throws | expired | Non-interactive failure projection, disabled, and not retryable |
| Independent non-cancel settlement | resolved_outside_presenter | Non-interactive card_status=expired, “Resolved outside this card” |
| Independent collapsed cancellation | cancelled | Non-interactive card_status=cancelled, neutral “Cancelled” |
| Timeout | expired | Expired and disabled |
| Request or run destroyed | cancelled | Cancelled or Stopped and disabled |
| Duplicate or late callback | Existing terminal state | Acknowledge and ignore |
| Settlement on a terminal record | Existing terminal state | Ignore through the terminal tombstone |
The resolved_outside_presenter local state is entered only from an independent non-cancel settlement event, not inferred from a false responder result. false means only that the permission response was not accepted: the request mapping may be absent, its session may be gone, or another surface may already have won. Both cases therefore use the non-interactive expired projection without claiming user cancellation.
The existing daemon bridge consumes the request-to-session mapping when respondToPermission() throws, and ChannelBase removes the pending request on the same path. A later daemon permissionResolved is no longer a reliable cleanup signal because the bridge may reject it as an unknown request. DingTalk therefore logs the failure, removes its pending record, retains the terminal tombstone, and immediately makes a best-effort non-success projection. It does not release the claim or promise callback retry.
AcpBridge emits permissionResolved synchronously before a successful respondToPermission() returns. While the DingTalk responder claim is in flight, the adapter therefore defers the matching settlement projection until the responder result and callback action are known. An accepted submit becomes submitted; an accepted cancel becomes cancelled; false and throws use the terminal rows above. A settlement received without a local responder claim follows the outcome-aware rows above. The daemon bridge emits its successful settlement later, after it has retained a responded-request mapping; if the card is already terminal, the tombstone ignores that event. The DingTalk-local timer first finalizes the live card as expired and then calls the responder, so the bridge's collapsed cancellation cannot relabel it. A locally known run cancellation similarly finalizes as run_cancelled before bridge cleanup. Unknown collapsed cancellations remain the neutral cancelled. This arbitration reuses the transient claim and adds no public processing state, retry queue, or error taxonomy.
An instance update is a UI projection, not the permission transaction. If the responder succeeds but the subsequent card update fails, the permission remains resolved, the local record remains terminal, duplicate callbacks remain rejected, and the adapter logs the failed UI projection.
Unlike the OpenClaw reference implementation, Qwen Code does not inject a synthetic inbound message. It responds directly to the original permission request. A second request in the same live run uses the text fallback and leaves the first native card answerable.
The capability configuration is local to DingTalk. It is parsed by the DingTalk adapter and does not add a cross-channel card concept to ChannelConfig:
{
"interactiveCards": {
"enabled": true,
"statusCard": {
"enabled": true
},
"questionCard": {
"enabled": true,
"timeoutMs": 270000
}
}
}
The effective question lifetime is the smaller of the configured timeout and the host permission lifetime.
Template IDs are built-in DingTalk Channel assets, not user configuration. The reference plugin uses these IDs with the installing bot's own DingTalk credentials; they are not treated as resources owned by the reference repository's AppKey:
675cde2f-f526-40cb-b828-f5b2b57b8b77.schemac2a6355b-9724-4f7e-9653-d33fcb3311bb.schemaThe design does not add user-supplied template configuration or a startup health check. A first-use OpenAPI rejection is a loud structured error containing the template ID and DingTalk error code, and then enters the documented degradation path.
Evidence for the built-in asset contract and callback flow:
a8fb6f80e7 supplies the current reserve/activate/claim/terminal delivery-race reference.These sources provide Card OpenAPI, template, and concurrency evidence. Qwen Code does not copy their separate tool, AsyncLocalStorage, persistent lifecycle store, synthetic-message reinjection, question supersession, fail-open owner check, or callback-after-await ACK timing.
The initial design does not add a background retry queue and does not retain a persistent presentation_failed state.
| Situation | Behavior |
|---|---|
| Status card disabled or creation/final update fails | Use the existing awaited Markdown response delivery and record a structured card error. Intermediate update failure stops further streaming writes and preserves bounded text for final delivery. |
| Status card delivered but streaming-open fails | Disable the blank card best-effort, stop card writes for the run, and use the existing awaited Markdown response delivery. |
blockStreaming === 'on' | Skip the status card; retain the existing BlockStreamer delivery path. Question cards remain independently eligible. |
| Question card created | Return presented; keep the original permission pending. |
| Same run already has a pending native question | Return unsupported for the newer request; keep the first card active and use the existing text permission fallback for the newer request. |
| Question card disabled or creation fails | Send readable semantic Markdown, state that the question was cancelled and can be retried, cancel the original request, return handled, and log the template-aware failure. |
| No current Channel-owned active run | Treat presentation as unsupported; skip both DingTalk cards and preserve the existing permission path. |
Exact-run cancellation returns false | Release the transient claim only if the same record remains current and non-terminal; keep the status card active so Stop can be retried. |
Question responder returns false | Finish with the existing cancelled projection and a neutral “Permission no longer pending” message. |
| Question responder throws | Remove the pending record, finish the claimed record as cancelled, retain a tombstone, project non-success immediately, and do not advertise callback retry. |
| Another path resolves first | When no local responder claim is in flight, classify a collapsed cancellation as neutral cancelled; use resolved_outside_presenter only for a non-cancel outcome. |
| Request/run is destroyed | Settle as request/run cancellation; project the card as cancelled or Stopped. |
| 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. |
For a card-presented question, /approve and /approve-always remain recognized but do not call the responder; they instruct the user to submit through the card because approval cannot supply the required answers object. /deny [requestId] remains an escape hatch because denial is already complete without answers. ChannelBase requires the command sender to match the originating prompt sender, then routes the denial through the same one-shot context responder so card settlement, registry cleanup, and first-responder-wins semantics remain intact. Ambiguous requests retain the existing explicit-request-ID prompt. Other permissions and adapters keep their current command behavior. The initial design does not promise automatic callback retry.
| Client or surface | Impact | Behavior after this proposal |
|---|---|---|
| DingTalk Channel-owned run | DingTalk-only change | Create and update the streaming status card. |
| DingTalk Channel-owned question request | DingTalk-only change | Present the form callback card or DingTalk-local semantic fallback. |
| DingTalk-routed request without a Channel-owned active run | No behavior change | No DingTalk card; preserve the existing permission path. |
| CLI/TUI | No change | Continue using the native question dialog. |
| Web/Desktop | No change | Continue using the native question component and existing action transport. |
| IDE/ACP | No change | Continue using the native ACP question UI; no schema change. |
| SDK and custom ACP clients | No change | Continue using the existing permission request and response protocol. |
| Other IM adapters | No direct change | Inherit unsupported; retain their current permission behavior and known limitation. |
| Ordinary permissions | No change | Keep the existing approval UI and commands on every client. |
Permission resolution remains first-responder-wins. The transient DingTalk claim only serializes callbacks for one card and arbitrates a matching settlement that arrives during its responder call; it does not replace shared settlement. If an independent settlement arrives without a local claim, DingTalk classifies its outcome without claiming which client responded. If the card responder returns true, the callback action selects submitted or cancelled, and a matching permissionResolved is cleanup rather than evidence that another surface won.
The implementation is complete only when the following behavior is covered. These tests exercise the changed layers; unchanged Core, ACP, daemon, Web, IDE, and other adapter suites are not feature work for this proposal.
runId; all lifecycle events for that prompt carry the same ID, and a later prompt in the same session gets a different ID.false and never fall back to session-only cancellation._meta.qwenInteractionKind plus _meta.qwenQuestions, assigns ordered string answer keys, and normalizes missing multiSelect to false.rawInput.questions only for an identified AskUserQuestion tool and does not misclassify another tool with a questions argument.kind: allow_once and the current legacy proceed_once option with no kind, and never invents an option ID.presented, handled, and unsupported each follow their declared pending-ownership behavior./approve or /approve-always; owner-only /deny [requestId] uses the same one-shot responder, while ordinary permissions retain all commands.UserInputSettlementReason values; locally known run cancellation wins over a later collapsed bridge cancellation.permissionResolved, timeout, cancellation, session death, bridge replacement, and send failure settle and remove the pending record exactly once.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.runId, rejects duplicates, and remains retryable only after a non-terminal false result.allow_once option, encodes single, multi-select, and custom answers as Record<string, string>, and directly resolves the original request.false, responder throw, and card projection failure all use finalizeQuestion, clear the run-level pending set, and never reopen a terminal record.blockStreaming=on, verify the existing block response remains authoritative while question cards can still be submitted successfully.The shared hook is an opt-in seam, not a rollout of DingTalk behavior. Feishu, QQ, Telegram, WeCom, Weixin, and plugin adapters do not read DingTalk configuration, template IDs, callback actions, or card states. Their existing permission formatting and commands remain unchanged.
The existing limitation remains explicit: /approve cannot carry ask_user_question answers. This proposal does not silently cancel questions or expose raw request JSON on other IM adapters.
A future IM adapter may explicitly override the semantic hook for a request tied to its own current ActivePrompt. An adapter returning presented must own its platform presentation, callback or structured-reply parser, pending registry, owner and run checks, timeout, cause-aware settlement, idempotency, and direct response to the original request. It must not inject a synthetic user message merely to resume the run.
Each adapter should opt in through a separate change so its platform-specific capability and state ownership can be reviewed independently.
The first implementation is intentionally daemon-local. Live pending-card registries are tied to the process lifetime; restart-safe recovery and non-sticky multi-instance callback routing require a separate persistence design. A terminal record is compacted to only callback correlation, terminal state, and expiry metadata, retained for 10 minutes for callback redelivery, and stored in insertion-ordered maps capped at 1,000 entries per card type. Expiry and oldest-entry eviction reclaim it; no responder, question payload, answer payload, timer, subscription, or queued content survives terminalization.
This implementation does not add cross-client run ownership or identity mapping, a cross-channel text-answer protocol, free-form reply parsing, synthetic message injection, a general cross-channel card framework, a callback retry system, or a new processing/error state machine.