From 6509e8de08fca74097817686ecdfd09980a74615 Mon Sep 17 00:00:00 2001 From: qqqys Date: Thu, 2 Jul 2026 19:01:07 +0800 Subject: [PATCH] feat(channels): show lifecycle status in adapters (#6114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: ignore local worktrees * docs(channels): design identity and task lifecycle p0 * docs(channels): plan identity and task lifecycle p0 * feat(channels): add identity and task lifecycle metadata * fix(channels): suppress cancelled tool call lifecycle * docs: add channel lifecycle status adapter design * docs: add channel lifecycle status adapter plan * feat(channels): map telegram lifecycle to typing * feat(channels): map weixin lifecycle to typing * fix(channels): reset weixin typing state after failed start * feat(channels): map dingtalk lifecycle to reactions * feat(channels): add feishu card status labels * fix(channels): preserve feishu collapsible status labels * feat(channels): show feishu lifecycle card status * fix(channels): cover loop lifecycle metadata * fix(channels): preserve feishu terminal card status * chore: remove internal task reports from branch * fix(channels): clear late lifecycle status starts * fix(channels): harden lifecycle event edges * fix(channels): clean adapter lifecycle state * fix(channels): finalize cancelled lifecycle before cleanup * fix(feishu): keep cancelled card status visible * docs(channels): add lifecycle status no-op coverage * fix(channels): address lifecycle review suggestions * docs(channels): align lifecycle status documentation * fix(channels): route adapter stop through lifecycle cancel * fix(channels): close lifecycle cancellation races * fix(channels): keep first feishu terminal status * fix(telegram): guard lifecycle typing updates * test(qqbot): cover lifecycle status no-ops * fix(feishu): preserve completed card race status * docs(lifecycle): align status review docs * fix(channels): separate pending cancel state * fix(channels): clean adapter lifecycle status edges * fix(channels): order clear cancellation lifecycle * fix(feishu): preserve user stop status label * fix(channels): suppress loop chunks during pending cancel * test(channels): use active session in cancel regression * fix(channels): harden adapter cancellation tests * fix(channels): sanitize lifecycle tool fields * fix(channels): route shared tool call lifecycle * fix(channels): preserve pending cancel intent * fix(channels): preserve pending cancel intent * fix(telegram): track typing by session * fix(feishu): preserve stop status during finalization * fix(channels): preserve responses after failed cancel * fix(channels): tighten lifecycle cancellation reasons * fix(channels): close lifecycle cancel races and validate identity config Address the outstanding review findings on #6105: - carry a typed reason on ChannelLoopSkippedError and report disabled loops as 'dropped' instead of 'timeout' - treat a turn as committed once delivery starts: /cancel re-checks deliveryStarted after the cancel RPC settles, and neither prompt path lets a late-settling cancel rewrite a delivered turn into cancelled (or follow a /clear cancellation with completed) - tag failed lifecycle events with phase: agent vs delivery - validate identity/memoryScope shape at config parse time instead of throwing an opaque TypeError on the first prompt of every session - guard onPromptStart hooks, pass job.id to loop onPromptStart/End, append the boundary block after operator instructions, gate /who + /status identity lines on configured identity/memoryScope, cache the boundary prompt, and route error logs through lifecycleError() Co-authored-by: Qwen-Coder * test(qqbot): keep lifecycle mock in sync * fix(channels): unify cancel state machine and adapter lifecycle edges Address the remaining review findings on #6114: - /cancel now delegates to requestActivePromptCancellation — one cancel state machine for slash command and adapter stop buttons; the helper refuses to claim success once delivery started and sanitizes its logs - share isTerminalTaskLifecycleType from channel-base instead of four hand-rolled terminal checks - DingTalk: only attach reactions for message ids seen inbound (loop job ids no longer trigger doomed emotion API calls), log reaction API failures, and recall reactions when a session dies - Feishu: track userStopped on the card state so every wind-down path renders 已停止生成 after a Stop click, and pass terminal labels through updateCard's statusLabel param at the remaining baked-text sites - Weixin: guard the typing .then() against a racing disconnect - document onTaskLifecycle as the canonical hook (onPromptStart/End are back-compat), fix TS4111 bracket access in the DingTalk test Co-authored-by: Qwen-Coder * fix(channels): keep failed terminal event when cancel settles post-delivery Self-review follow-up: once delivery started, the catch paths no longer reconcile a pending cancel — a late-resolving cancel RPC used to flip cancelled=true there, suppressing the failed emit while the /cancel handler (seeing deliveryStarted) also declined to emit, leaving a started task with no terminal lifecycle event at all. Co-authored-by: Qwen-Coder * fix(channels): close self-review findings on adapter lifecycle edges - DingTalk: track inbound message ids in a capped insertion-ordered set instead of the TTL-swept dedup map, so a turn queued minutes behind a long predecessor still attaches its reaction - Feishu: reset userStopped when the cancel RPC fails (later wind-down must render the real terminal status), and give handleStop's plain message fallback the same ---/label shape the strip regex expects Co-authored-by: Qwen-Coder * fix(channels): hold streamed chunks while a cancel is pending Chunks arriving while a /cancel RPC is in flight were pushed straight into the BlockStreamer, which can send a block on a size/paragraph threshold before the cancel resolves — leaking output a successful cancel can never recall. Hold the pending-window chunks instead: replay them (block streaming + text_chunk transcript) when the cancel fails, discard them when it succeeds. onResponseChunk stays live through the window so adapter-accumulated display state has no permanent hole on a failed cancel; adapters gate visible updates on their own stop flags. Also stop passing the loop job id to onPromptStart/onPromptEnd (and the /clear eviction path): the hook contract is inbound platform message ids, and adapters act on them — cards and reactions keyed to a fake id. Lifecycle events still carry job.id for correlation. Co-authored-by: Qwen-Coder * fix(feishu): strip every status-label layout from quoted-reply context Replace the two $-anchored strip regexes in extractCardText with one line-granular status-block filter. The anchored patterns missed four layouts the cards actually render: the two-block truncation card (notice block + label block), terminal labels joined mid-string before a collapsible panel body, the 停止失败,请重试 label (never in the alternation), and label-only stopped cards where the divider leads the text. Tests now assert the real rendered shapes. Also update the adapter-cancellation suppression test to the new hold-and-replay chunk semantics from the base layer. Co-authored-by: Qwen-Coder * fix(feishu): ignore loop job ids in prompt hooks * fix(channels): defer adapter chunks while cancel is pending * fix(feishu): preserve bare status text in quotes * fix(channels): release held chunks on failed cancel * fix(feishu): preserve generated stop fallback status * fix(telegram): clear typing on dead sessions * fix(feishu): share status label definitions --------- Co-authored-by: Shaojin Wen Co-authored-by: Qwen-Coder --- ...07-01-channel-lifecycle-status-adapters.md | 149 +++ ...07-01-channel-lifecycle-status-umbrella.md | 42 + ...07-01-channel-lifecycle-status-umbrella.md | 30 + ...07-01-channel-lifecycle-status-adapters.md | 1094 +++++++++++++++++ ...07-01-channel-lifecycle-status-umbrella.md | 30 + .../channels/base/src/ChannelBase.test.ts | 238 +++- packages/channels/base/src/ChannelBase.ts | 130 +- packages/channels/base/src/index.ts | 1 + packages/channels/base/src/types.ts | 7 + .../dingtalk/src/DingtalkAdapter.test.ts | 328 ++++- .../channels/dingtalk/src/DingtalkAdapter.ts | 127 +- packages/channels/feishu/src/FeishuAdapter.ts | 326 ++++- packages/channels/feishu/src/adapter.test.ts | 970 ++++++++++++++- packages/channels/feishu/src/markdown.test.ts | 35 + packages/channels/feishu/src/markdown.ts | 12 +- packages/channels/plugin-example/README.md | 4 + packages/channels/qqbot/src/send.test.ts | 75 +- .../telegram/src/TelegramAdapter.test.ts | 140 ++- .../channels/telegram/src/TelegramAdapter.ts | 83 +- .../channels/weixin/src/WeixinAdapter.test.ts | 198 +++ packages/channels/weixin/src/WeixinAdapter.ts | 53 +- .../src/commands/channel/config-utils.test.ts | 11 + .../cli/src/commands/channel/config-utils.ts | 2 +- 23 files changed, 3918 insertions(+), 167 deletions(-) create mode 100644 .qwen/design/2026-07-01-channel-lifecycle-status-adapters.md create mode 100644 .qwen/design/2026-07-01-channel-lifecycle-status-umbrella.md create mode 100644 .qwen/e2e-tests/2026-07-01-channel-lifecycle-status-umbrella.md create mode 100644 .qwen/plans/2026-07-01-channel-lifecycle-status-adapters.md create mode 100644 .qwen/plans/2026-07-01-channel-lifecycle-status-umbrella.md create mode 100644 packages/channels/weixin/src/WeixinAdapter.test.ts diff --git a/.qwen/design/2026-07-01-channel-lifecycle-status-adapters.md b/.qwen/design/2026-07-01-channel-lifecycle-status-adapters.md new file mode 100644 index 0000000000..fe57fb1980 --- /dev/null +++ b/.qwen/design/2026-07-01-channel-lifecycle-status-adapters.md @@ -0,0 +1,149 @@ +# Channel Lifecycle Status Adapters + +Date: 2026-07-01 + +## Goal + +Expose task lifecycle state through the first four channel adapters: + +- Telegram +- Weixin +- DingTalk +- Feishu + +This is a P1.1 follow-up to the channel identity and lifecycle metadata work. +The goal is to make each supported channel show the best native progress signal +available without changing the shared channel contract again. + +## Non-Goals + +- Do not implement Slack behavior. +- Do not implement QQ Bot behavior. +- Do not update mock/plugin examples. +- Do not add terminal status emoji for DingTalk. +- Do not introduce a shared status-rendering abstraction for one round of + adapter-specific mappings. + +## References and Alignment + +The design follows the current Qwen channel adapter capabilities first. +Lifecycle semantics stay aligned with the existing task/session status model +already used in this repository: a task can start, run, complete, be +cancelled, or fail. No additional external status model is introduced in this +scope because each channel already has a clear native surface for these states. + +## Current State + +| Channel | Existing status surface | Current behavior | +| --- | --- | --- | +| Telegram | Typing indicator | Starts typing on prompt start and stops on prompt end. | +| Weixin | Typing indicator | Starts typing on prompt start and stops on prompt end. | +| DingTalk | Message reaction | Adds the eye reaction on prompt start and recalls it on prompt end. | +| Feishu | Streaming card | Shows and updates a streaming card, with completion and error paths. | + +## Proposed Design + +Keep the implementation adapter-local. Each adapter consumes the lifecycle event +hook and maps the event into the platform's existing native status surface. + +| Lifecycle event | Telegram | Weixin | DingTalk | Feishu | +| --- | --- | --- | --- | --- | +| `started` | Start typing. | Start typing. | Add eye reaction. | Show/update card as running. | +| `text_chunk` | Ignore. | Ignore. | Ignore. | Ignore in the lifecycle hook. Content streaming stays on the existing response/card stream path. | +| `tool_call` | Ignore. | Ignore. | Ignore. | Ignore for UI. | +| `completed` | Stop typing. | Stop typing. | Recall eye reaction. | Mark card completed. | +| `cancelled` | Stop typing. | Stop typing. | Recall eye reaction. | Mark card cancelled. | +| `failed` | Stop typing. | Stop typing. | Recall eye reaction. | Mark card failed. | + +### Telegram + +Telegram keeps the existing typing implementation. The lifecycle hook should map +`started` to the existing typing start path and all terminal events to the +existing typing stop path. + +`text_chunk` and `tool_call` do not need Telegram UI changes. + +### Weixin + +Weixin follows the same shape as Telegram. The lifecycle hook should map +`started` to `setTyping(true)` and terminal events to `setTyping(false)`. + +No additional messages are sent. + +### DingTalk + +DingTalk keeps the existing eye reaction behavior: + +- `started`: attach the existing eye reaction. +- `completed`, `cancelled`, `failed`: recall the existing eye reaction. + +There is no terminal emoji in this scope. Failed and cancelled tasks should not +send extra status messages unless an existing error path already does so. + +### Feishu + +Feishu keeps the streaming card as the status surface and makes the terminal +state explicit in card content: + +| State | Card label | +| --- | --- | +| Running | `运行中...` | +| Completed | `已完成` | +| Cancelled | `已取消` | +| Failed | `已失败,请重试` | + +The card still streams answer content as it does today through the existing +response/card stream hook. Lifecycle `text_chunk` is not consumed directly by +the adapter in this scope, which supersedes the earlier adapter-local idea of +using lifecycle chunks to append card content. `tool_call` remains hidden from +the card UI in this scope. + +The markdown/card helper can accept a minimal status label option if needed, but +should not grow into a generic rendering framework. + +## Data Flow + +1. Channel execution emits lifecycle events from the base channel layer. +2. The selected adapter receives the event through its lifecycle hook. +3. The adapter maps the event to the platform status surface. +4. Platform status updates run best-effort and do not affect task execution. + +The lifecycle event payload should provide enough existing context to identify +the channel message/session. If a platform-specific identifier is missing, the +adapter skips the status update. + +## Error Handling + +Platform status updates are non-critical. A failed typing, reaction, or card +status update should be logged or swallowed according to the adapter's existing +style and must not fail the task. + +Terminal events should be idempotent for a message/session. Repeated terminal +events should not create duplicate status updates or leave a stale running +indicator. + +Feishu needs special care because it already has card completion, error, and +stop-button flows. The lifecycle mapping should reuse the existing card session +state and avoid competing updates that overwrite a more specific terminal state. + +## Test Plan + +Add focused unit coverage in the affected channel packages: + +- Telegram: lifecycle `started` starts typing; terminal events stop typing; no + duplicate typing interval is introduced. +- Weixin: lifecycle `started` calls `setTyping(true)`; terminal events call + `setTyping(false)`. +- DingTalk: lifecycle `started` attaches the eye reaction; terminal events + recall it; no terminal emoji is sent. +- Feishu: running, completed, cancelled, and failed card states render the + expected labels; lifecycle `text_chunk` remains owned by the existing stream + path rather than the lifecycle hook; `tool_call` does not add UI output. + +Verification should run package-local Vitest commands for the touched adapters, +then project build and typecheck before the PR is submitted. + +## Open Decisions + +None. The current scope is intentionally narrow and follows existing adapter +capabilities. diff --git a/.qwen/design/2026-07-01-channel-lifecycle-status-umbrella.md b/.qwen/design/2026-07-01-channel-lifecycle-status-umbrella.md new file mode 100644 index 0000000000..f362fd74a4 --- /dev/null +++ b/.qwen/design/2026-07-01-channel-lifecycle-status-umbrella.md @@ -0,0 +1,42 @@ +# Channel Lifecycle Status Umbrella + +Date: 2026-07-01 + +## Goal + +Provide one review surface that summarizes the lifecycle-status behavior across +the supported channel adapters and calls out what remains intentionally out of +scope. + +## Scope + +- Telegram +- Weixin +- DingTalk +- Feishu + +## Explicit Non-Goals + +- Slack remains out of scope. +- QQ Bot remains out of scope for lifecycle status UI. +- The plugin example remains out of scope for lifecycle status UI. +- DingTalk terminal emoji remains out of scope. + +## Reviewer Matrix + +| Channel | Supported lifecycle events | Native surface | `started` behavior | `text_chunk` behavior | Terminal behavior | Unsupported / no-op reason | Exact test files | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Telegram | `started`, `completed`, `cancelled`, `failed` | Typing indicator | Starts the existing per-chat typing loop once. Duplicate `started` events do not add another loop. | Ignored by the lifecycle hook. Response content continues through the normal reply path. | Stops the typing loop on any terminal event and leaves no stale interval behind. | `tool_call` has no native status surface and does not need adapter UI. | `packages/channels/telegram/src/TelegramAdapter.test.ts` | +| Weixin | `started`, `completed`, `cancelled`, `failed` | Typing indicator | Calls `setTyping(chatId, true)` once for the active chat. Duplicate `started` events do not restack typing state. | Ignored by the lifecycle hook. Response content continues through the normal send path. | Calls `setTyping(chatId, false)` on terminal events. Failed start attempts clear local state so a later `started` can retry. | `tool_call` has no separate status surface and no extra message should be sent. | `packages/channels/weixin/src/WeixinAdapter.test.ts` | +| DingTalk | `started`, `completed`, `cancelled`, `failed` | Eye reaction on the inbound message | Attaches the existing eye reaction once when a conversation id is available. | Ignored by the lifecycle hook. Response content continues through the normal send path. | Recalls the eye reaction on terminal events, including late-resolving attach races after cancellation. | Direct robot webhook chats do not expose the conversation id needed for reactions, so lifecycle status is a no-op there. `tool_call` also has no UI in scope. | `packages/channels/dingtalk/src/DingtalkAdapter.test.ts` | +| Feishu | `started`, `completed`, `cancelled`, `failed` | Streaming card status label | Keeps the card in its running state and reserves space for the running label while the existing card stream is active. | Not consumed directly by the lifecycle hook. Content streaming remains owned by the existing response/card stream hook. | Finalizes the card status label as completed, cancelled, or failed without overwriting the streamed answer body. | `tool_call` stays hidden because the card already uses the answer stream plus terminal status labels only. | `packages/channels/feishu/src/adapter.test.ts`, `packages/channels/feishu/src/markdown.test.ts` | +| QQ Bot | None | None | No-op. | No-op. QQ Bot still streams reply chunks through outbound message sends, but not through lifecycle status updates. | No-op. | The channel has no typing or task-status endpoint, and `QQChannel` leaves `onPromptStart`, `onPromptEnd`, and `onTaskLifecycle` empty by design. | `packages/channels/qqbot/src/send.test.ts`, `packages/channels/qqbot/src/api.test.ts` | +| Plugin example | None | WebSocket protocol messages only | No-op for lifecycle status. | Streams response chunks over the mock protocol's `chunk` message type from `onResponseChunk`, outside lifecycle status handling. | Sends the final outbound message on response completion, outside lifecycle status handling. | The mock channel demonstrates transport wiring only; it has no native typing, reaction, or status surface. | `integration-tests/channel-plugin.test.ts` | + +## Review Notes + +- Feishu lifecycle `text_chunk` remains a no-op in the lifecycle hook. It does + not append or update answer content there. +- Slack is intentionally excluded from this matrix because it is out of scope. +- DingTalk terminal events only recall the existing eye reaction in this scope. + No terminal emoji is added. diff --git a/.qwen/e2e-tests/2026-07-01-channel-lifecycle-status-umbrella.md b/.qwen/e2e-tests/2026-07-01-channel-lifecycle-status-umbrella.md new file mode 100644 index 0000000000..3a8dfe74e3 --- /dev/null +++ b/.qwen/e2e-tests/2026-07-01-channel-lifecycle-status-umbrella.md @@ -0,0 +1,30 @@ +# Channel Lifecycle Status Umbrella Coverage + +Date: 2026-07-01 + +## Support matrix + +| Channel | Supported lifecycle events | Native surface | `started` behavior | `text_chunk` behavior | Terminal behavior | Unsupported / no-op reason | Exact test files | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Telegram | `started`, `completed`, `cancelled`, `failed` | Typing indicator | Starts the existing per-chat typing loop once. Duplicate `started` events do not add another loop. | Ignored by the lifecycle hook. Response content continues through the normal reply path. | Stops the typing loop on any terminal event and leaves no stale interval behind. | `tool_call` has no native status surface and does not need adapter UI. | `packages/channels/telegram/src/TelegramAdapter.test.ts` | +| Weixin | `started`, `completed`, `cancelled`, `failed` | Typing indicator | Calls `setTyping(chatId, true)` once for the active chat. Duplicate `started` events do not restack typing state. | Ignored by the lifecycle hook. Response content continues through the normal send path. | Calls `setTyping(chatId, false)` on terminal events. Failed start attempts clear local state so a later `started` can retry. | `tool_call` has no separate status surface and no extra message should be sent. | `packages/channels/weixin/src/WeixinAdapter.test.ts` | +| DingTalk | `started`, `completed`, `cancelled`, `failed` | Eye reaction on the inbound message | Attaches the existing eye reaction once when a conversation id is available. | Ignored by the lifecycle hook. Response content continues through the normal send path. | Recalls the eye reaction on terminal events, including late-resolving attach races after cancellation. | Direct robot webhook chats do not expose the conversation id needed for reactions, so lifecycle status is a no-op there. `tool_call` also has no UI in scope. | `packages/channels/dingtalk/src/DingtalkAdapter.test.ts` | +| Feishu | `started`, `completed`, `cancelled`, `failed` | Streaming card status label | Keeps the card in its running state and reserves space for the running label while the existing card stream is active. | Not consumed directly by the lifecycle hook. Content streaming remains owned by the existing response/card stream hook. | Finalizes the card status label as completed, cancelled, or failed without overwriting the streamed answer body. | `tool_call` stays hidden because the card already uses the answer stream plus terminal status labels only. | `packages/channels/feishu/src/adapter.test.ts`, `packages/channels/feishu/src/markdown.test.ts` | +| QQ Bot | None | None | No-op. | No-op. QQ Bot still streams reply chunks through outbound message sends, but not through lifecycle status updates. | No-op. | The channel has no typing or task-status endpoint, and `QQChannel` leaves `onPromptStart`, `onPromptEnd`, and `onTaskLifecycle` empty by design. | `packages/channels/qqbot/src/send.test.ts`, `packages/channels/qqbot/src/api.test.ts` | +| Plugin example | None | WebSocket protocol messages only | No-op for lifecycle status. | Streams response chunks over the mock protocol's `chunk` message type from `onResponseChunk`, outside lifecycle status handling. | Sends the final outbound message on response completion, outside lifecycle status handling. | The mock channel demonstrates transport wiring only; it has no native typing, reaction, or status surface. | `integration-tests/channel-plugin.test.ts` | + +## Verification commands + +### Adapter and package coverage referenced above + +- `cd packages/channels/telegram && npx vitest run src/TelegramAdapter.test.ts` +- `cd packages/channels/weixin && npx vitest run src/WeixinAdapter.test.ts` +- `cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts` +- `cd packages/channels/feishu && npx vitest run src/adapter.test.ts src/markdown.test.ts` +- `cd packages/channels/qqbot && npx vitest run src/send.test.ts src/api.test.ts` +- `cd integration-tests && npx vitest run channel-plugin.test.ts` + +### Branch verification required for this doc-only update + +- Run the review-provided grep check against this file pair. +- `git diff --check` diff --git a/.qwen/plans/2026-07-01-channel-lifecycle-status-adapters.md b/.qwen/plans/2026-07-01-channel-lifecycle-status-adapters.md new file mode 100644 index 0000000000..b58bed7f41 --- /dev/null +++ b/.qwen/plans/2026-07-01-channel-lifecycle-status-adapters.md @@ -0,0 +1,1094 @@ +# Channel Lifecycle Status Adapters Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show task lifecycle status through Telegram, Weixin, DingTalk, and +Feishu using each platform's existing native status surface. + +**Architecture:** Keep the work adapter-local. P0 adds +`ChannelTaskLifecycleEvent` and `onTaskLifecycle`; this plan maps those events +to existing typing, reaction, and card paths without changing the shared channel +contract again. Because P0 still calls legacy prompt/streaming hooks, adapter +helpers must be idempotent and must not double-append streamed Feishu content. + +**Tech Stack:** TypeScript, ESM, Vitest, existing channel packages under +`packages/channels/*`. + +## Global Constraints + +- Implement only Telegram, Weixin, DingTalk, and Feishu. +- Do not implement Slack behavior. +- Do not implement QQ Bot behavior. +- Do not update mock/plugin examples. +- Do not add terminal status emoji for DingTalk. +- Do not introduce a shared status-rendering abstraction. +- Keep platform status updates best-effort; failures must not fail the task. +- Run tests from each package directory with `npx vitest run ...`. +- Run `npm run build` and `npm run typecheck` before submitting the PR. +- Use neutral branch, issue, PR, and plan language. + +--- + +## File Structure + +- Modify `packages/channels/telegram/src/TelegramAdapter.ts`: add lifecycle + mapping and idempotent typing helpers. +- Modify `packages/channels/telegram/src/TelegramAdapter.test.ts`: add direct + lifecycle tests. +- Modify `packages/channels/weixin/src/WeixinAdapter.ts`: add lifecycle mapping + and idempotent typing helpers. +- Create or modify `packages/channels/weixin/src/WeixinAdapter.test.ts`: add + lifecycle typing tests if no adapter-level test already exists. +- Modify `packages/channels/dingtalk/src/DingtalkAdapter.ts`: add lifecycle + mapping and idempotent reaction helpers. +- Modify `packages/channels/dingtalk/src/DingtalkAdapter.test.ts`: add lifecycle + reaction tests. +- Modify `packages/channels/feishu/src/markdown.ts`: add a minimal card status + label option. +- Modify `packages/channels/feishu/src/markdown.test.ts`: test running and + terminal labels. +- Modify `packages/channels/feishu/src/FeishuAdapter.ts`: store terminal state + from lifecycle and render explicit card labels. +- Modify `packages/channels/feishu/src/adapter.test.ts`: test completed, + cancelled, and failed labels without double-streaming. + +--- + +### Task 1: Prepare The Implementation Branch + +**Files:** + +- Read: `.qwen/design/2026-07-01-channel-lifecycle-status-adapters.md` +- Read: `packages/channels/base/src/types.ts` +- Read: `packages/channels/base/src/ChannelBase.ts` + +**Interfaces:** + +- Consumes: P0's exported `ChannelTaskLifecycleEvent` type. +- Produces: a working branch where adapter packages can import + `ChannelTaskLifecycleEvent` from `@qwen-code/channel-base`. + +- [ ] **Step 1: Verify P0 lifecycle exists** + +Run: + +```bash +rg -n "ChannelTaskLifecycleEvent|onTaskLifecycle" packages/channels/base/src +``` + +Expected: `packages/channels/base/src/types.ts` defines +`ChannelTaskLifecycleEvent`, and `packages/channels/base/src/ChannelBase.ts` +defines `protected onTaskLifecycle(...)`. + +- [ ] **Step 2: If P0 is not on the current branch, base this work on P0** + +Run: + +```bash +git branch --show-current +rg -n "ChannelTaskLifecycleEvent|onTaskLifecycle" packages/channels/base/src +``` + +Expected: the lifecycle symbols exist before any adapter code is edited. If they +do not exist, switch to the P0 branch or wait for the P0 PR to merge, then rebase +this feature branch on that base. + +- [ ] **Step 3: Commit only if a branch/base adjustment created metadata changes** + +Run: + +```bash +git status --short +``` + +Expected: no source changes from this task. Do not commit if the tree is clean. + +--- + +### Task 2: Telegram Lifecycle Typing + +**Files:** + +- Modify: `packages/channels/telegram/src/TelegramAdapter.ts` +- Modify: `packages/channels/telegram/src/TelegramAdapter.test.ts` + +**Interfaces:** + +- Consumes: + `type ChannelTaskLifecycleEvent` from `@qwen-code/channel-base`. +- Produces: + `TelegramChannel.onTaskLifecycle(event: ChannelTaskLifecycleEvent): void`. + +- [ ] **Step 1: Write failing lifecycle tests** + +In `packages/channels/telegram/src/TelegramAdapter.test.ts`, import the +lifecycle type and add a test helper: + +```ts +import type { + ChannelAgentBridge, + ChannelConfig, + ChannelTaskLifecycleEvent, + Envelope, +} from '@qwen-code/channel-base'; + +class TestTelegramChannel extends TelegramChannel { + startTyping(chatId: string): void { + this.onPromptStart(chatId, 'session-1', 'message-1'); + } + + emitLifecycle(event: ChannelTaskLifecycleEvent): void { + this.onTaskLifecycle(event); + } + + buildTestEnvelope( + msg: TestTelegramMessage, + text: string, + entities?: TestTelegramEntity[], + ): Envelope { + return ( + this as unknown as { + buildEnvelope: ( + msg: TestTelegramMessage, + text: string, + entities?: TestTelegramEntity[], + ) => Envelope; + } + ).buildEnvelope(msg, text, entities); + } +} +``` + +Add this test: + +```ts +it('maps lifecycle start and terminal events to typing', () => { + const channel = createChannel(); + const bot = installFakeBot(channel); + + const baseEvent = { + channelName: 'telegram', + chatId: 'chat-1', + sessionId: 'session-1', + messageId: 'message-1', + identity: { id: 'channel:telegram', displayName: 'telegram' }, + memoryScope: { namespace: 'channel:telegram', mode: 'metadata-only' }, + } satisfies Omit; + + channel.emitLifecycle({ ...baseEvent, type: 'started' }); + channel.emitLifecycle({ ...baseEvent, type: 'started' }); + expect(bot.api.sendChatAction).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(4000); + expect(bot.api.sendChatAction).toHaveBeenCalledTimes(2); + + channel.emitLifecycle({ ...baseEvent, type: 'completed' }); + channel.emitLifecycle({ ...baseEvent, type: 'failed', error: 'boom' }); + + vi.advanceTimersByTime(4000); + expect(bot.api.sendChatAction).toHaveBeenCalledTimes(2); +}); +``` + +- [ ] **Step 2: Run the focused test and confirm it fails** + +Run: + +```bash +cd packages/channels/telegram && npx vitest run src/TelegramAdapter.test.ts +``` + +Expected: fail because `onTaskLifecycle` is not implemented in Telegram. + +- [ ] **Step 3: Implement idempotent lifecycle typing** + +In `packages/channels/telegram/src/TelegramAdapter.ts`, update the type import: + +```ts +import type { ChannelTaskLifecycleEvent } from '@qwen-code/channel-base'; +``` + +Replace the typing hook body with shared helpers: + +```ts +private startTyping(chatId: string): void { + if (this.typingIntervals.has(chatId)) return; + + const sendTyping = () => + this.bot.api.sendChatAction(chatId, 'typing').catch(() => {}); + sendTyping(); + this.typingIntervals.set(chatId, setInterval(sendTyping, 4000)); +} + +private stopTyping(chatId: string): void { + const interval = this.typingIntervals.get(chatId); + if (!interval) return; + clearInterval(interval); + this.typingIntervals.delete(chatId); +} + +protected override onTaskLifecycle(event: ChannelTaskLifecycleEvent): void { + if (event.type === 'started') { + this.startTyping(event.chatId); + return; + } + if ( + event.type === 'completed' || + event.type === 'cancelled' || + event.type === 'failed' + ) { + this.stopTyping(event.chatId); + } +} + +protected override onPromptStart(chatId: string): void { + this.startTyping(chatId); +} + +protected override onPromptEnd(chatId: string): void { + this.stopTyping(chatId); +} +``` + +- [ ] **Step 4: Run Telegram tests** + +Run: + +```bash +cd packages/channels/telegram && npx vitest run src/TelegramAdapter.test.ts +``` + +Expected: pass. + +- [ ] **Step 5: Commit Telegram changes** + +Run: + +```bash +git add packages/channels/telegram/src/TelegramAdapter.ts packages/channels/telegram/src/TelegramAdapter.test.ts +git commit -m "feat(channels): map telegram lifecycle to typing" +``` + +--- + +### Task 3: Weixin Lifecycle Typing + +**Files:** + +- Modify: `packages/channels/weixin/src/WeixinAdapter.ts` +- Create or modify: `packages/channels/weixin/src/WeixinAdapter.test.ts` + +**Interfaces:** + +- Consumes: + `type ChannelTaskLifecycleEvent` from `@qwen-code/channel-base`. +- Produces: + `WeixinChannel.onTaskLifecycle(event: ChannelTaskLifecycleEvent): void`. + +- [ ] **Step 1: Write failing tests** + +If no adapter-level test exists, create +`packages/channels/weixin/src/WeixinAdapter.test.ts` with the local mocks needed +to instantiate `WeixinChannel`. Add a test-only subclass: + +```ts +class TestWeixinChannel extends WeixinChannel { + emitLifecycle(event: ChannelTaskLifecycleEvent): void { + this.onTaskLifecycle(event); + } +} +``` + +Add the behavior test: + +```ts +it('maps lifecycle start and terminal events to typing state', () => { + const channel = createChannel(); + const setTyping = vi.fn().mockResolvedValue(undefined); + (channel as unknown as { setTyping: typeof setTyping }).setTyping = + setTyping; + + const baseEvent = { + channelName: 'weixin', + chatId: 'user-1', + sessionId: 'session-1', + messageId: 'message-1', + identity: { id: 'channel:weixin', displayName: 'weixin' }, + memoryScope: { namespace: 'channel:weixin', mode: 'metadata-only' }, + } satisfies Omit; + + channel.emitLifecycle({ ...baseEvent, type: 'started' }); + channel.emitLifecycle({ ...baseEvent, type: 'started' }); + channel.emitLifecycle({ ...baseEvent, type: 'cancelled', reason: 'clear' }); + channel.emitLifecycle({ ...baseEvent, type: 'completed' }); + + expect(setTyping).toHaveBeenNthCalledWith(1, 'user-1', true); + expect(setTyping).toHaveBeenNthCalledWith(2, 'user-1', false); + expect(setTyping).toHaveBeenCalledTimes(2); +}); +``` + +- [ ] **Step 2: Run the focused test and confirm it fails** + +Run: + +```bash +cd packages/channels/weixin && npx vitest run src/WeixinAdapter.test.ts +``` + +Expected: fail because the lifecycle hook is not implemented. + +- [ ] **Step 3: Implement idempotent typing helpers** + +In `packages/channels/weixin/src/WeixinAdapter.ts`, import the lifecycle type and +add a per-chat active set: + +```ts +import type { ChannelTaskLifecycleEvent } from '@qwen-code/channel-base'; + +private activeTypingChats = new Set(); +``` + +Replace prompt hook bodies with: + +```ts +private startTyping(chatId: string): void { + if (this.activeTypingChats.has(chatId)) return; + this.activeTypingChats.add(chatId); + this.setTyping(chatId, true).catch(() => { + this.activeTypingChats.delete(chatId); + }); +} + +private stopTyping(chatId: string): void { + if (!this.activeTypingChats.delete(chatId)) return; + this.setTyping(chatId, false).catch(() => {}); +} + +protected override onTaskLifecycle(event: ChannelTaskLifecycleEvent): void { + if (event.type === 'started') { + this.startTyping(event.chatId); + return; + } + if ( + event.type === 'completed' || + event.type === 'cancelled' || + event.type === 'failed' + ) { + this.stopTyping(event.chatId); + } +} + +protected override onPromptStart(chatId: string): void { + this.startTyping(chatId); +} + +protected override onPromptEnd(chatId: string): void { + this.stopTyping(chatId); +} +``` + +- [ ] **Step 4: Run Weixin tests** + +Run: + +```bash +cd packages/channels/weixin && npx vitest run src/WeixinAdapter.test.ts src/api.test.ts src/send.test.ts +``` + +Expected: pass. + +- [ ] **Step 5: Commit Weixin changes** + +Run: + +```bash +git add packages/channels/weixin/src/WeixinAdapter.ts packages/channels/weixin/src/WeixinAdapter.test.ts +git commit -m "feat(channels): map weixin lifecycle to typing" +``` + +--- + +### Task 4: DingTalk Lifecycle Reactions + +**Files:** + +- Modify: `packages/channels/dingtalk/src/DingtalkAdapter.ts` +- Modify: `packages/channels/dingtalk/src/DingtalkAdapter.test.ts` + +**Interfaces:** + +- Consumes: + `type ChannelTaskLifecycleEvent` from `@qwen-code/channel-base`. +- Produces: + `DingtalkChannel.onTaskLifecycle(event: ChannelTaskLifecycleEvent): void`. + +- [ ] **Step 1: Write failing lifecycle tests** + +In `packages/channels/dingtalk/src/DingtalkAdapter.test.ts`, import +`ChannelTaskLifecycleEvent` and add a lifecycle hook accessor: + +```ts +function getLifecycleHook( + channel: DingtalkChannelInstance, +): (event: ChannelTaskLifecycleEvent) => void { + const fn = (channel as unknown as Record) + .onTaskLifecycle as (event: ChannelTaskLifecycleEvent) => void; + return fn.bind(channel); +} +``` + +Add tests: + +```ts +it('maps lifecycle start and terminal events to the eye reaction', () => { + const channel = createChannel(); + const attachReaction = vi.fn().mockResolvedValue(undefined); + const recallReaction = vi.fn().mockResolvedValue(undefined); + ( + channel as unknown as { + attachReaction: typeof attachReaction; + recallReaction: typeof recallReaction; + } + ).attachReaction = attachReaction; + ( + channel as unknown as { + attachReaction: typeof attachReaction; + recallReaction: typeof recallReaction; + } + ).recallReaction = recallReaction; + + const event = { + channelName: 'dingtalk', + chatId: 'cid-123', + sessionId: 'session-1', + messageId: 'message-1', + identity: { id: 'channel:dingtalk', displayName: 'dingtalk' }, + memoryScope: { namespace: 'channel:dingtalk', mode: 'metadata-only' }, + } satisfies Omit; + + const lifecycle = getLifecycleHook(channel); + lifecycle({ ...event, type: 'started' }); + lifecycle({ ...event, type: 'started' }); + lifecycle({ ...event, type: 'failed', error: 'boom' }); + lifecycle({ ...event, type: 'completed' }); + + expect(attachReaction).toHaveBeenCalledOnce(); + expect(attachReaction).toHaveBeenCalledWith('message-1', 'cid-123'); + expect(recallReaction).toHaveBeenCalledOnce(); + expect(recallReaction).toHaveBeenCalledWith('message-1', 'cid-123'); +}); + +it('does not attach lifecycle reactions without a conversation id', () => { + const channel = createChannel(); + const attachReaction = vi.fn().mockResolvedValue(undefined); + (channel as unknown as { attachReaction: typeof attachReaction }) + .attachReaction = attachReaction; + + getLifecycleHook(channel)({ + type: 'started', + channelName: 'dingtalk', + chatId: 'HTTPS://oapi.dingtalk.com/robot/send?access_token=token', + sessionId: 'session-1', + messageId: 'message-1', + identity: { id: 'channel:dingtalk', displayName: 'dingtalk' }, + memoryScope: { namespace: 'channel:dingtalk', mode: 'metadata-only' }, + }); + + expect(attachReaction).not.toHaveBeenCalled(); +}); +``` + +- [ ] **Step 2: Run the focused test and confirm it fails** + +Run: + +```bash +cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts +``` + +Expected: fail because lifecycle reactions are not implemented. + +- [ ] **Step 3: Implement idempotent reaction helpers** + +In `packages/channels/dingtalk/src/DingtalkAdapter.ts`, import the lifecycle type +and add a reaction key set: + +```ts +import type { ChannelTaskLifecycleEvent } from '@qwen-code/channel-base'; + +private activeReactionKeys = new Set(); +``` + +Replace prompt hook bodies with helpers: + +```ts +private reactionKey(messageId: string, conversationId: string): string { + return `${conversationId}:${messageId}`; +} + +private startReaction(chatId: string, messageId?: string): void { + if (!messageId || !this.isConversationId(chatId)) return; + const key = this.reactionKey(messageId, chatId); + if (this.activeReactionKeys.has(key)) return; + this.activeReactionKeys.add(key); + this.attachReaction(messageId, chatId).catch(() => { + this.activeReactionKeys.delete(key); + }); +} + +private stopReaction(chatId: string, messageId?: string): void { + if (!messageId || !this.isConversationId(chatId)) return; + const key = this.reactionKey(messageId, chatId); + if (!this.activeReactionKeys.delete(key)) return; + this.recallReaction(messageId, chatId).catch(() => {}); +} + +protected override onTaskLifecycle(event: ChannelTaskLifecycleEvent): void { + if (event.type === 'started') { + this.startReaction(event.chatId, event.messageId); + return; + } + if ( + event.type === 'completed' || + event.type === 'cancelled' || + event.type === 'failed' + ) { + this.stopReaction(event.chatId, event.messageId); + } +} + +protected override onPromptStart( + chatId: string, + _sessionId: string, + messageId?: string, +): void { + this.startReaction(chatId, messageId); +} + +protected override onPromptEnd( + chatId: string, + _sessionId: string, + messageId?: string, +): void { + this.stopReaction(chatId, messageId); +} +``` + +- [ ] **Step 4: Run DingTalk tests** + +Run: + +```bash +cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts src/markdown.test.ts +``` + +Expected: pass. + +- [ ] **Step 5: Commit DingTalk changes** + +Run: + +```bash +git add packages/channels/dingtalk/src/DingtalkAdapter.ts packages/channels/dingtalk/src/DingtalkAdapter.test.ts +git commit -m "feat(channels): map dingtalk lifecycle to reactions" +``` + +--- + +### Task 5: Feishu Card Status Labels + +**Files:** + +- Modify: `packages/channels/feishu/src/markdown.ts` +- Modify: `packages/channels/feishu/src/markdown.test.ts` + +**Interfaces:** + +- Produces: + `buildCardContent(markdown, { statusLabel?: string })`. + +- [ ] **Step 1: Write failing markdown tests** + +In `packages/channels/feishu/src/markdown.test.ts`, add: + +```ts +it('uses a custom running status label', () => { + const card = buildCardContent('text', { + isStreaming: true, + statusLabel: '运行中...', + }) as unknown as CardStructure; + + expect(card.body.elements[0]!.content).toContain('运行中...'); + expect(card.body.elements[0]!.content).not.toContain('生成中...'); +}); + +it('uses a terminal status label without enabling streaming controls', () => { + const card = buildCardContent('text', { + statusLabel: '已完成', + }) as unknown as CardStructure; + + expect(card.body.elements[0]!.content).toContain('已完成'); + expect(card.body.elements.some((e) => e.tag === 'button')).toBe(false); +}); +``` + +- [ ] **Step 2: Run the focused test and confirm it fails** + +Run: + +```bash +cd packages/channels/feishu && npx vitest run src/markdown.test.ts +``` + +Expected: fail because `statusLabel` is not accepted. + +- [ ] **Step 3: Implement the minimal status label option** + +In `packages/channels/feishu/src/markdown.ts`, extend the options object: + +```ts +statusLabel?: string; +``` + +Replace the current content markdown calculation with: + +```ts +const statusLabel = + options?.statusLabel ?? (options?.isStreaming ? '生成中...' : undefined); +const contentMd = statusLabel + ? `${markdown}\n\n---\n*${statusLabel}*` + : markdown; +``` + +- [ ] **Step 4: Run Feishu markdown tests** + +Run: + +```bash +cd packages/channels/feishu && npx vitest run src/markdown.test.ts +``` + +Expected: pass. + +- [ ] **Step 5: Commit markdown changes** + +Run: + +```bash +git add packages/channels/feishu/src/markdown.ts packages/channels/feishu/src/markdown.test.ts +git commit -m "feat(channels): add feishu card status labels" +``` + +--- + +### Task 6: Feishu Lifecycle Terminal Mapping + +**Files:** + +- Modify: `packages/channels/feishu/src/FeishuAdapter.ts` +- Modify: `packages/channels/feishu/src/adapter.test.ts` + +**Interfaces:** + +- Consumes: + `type ChannelTaskLifecycleEvent` from `@qwen-code/channel-base`. +- Consumes: + `buildCardContent(markdown, { statusLabel?: string })` from Task 5. +- Produces: + explicit Feishu card labels for completed, cancelled, and failed states. + +- [ ] **Step 1: Write failing adapter tests** + +In `packages/channels/feishu/src/adapter.test.ts`, add tests that patch +`updateCard` and call lifecycle directly: + +```ts +it('records failed lifecycle state for prompt-end card finalization', async () => { + const channel = createChannel(); + const cardSessions = getPrivateMethod>( + channel, + 'cardSessions', + ); + cardSessions.set('inbound_1', { + messageId: 'om_valid_message_id', + created: true, + creating: false, + stopped: false, + accumulatedText: 'partial answer', + lastUpdateAt: Date.now(), + }); + + const updateCard = vi.fn().mockResolvedValue(true); + (channel as unknown as { updateCard: typeof updateCard }).updateCard = + updateCard; + + getPrivateMethod<(event: ChannelTaskLifecycleEvent) => void>( + channel, + 'onTaskLifecycle', + ).call(channel, { + type: 'failed', + channelName: 'feishu', + chatId: 'oc_chat_id', + sessionId: 'session_1', + messageId: 'inbound_1', + error: 'boom', + identity: { id: 'channel:feishu', displayName: 'feishu' }, + memoryScope: { namespace: 'channel:feishu', mode: 'metadata-only' }, + }); + + await getPrivateMethod< + (chatId: string, sessionId: string, messageId?: string) => Promise + >(channel, 'onPromptEnd').call( + channel, + 'oc_chat_id', + 'session_1', + 'inbound_1', + ); + + expect(updateCard.mock.calls[0]![1]).toContain('已失败,请重试'); +}); +``` + +Add the same shape for cancelled: + +```ts +it('records cancelled lifecycle state for prompt-end card finalization', async () => { + const channel = createChannel(); + const cardSessions = getPrivateMethod>( + channel, + 'cardSessions', + ); + cardSessions.set('inbound_1', { + messageId: 'om_valid_message_id', + created: true, + creating: false, + stopped: false, + accumulatedText: 'partial answer', + lastUpdateAt: Date.now(), + }); + + const updateCard = vi.fn().mockResolvedValue(true); + (channel as unknown as { updateCard: typeof updateCard }).updateCard = + updateCard; + + getPrivateMethod<(event: ChannelTaskLifecycleEvent) => void>( + channel, + 'onTaskLifecycle', + ).call(channel, { + type: 'cancelled', + reason: 'cancel_command', + channelName: 'feishu', + chatId: 'oc_chat_id', + sessionId: 'session_1', + messageId: 'inbound_1', + identity: { id: 'channel:feishu', displayName: 'feishu' }, + memoryScope: { namespace: 'channel:feishu', mode: 'metadata-only' }, + }); + + await getPrivateMethod< + (chatId: string, sessionId: string, messageId?: string) => Promise + >(channel, 'onPromptEnd').call( + channel, + 'oc_chat_id', + 'session_1', + 'inbound_1', + ); + + expect(updateCard.mock.calls[0]![1]).toContain('已取消'); +}); +``` + +Add a completed test by mocking the final `updateCard` call in +`onResponseComplete`: + +```ts +it('marks completed cards with the completed status label', async () => { + const channel = createChannel(); + const sessionToInboundMsg = getPrivateMethod>( + channel, + 'sessionToInboundMsg', + ); + const cardSessions = getPrivateMethod>( + channel, + 'cardSessions', + ); + sessionToInboundMsg.set('session_1', 'inbound_1'); + cardSessions.set('inbound_1', { + messageId: 'om_valid_message_id', + created: true, + creating: false, + stopped: false, + accumulatedText: 'answer', + lastUpdateAt: Date.now(), + }); + + const updateCard = vi.fn().mockResolvedValue(true); + (channel as unknown as { updateCard: typeof updateCard }).updateCard = + updateCard; + + await getPrivateMethod< + (chatId: string, fullText: string, sessionId: string) => Promise + >(channel, 'onResponseComplete').call( + channel, + 'oc_chat_id', + 'final answer', + 'session_1', + ); + + expect(updateCard.mock.calls[0]![1]).toContain('已完成'); +}); +``` + +- [ ] **Step 2: Run the focused adapter tests and confirm they fail** + +Run: + +```bash +cd packages/channels/feishu && npx vitest run src/adapter.test.ts +``` + +Expected: fail because Feishu does not store lifecycle terminal state or render +the new labels. + +- [ ] **Step 3: Add terminal state to card sessions** + +In `packages/channels/feishu/src/FeishuAdapter.ts`, import the lifecycle type and +extend `CardSessionState`: + +```ts +import type { ChannelTaskLifecycleEvent } from '@qwen-code/channel-base'; + +type FeishuTerminalStatus = 'completed' | 'cancelled' | 'failed'; + +interface CardSessionState { + terminalStatus?: FeishuTerminalStatus; +} +``` + +If `CardSessionState` already exists, only add the `terminalStatus` property to +the existing interface. + +- [ ] **Step 4: Add Feishu lifecycle handling without double-streaming** + +Add this method to `FeishuAdapter.ts`: + +```ts +protected override onTaskLifecycle(event: ChannelTaskLifecycleEvent): void { + if ( + event.type !== 'completed' && + event.type !== 'cancelled' && + event.type !== 'failed' + ) { + return; + } + + const inboundMsgId = + event.messageId || this.sessionToInboundMsg.get(event.sessionId); + if (!inboundMsgId) return; + + const cardState = this.cardSessions.get(inboundMsgId); + if (!cardState) return; + + cardState.terminalStatus = event.type; +} +``` + +Do not process `text_chunk` in `onTaskLifecycle` in this task. The base channel +still calls `onResponseChunk` immediately after emitting the lifecycle chunk, so +handling both paths would duplicate Feishu card content. + +- [ ] **Step 5: Pass status labels into card rendering** + +Add a helper: + +```ts +private statusLabelFor(terminalStatus?: FeishuTerminalStatus): string { + switch (terminalStatus) { + case 'completed': + return '已完成'; + case 'cancelled': + return '已取消'; + case 'failed': + return '已失败,请重试'; + default: + return '运行中...'; + } +} +``` + +Update `createStreamingCard` and non-final `updateCard` calls to use the running +label: + +```ts +const card = buildCardContent(text, { + title: cardTitle, + showStopButton: true, + isStreaming: true, + statusLabel: this.statusLabelFor(), + collapsible: this.collapsible, + collapsibleThreshold: this.collapsibleThreshold, +}); +``` + +Update `updateCard` so final calls can pass a terminal label: + +```ts +private async updateCard( + messageId: string, + text: string, + finished = false, + inboundMsgId?: string, + statusLabel?: string, +): Promise { + const card = buildCardContent(text, { + title: cardTitle, + showStopButton: !finished, + isStreaming: !finished, + statusLabel, + collapsible: this.collapsible, + collapsibleThreshold: this.collapsibleThreshold, + }); +} +``` + +When `onResponseComplete` finalizes a card, pass the completed label: + +```ts +await this.updateCard( + cardState.messageId, + `${displayText}\n\n---\n*${this.statusLabelFor('completed')}*`, + true, + inboundMsgId, +); +``` + +When `onPromptEnd` finalizes a failed or cancelled card, use the stored terminal +state: + +```ts +const terminalStatus = cs.terminalStatus || 'failed'; +const terminalLabel = this.statusLabelFor(terminalStatus); +const text = cs.accumulatedText + ? (atPrefix + ? `${atPrefix}\n\n${cs.accumulatedText}` + : cs.accumulatedText) + + '\n\n---\n' + + `*${terminalLabel}*` + : (atPrefix ? `${atPrefix}\n\n` : '') + `*${terminalLabel}*`; +``` + +Do not append the label both in `text` and through `statusLabel` on the same +call. Use the existing text-append style in `onPromptEnd` and use +`statusLabel` for normal card builder paths. + +- [ ] **Step 6: Run Feishu tests** + +Run: + +```bash +cd packages/channels/feishu && npx vitest run src/markdown.test.ts src/adapter.test.ts +``` + +Expected: pass. + +- [ ] **Step 7: Commit Feishu changes** + +Run: + +```bash +git add packages/channels/feishu/src/markdown.ts packages/channels/feishu/src/markdown.test.ts packages/channels/feishu/src/FeishuAdapter.ts packages/channels/feishu/src/adapter.test.ts +git commit -m "feat(channels): show feishu lifecycle card status" +``` + +--- + +### Task 7: Final Verification And PR Prep + +**Files:** + +- Read: `.github/pull_request_template.md` +- Write if needed: `.qwen/pr-drafts/channel-lifecycle-status-adapters.md` + +**Interfaces:** + +- Consumes: all prior adapter commits. +- Produces: verified branch ready for PR. + +- [ ] **Step 1: Run focused channel tests** + +Run: + +```bash +cd packages/channels/telegram && npx vitest run src/TelegramAdapter.test.ts +cd packages/channels/weixin && npx vitest run src/WeixinAdapter.test.ts src/api.test.ts src/send.test.ts +cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts src/markdown.test.ts +cd packages/channels/feishu && npx vitest run src/markdown.test.ts src/adapter.test.ts +``` + +Expected: all pass. + +- [ ] **Step 2: Run project verification** + +Run: + +```bash +npm run build +npm run typecheck +``` + +Expected: both pass. + +- [ ] **Step 3: Inspect final diff** + +Run: + +```bash +git status --short +git diff --stat main...HEAD +``` + +Expected: only the design/plan and four channel adapter areas changed. + +- [ ] **Step 4: Prepare PR body** + +Use `.github/pull_request_template.md`. Keep the description prose-based and do +not hard-wrap paragraphs. The reviewer test plan should say: + +```markdown +## Reviewer Test Plan + +- Verify Telegram shows typing while a task is running and clears typing when it completes, is cancelled, or fails. +- Verify Weixin sends typing true while a task is running and typing false for completed, cancelled, and failed terminal states. +- Verify DingTalk keeps the existing eye reaction behavior: attach while running and recall on completed, cancelled, or failed, with no terminal emoji. +- Verify Feishu cards show running, completed, cancelled, and failed labels without duplicating streamed content. +``` + +- [ ] **Step 5: Open PR** + +Run: + +```bash +git push -u origin feat/channel-lifecycle-status-adapters +gh pr create --fill +``` + +Expected: PR opens against the repository default branch. + +--- + +## Self-Review + +- Spec coverage: Tasks 2-4 cover Telegram, Weixin, and DingTalk lifecycle status + mapping. Tasks 5-6 cover Feishu card labels and terminal lifecycle mapping. + Task 7 covers package-local tests, build, typecheck, and PR prep. +- Scope check: no Slack, QQ Bot, mock/plugin, or shared abstraction work is + included. +- Ambiguity check: Feishu `text_chunk` lifecycle is intentionally not consumed + directly because the base still calls the legacy `onResponseChunk` hook in the + same path. This prevents duplicate card content while preserving existing + streaming behavior. +- Placeholder scan: no placeholder markers remain. diff --git a/.qwen/plans/2026-07-01-channel-lifecycle-status-umbrella.md b/.qwen/plans/2026-07-01-channel-lifecycle-status-umbrella.md new file mode 100644 index 0000000000..79719a3b9d --- /dev/null +++ b/.qwen/plans/2026-07-01-channel-lifecycle-status-umbrella.md @@ -0,0 +1,30 @@ +# Channel Lifecycle Status Umbrella Review Plan + +Date: 2026-07-01 + +## Goal + +Verify that the lifecycle-status documentation stays aligned across the adapter +design and umbrella review surfaces. + +## Review Checklist + +- Confirm no document says Feishu lifecycle `text_chunk` appends or updates the + answer body. +- Confirm the umbrella matrix lists: + - supported lifecycle events + - native surface + - `started` behavior + - `text_chunk` behavior + - terminal behavior + - unsupported or no-op reason + - exact test files +- Confirm Slack remains out of scope. +- Confirm DingTalk terminal emoji remains out of scope. +- Confirm branch-, issue-, and PR-facing language stays neutral and does not + name external tools or vendors. + +## Verification + +- Run the review-provided grep against these four documentation files. +- Run `git diff --check`. diff --git a/packages/channels/base/src/ChannelBase.test.ts b/packages/channels/base/src/ChannelBase.test.ts index d278717640..61ddbe8e32 100644 --- a/packages/channels/base/src/ChannelBase.test.ts +++ b/packages/channels/base/src/ChannelBase.test.ts @@ -67,6 +67,10 @@ class TestChannel extends ChannelBase { this.registerCancelCommand(); } + cancelPromptForTest(sessionId: string): Promise { + return this.requestActivePromptCancellation(sessionId, 'cancel_command'); + } + protected override onPromptStart( chatId: string, sessionId: string, @@ -5176,6 +5180,119 @@ describe('ChannelBase', () => { ]); }); + it('does not emit failed after an adapter-initiated cancellation rejects', async () => { + let rejectPrompt!: (error: Error) => void; + const pendingPrompt = new Promise((_resolve, reject) => { + rejectPrompt = reject; + }); + (bridge.prompt as ReturnType).mockReturnValue( + pendingPrompt, + ); + const ch = createChannel(); + + const prompt = ch.handleInbound(envelope({ messageId: 'm-stop' })); + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1)); + const cancel = ch.cancelPromptForTest('s-1'); + expect(cancel).toBeDefined(); + rejectPrompt(Object.assign(new Error('aborted'), { name: 'AbortError' })); + await expect(prompt).rejects.toThrow('aborted'); + await expect(cancel).resolves.toBe(true); + + expect(ch.taskEvents).toEqual([ + expect.objectContaining({ + type: 'started', + messageId: 'm-stop', + }), + expect.objectContaining({ + type: 'cancelled', + reason: 'cancel_command', + messageId: 'm-stop', + }), + ]); + }); + + it('suppresses lifecycle activity while adapter cancellation is pending', async () => { + let resolvePrompt!: (value: string) => void; + const pendingPrompt = new Promise((resolve) => { + resolvePrompt = resolve; + }); + let resolveCancel!: () => void; + const pendingCancel = new Promise((resolve) => { + resolveCancel = resolve; + }); + (bridge.prompt as ReturnType).mockReturnValue( + pendingPrompt, + ); + (bridge.cancelSession as ReturnType).mockReturnValue( + pendingCancel, + ); + const ch = createChannel(); + + const prompt = ch.handleInbound(envelope({ messageId: 'm-stop' })); + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1)); + const sessionId = (bridge.prompt as ReturnType).mock + .calls[0]![0] as string; + + const cancel = ch.cancelPromptForTest(sessionId); + await Promise.resolve(); + (bridge as unknown as EventEmitter).emit( + 'textChunk', + sessionId, + 'late part', + ); + (bridge as unknown as EventEmitter).emit('toolCall', { + sessionId, + toolCallId: 'tool-pending-adapter-cancel', + kind: 'read_file', + title: 'Read README.md', + status: 'running', + }); + + expect(ch.responseChunks).toEqual([]); + expect(ch.taskEvents).toEqual([ + expect.objectContaining({ + type: 'started', + messageId: 'm-stop', + }), + ]); + resolveCancel(); + await expect(cancel).resolves.toBe(true); + resolvePrompt('late'); + await prompt; + // Held chunk is discarded on a successful cancel — no text_chunk event. + expect( + ch.taskEvents.filter((event) => event.type === 'text_chunk'), + ).toEqual([]); + }); + + it('clears collect buffers after adapter-initiated cancellation succeeds', async () => { + let resolvePrompt!: (value: string) => void; + const pendingPrompt = new Promise((resolve) => { + resolvePrompt = resolve; + }); + (bridge.prompt as ReturnType).mockReturnValue( + pendingPrompt, + ); + const ch = createChannel({ dispatchMode: 'collect' }); + + const prompt = ch.handleInbound(envelope({ messageId: 'm-stop' })); + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1)); + const sessionId = (bridge.prompt as ReturnType).mock + .calls[0]![0] as string; + const maps = ch as unknown as { + collectBuffers: Map; + }; + maps.collectBuffers.set(sessionId, [ + { text: 'buffered', envelope: envelope({ text: 'buffered' }) }, + ]); + + await expect(ch.cancelPromptForTest(sessionId)).resolves.toBe(true); + + expect(maps.collectBuffers.has(sessionId)).toBe(false); + resolvePrompt('late'); + await prompt; + }); + it('does not emit tool call lifecycle events after cancellation', async () => { let resolvePrompt!: (value: string) => void; const pendingPrompt = new Promise((resolve) => { @@ -5604,6 +5721,53 @@ describe('ChannelBase', () => { ]); }); + it('releases held chunks before failed when cancel fails then prompt rejects', async () => { + let rejectPrompt!: (err: Error) => void; + const pendingPrompt = new Promise((_resolve, reject) => { + rejectPrompt = reject; + }); + let rejectCancel!: (err: Error) => void; + const pendingCancel = new Promise((_resolve, reject) => { + rejectCancel = reject; + }); + (bridge.prompt as ReturnType).mockReturnValue( + pendingPrompt, + ); + (bridge.cancelSession as ReturnType).mockReturnValue( + pendingCancel, + ); + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + const ch = createChannel(); + ch.enableCancelCommand(); + const prompt = ch.handleInbound(envelope({ text: 'long task' })); + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledOnce()); + + (bridge as unknown as EventEmitter).emit('textChunk', 's-1', 'before '); + const cancel = ch.handleInbound(envelope({ text: '/cancel' })); + await Promise.resolve(); + (bridge as unknown as EventEmitter).emit('textChunk', 's-1', 'during '); + rejectCancel(new Error('session not found')); + await cancel; + rejectPrompt(new Error('agent down')); + + await expect(prompt).rejects.toThrow('agent down'); + expect(ch.responseChunks.map((entry) => entry.chunk)).toEqual([ + 'before ', + 'during ', + ]); + expect(ch.taskEvents).toEqual([ + expect.objectContaining({ type: 'started' }), + expect.objectContaining({ type: 'text_chunk', chunk: 'before ' }), + expect.objectContaining({ type: 'text_chunk', chunk: 'during ' }), + expect.objectContaining({ + type: 'failed', + error: 'agent down', + phase: 'agent', + }), + ]); + }); + it('never sends held block-streaming chunks when the pending cancel succeeds', async () => { let resolvePrompt!: (v: string) => void; const pendingPrompt = new Promise((resolve) => { @@ -7645,13 +7809,13 @@ describe('ChannelBase', () => { expect(promptText).toContain('- namespace: memory:test'); expect(promptText).toContain('Reply briefly.'); expect(promptText).toContain( - '[Loop "daily summary" created by Alice]\n\npost summary', + '[Loop "daily summary" created by Alice] Scheduled task running unattended: no one is present to answer questions, and your final response is delivered to this chat automatically — do whatever work the task requires, then put the result in your final response instead of trying to deliver it to this chat yourself.\n\npost summary', ); const secondPromptText = (bridge.prompt as ReturnType).mock .calls[1]![1] as string; expect(secondPromptText).not.toContain('Channel identity:'); expect(secondPromptText).toContain( - '[Loop "daily summary" created by Alice]\n\npost summary again', + '[Loop "daily summary" created by Alice] Scheduled task running unattended: no one is present to answer questions, and your final response is delivered to this chat automatically — do whatever work the task requires, then put the result in your final response instead of trying to deliver it to this chat yourself.\n\npost summary again', ); }); @@ -8737,6 +8901,76 @@ describe('ChannelBase', () => { ]); }); + it('releases held loop chunks before failed when cancel fails then prompt rejects', async () => { + let rejectPrompt!: (err: Error) => void; + const pendingPrompt = new Promise((_resolve, reject) => { + rejectPrompt = reject; + }); + let rejectCancel!: (err: Error) => void; + const pendingCancel = new Promise((_resolve, reject) => { + rejectCancel = reject; + }); + (bridge.prompt as ReturnType).mockReturnValue( + pendingPrompt, + ); + (bridge.cancelSession as ReturnType).mockReturnValue( + pendingCancel, + ); + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + const ch = createChannel(); + ch.enableCancelCommand(); + ch.proactiveSupported = true; + + const loopRun = ch.runLoopPrompt({ + id: 'job-1', + channelName: 'test-chan', + target: { + channelName: 'test-chan', + senderId: 'alice', + chatId: 'chat1', + isGroup: false, + }, + cwd: '/tmp', + cron: '0 9 * * *', + prompt: 'post summary', + label: 'daily summary', + recurring: true, + enabled: true, + createdBy: 'Alice', + createdAt: '2026-06-30T01:00:00.000Z', + consecutiveFailures: 0, + runCount: 0, + }); + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledOnce()); + + (bridge as unknown as EventEmitter).emit('textChunk', 's-1', 'before '); + const cancel = ch.handleInbound( + envelope({ text: '/cancel', senderId: 'alice' }), + ); + await Promise.resolve(); + (bridge as unknown as EventEmitter).emit('textChunk', 's-1', 'during '); + rejectCancel(new Error('session not found')); + await cancel; + rejectPrompt(new Error('loop boom')); + + await expect(loopRun).rejects.toThrow('loop boom'); + expect(ch.responseChunks.map((entry) => entry.chunk)).toEqual([ + 'before ', + 'during ', + ]); + expect(ch.taskEvents).toEqual([ + expect.objectContaining({ type: 'started', messageId: 'job-1' }), + expect.objectContaining({ type: 'text_chunk', chunk: 'before ' }), + expect.objectContaining({ type: 'text_chunk', chunk: 'during ' }), + expect.objectContaining({ + type: 'failed', + error: 'loop boom', + phase: 'agent', + messageId: 'job-1', + }), + ]); + }); + it('emits tool_call lifecycle events during loop prompts', async () => { let resolvePrompt!: (value: string) => void; (bridge.prompt as ReturnType).mockReturnValue( diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index 469aacb073..c5dfabf18e 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -279,8 +279,10 @@ export abstract class ChannelBase { abstract disconnect(): void; /** - * Adapter hook for task lifecycle events. The prompt flow never awaits this - * hook; an async override's rejection is caught and logged, nothing more. + * Adapter hook for task lifecycle events — the canonical way to track task + * state (onPromptStart/onPromptEnd are retained for back-compat). The prompt + * flow never awaits this hook; an async override's rejection is caught and + * logged, nothing more. */ protected onTaskLifecycle( _event: ChannelTaskLifecycleEvent, @@ -676,6 +678,7 @@ export abstract class ChannelBase { !promptState.cancelled && !(err instanceof ChannelLoopSkippedError) ) { + releaseHeldChunks(); this.emitTaskLifecycle({ ...this.lifecycleBase(job.target.chatId, sessionId, job.id), type: 'failed', @@ -812,6 +815,67 @@ export abstract class ChannelBase { } } + protected requestActivePromptCancellation( + sessionId: string, + reason: 'cancel_command' | 'clear' | 'steer' = 'cancel_command', + ): Promise { + const active = this.activePrompts.get(sessionId); + if (!active) { + return this.bridge.cancelSession(sessionId).then( + () => true, + (err) => { + this.logCancelSessionFailure(sessionId, err); + return false; + }, + ); + } + if (active.deliveryStarted) { + return Promise.resolve(false); + } + const cancelRequested = + active.cancelRequested ?? + this.bridge.cancelSession(sessionId).then( + () => true, + (err) => { + this.logCancelSessionFailure(sessionId, err); + active.cancelRequested = undefined; + return false; + }, + ); + active.cancelRequested = cancelRequested; + active.cancelPending = true; + return cancelRequested + .finally(() => { + active.cancelPending = false; + }) + .then((cancelSucceeded) => { + // Re-check after the await: while the cancel RPC was in flight the + // turn may have started delivery, or ended on its own (uncancelled) — + // claiming success then would emit a spurious cancelled event for a + // response the user received. A turn that ended already-cancelled + // (the abort landed) still counts as a successful cancel. + const turnEnded = this.activePrompts.get(sessionId) !== active; + if ( + !cancelSucceeded || + active.deliveryStarted || + (turnEnded && !active.cancelled) + ) { + return false; + } + active.cancelled = true; + this.stopActiveStreaming(active, sessionId, reason); + this.collectBuffers.delete(sessionId); + this.emitTaskCancellation(active, sessionId, reason); + return true; + }); + } + + private logCancelSessionFailure(sessionId: string, err: unknown): void { + process.stderr.write( + `[${sanitizeLogText(this.name, 64)}] cancelSession failed for session=${sanitizeLogText(sessionId, 64)}: ${this.lifecycleError(err)}\n`, + ); + } + private async settleCancelRequested(active: ActivePrompt): Promise { if (!active.cancelRequested || active.cancelled) { return; @@ -934,55 +998,18 @@ export abstract class ChannelBase { ); return true; } - if (active.deliveryStarted) { - await this.sendMessage( - envelope.chatId, - 'Failed to cancel current request.', - ); - return true; - } - - const cancelRequested = - active.cancelRequested ?? - this.bridge.cancelSession(activeSessionId).then( - () => true, - (err) => { - process.stderr.write( - `[${sanitizeLogText(this.name, 64)}] cancelSession failed for session=${sanitizeLogText(activeSessionId, 64)}: ${this.lifecycleError(err)}\n`, - ); - active.cancelRequested = undefined; - return false; - }, - ); - active.cancelRequested = cancelRequested; - active.cancelPending = true; - - let cancelSucceeded: boolean; - try { - cancelSucceeded = await cancelRequested; - } finally { - active.cancelPending = false; - } - // Re-check after the await: the turn may have ended (or its delivery - // begun) while the cancel RPC was in flight — claiming success then - // would emit a spurious cancelled event for a delivered response. - if ( - this.activePrompts.get(activeSessionId) !== active || - active.deliveryStarted || - !cancelSucceeded - ) { - await this.sendMessage( - envelope.chatId, - 'Failed to cancel current request.', - ); - return true; - } - - active.cancelled = true; - this.stopActiveStreaming(active, activeSessionId, 'cancel'); - this.collectBuffers.delete(activeSessionId); - this.emitTaskCancellation(active, activeSessionId, 'cancel_command'); - await this.sendMessage(envelope.chatId, 'Cancelled current request.'); + // Single cancel state machine: adapter stop buttons and /cancel share + // requestActivePromptCancellation so the two paths cannot drift. + const cancelSucceeded = await this.requestActivePromptCancellation( + activeSessionId, + 'cancel_command', + ); + await this.sendMessage( + envelope.chatId, + cancelSucceeded + ? 'Cancelled current request.' + : 'Failed to cancel current request.', + ); return true; }); } @@ -2634,6 +2661,7 @@ export abstract class ChannelBase { await this.settleCancelRequested(promptState); } if (!promptState.cancelled) { + releaseHeldChunks(); this.emitTaskLifecycle({ ...this.lifecycleBase( envelope.chatId, diff --git a/packages/channels/base/src/index.ts b/packages/channels/base/src/index.ts index 7a25cf08ea..38819e1263 100644 --- a/packages/channels/base/src/index.ts +++ b/packages/channels/base/src/index.ts @@ -51,6 +51,7 @@ export { sanitizePromptText, sanitizeLogText, } from './sanitize.js'; +export { isTerminalTaskLifecycleType } from './types.js'; export type { Attachment, BlockStreamingChunkConfig, diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts index 6612274c15..47c1721614 100644 --- a/packages/channels/base/src/types.ts +++ b/packages/channels/base/src/types.ts @@ -180,6 +180,13 @@ export type ChannelTaskLifecycleEvent = phase: 'agent' | 'delivery'; }); +/** Terminal lifecycle event types — exactly one is expected per task. */ +export function isTerminalTaskLifecycleType( + type: ChannelTaskLifecycleEvent['type'], +): type is 'completed' | 'cancelled' | 'failed' { + return type === 'completed' || type === 'cancelled' || type === 'failed'; +} + export interface ChannelMemoryTarget { channelName: string; chatId: string; diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.test.ts b/packages/channels/dingtalk/src/DingtalkAdapter.test.ts index 757a62c4fa..bddbe090c7 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.test.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.test.ts @@ -1,6 +1,14 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { DWClientDownStream } from 'dingtalk-stream-sdk-nodejs'; -import type { SessionTarget } from '@qwen-code/channel-base'; +import type { + ChannelTaskLifecycleEvent, + SessionTarget, +} from '@qwen-code/channel-base'; + +type LifecycleBase = Omit< + Extract, + 'type' +>; const dingtalkSdkMock = vi.hoisted(() => ({ instances: [] as unknown[], @@ -48,6 +56,7 @@ vi.mock('@qwen-code/channel-base', async () => { protected config: Record; protected name: string; handleInbound = vi.fn().mockResolvedValue(undefined); + onSessionDied(_sessionId: string): void {} constructor( name: string, @@ -60,6 +69,7 @@ vi.mock('@qwen-code/channel-base', async () => { }, sanitizeLogText: real.sanitizeLogText, sanitizeSenderName: real.sanitizeSenderName, + isTerminalTaskLifecycleType: real.isTerminalTaskLifecycleType, }; }); @@ -105,7 +115,168 @@ function getPromptHook( return fn.bind(channel); } +function getLifecycleHook( + channel: DingtalkChannelInstance, +): (event: ChannelTaskLifecycleEvent) => void { + const fn = (channel as unknown as Record)[ + 'onTaskLifecycle' + ] as (event: ChannelTaskLifecycleEvent) => void; + return fn.bind(channel); +} + +/** Reactions only fire for message ids seen inbound — mimic message arrival. */ +function seedSeenMessage( + channel: DingtalkChannelInstance, + messageId: string, +): void { + ( + channel as unknown as { inboundMessageIds: Set } + ).inboundMessageIds.add(messageId); +} + +function deferredPromise() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + describe('DingtalkChannel prompt reactions', () => { + it('maps lifecycle start and terminal events to the eye reaction', () => { + const channel = createChannel(); + const attachReaction = vi.fn().mockResolvedValue(undefined); + const recallReaction = vi.fn().mockResolvedValue(undefined); + ( + channel as unknown as { + attachReaction: typeof attachReaction; + recallReaction: typeof recallReaction; + } + ).attachReaction = attachReaction; + ( + channel as unknown as { + attachReaction: typeof attachReaction; + recallReaction: typeof recallReaction; + } + ).recallReaction = recallReaction; + + const event = { + channelName: 'dingtalk', + chatId: 'cid-123', + sessionId: 'session-1', + messageId: 'message-1', + identity: { id: 'channel:dingtalk', displayName: 'dingtalk' }, + memoryScope: { namespace: 'channel:dingtalk', mode: 'metadata-only' }, + } satisfies LifecycleBase; + + seedSeenMessage(channel, 'message-1'); + const lifecycle = getLifecycleHook(channel); + lifecycle({ ...event, type: 'started' }); + lifecycle({ ...event, type: 'started' }); + lifecycle({ ...event, type: 'failed', error: 'boom', phase: 'agent' }); + lifecycle({ ...event, type: 'completed' }); + + expect(attachReaction).toHaveBeenCalledOnce(); + expect(attachReaction).toHaveBeenCalledWith('message-1', 'cid-123'); + expect(recallReaction).toHaveBeenCalledOnce(); + expect(recallReaction).toHaveBeenCalledWith('message-1', 'cid-123'); + }); + + it('recalls again when a late lifecycle attach resolves after terminal cleanup', async () => { + const channel = createChannel(); + const attach = deferredPromise(); + const attachReaction = vi + .fn() + .mockReturnValueOnce(attach.promise) + .mockResolvedValueOnce(undefined); + const recallReaction = vi.fn().mockResolvedValue(undefined); + ( + channel as unknown as { + attachReaction: typeof attachReaction; + recallReaction: typeof recallReaction; + } + ).attachReaction = attachReaction; + ( + channel as unknown as { + attachReaction: typeof attachReaction; + recallReaction: typeof recallReaction; + } + ).recallReaction = recallReaction; + + const event = { + channelName: 'dingtalk', + chatId: 'cid-456', + sessionId: 'session-2', + messageId: 'message-2', + identity: { id: 'channel:dingtalk', displayName: 'dingtalk' }, + memoryScope: { namespace: 'channel:dingtalk', mode: 'metadata-only' }, + } satisfies LifecycleBase; + + seedSeenMessage(channel, 'message-2'); + const lifecycle = getLifecycleHook(channel); + lifecycle({ ...event, type: 'started' }); + lifecycle({ ...event, type: 'cancelled', reason: 'cancel_command' }); + + expect(attachReaction).toHaveBeenNthCalledWith(1, 'message-2', 'cid-456'); + expect(recallReaction).toHaveBeenNthCalledWith(1, 'message-2', 'cid-456'); + + attach.resolve(); + + await vi.waitFor(() => { + expect(recallReaction).toHaveBeenNthCalledWith(2, 'message-2', 'cid-456'); + expect(recallReaction).toHaveBeenCalledTimes(2); + }); + }); + + it('does not attach lifecycle reactions without a conversation id', () => { + const channel = createChannel(); + const attachReaction = vi.fn().mockResolvedValue(undefined); + ( + channel as unknown as { attachReaction: typeof attachReaction } + ).attachReaction = attachReaction; + + getLifecycleHook(channel)({ + type: 'started', + channelName: 'dingtalk', + chatId: 'HTTPS://oapi.dingtalk.com/robot/send?access_token=token', + sessionId: 'session-1', + messageId: 'message-1', + identity: { id: 'channel:dingtalk', displayName: 'dingtalk' }, + memoryScope: { namespace: 'channel:dingtalk', mode: 'metadata-only' }, + }); + + expect(attachReaction).not.toHaveBeenCalled(); + }); + + it('clears active lifecycle reactions on disconnect', () => { + const channel = createChannel(); + const attachReaction = vi.fn().mockResolvedValue(undefined); + ( + channel as unknown as { attachReaction: typeof attachReaction } + ).attachReaction = attachReaction; + const activeReactionKeys = ( + channel as unknown as { activeReactionKeys: Set } + ).activeReactionKeys; + + seedSeenMessage(channel, 'message-1'); + getLifecycleHook(channel)({ + type: 'started', + channelName: 'dingtalk', + chatId: 'cid-123', + sessionId: 'session-1', + messageId: 'message-1', + identity: { id: 'channel:dingtalk', displayName: 'dingtalk' }, + memoryScope: { namespace: 'channel:dingtalk', mode: 'metadata-only' }, + }); + expect(activeReactionKeys.size).toBe(1); + + channel.disconnect(); + + expect(activeReactionKeys.size).toBe(0); + }); + it('skips uppercase webhook URLs when starting a prompt', () => { const channel = createChannel(); const attachReaction = vi.fn().mockResolvedValue(undefined); @@ -129,6 +300,7 @@ describe('DingtalkChannel prompt reactions', () => { channel as unknown as { attachReaction: typeof attachReaction } ).attachReaction = attachReaction; + seedSeenMessage(channel, 'message-1'); getPromptHook(channel, 'onPromptStart')( 'cid-123', 'session-1', @@ -153,6 +325,160 @@ describe('DingtalkChannel prompt reactions', () => { expect(recallReaction).not.toHaveBeenCalled(); }); + + it('skips reactions when the started event has no messageId', () => { + const channel = createChannel(); + const attachReaction = vi.fn().mockResolvedValue(undefined); + ( + channel as unknown as { attachReaction: typeof attachReaction } + ).attachReaction = attachReaction; + + getLifecycleHook(channel)({ + type: 'started', + channelName: 'dingtalk', + chatId: 'cid-123', + sessionId: 'session-1', + identity: { id: 'channel:dingtalk', displayName: 'dingtalk' }, + memoryScope: { namespace: 'channel:dingtalk', mode: 'metadata-only' }, + }); + + expect(attachReaction).not.toHaveBeenCalled(); + }); + + it('skips reactions for loop job ids that never arrived as messages', () => { + const channel = createChannel(); + const attachReaction = vi.fn().mockResolvedValue(undefined); + ( + channel as unknown as { attachReaction: typeof attachReaction } + ).attachReaction = attachReaction; + + getPromptHook(channel, 'onPromptStart')('cid-123', 'session-1', 'job-1'); + + expect(attachReaction).not.toHaveBeenCalled(); + }); + + it('clears the reaction key when attach fails so a retry can attach again', async () => { + const channel = createChannel(); + const attachReaction = vi + .fn() + .mockRejectedValueOnce(new Error('api down')) + .mockResolvedValueOnce(undefined); + ( + channel as unknown as { attachReaction: typeof attachReaction } + ).attachReaction = attachReaction; + const activeReactionKeys = ( + channel as unknown as { activeReactionKeys: Set } + ).activeReactionKeys; + seedSeenMessage(channel, 'message-1'); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + try { + getPromptHook(channel, 'onPromptStart')( + 'cid-123', + 'session-1', + 'message-1', + ); + await vi.waitFor(() => expect(activeReactionKeys.size).toBe(0)); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('reaction attach failed: api down'), + ); + + getPromptHook(channel, 'onPromptStart')( + 'cid-123', + 'session-1', + 'message-1', + ); + expect(attachReaction).toHaveBeenCalledTimes(2); + } finally { + stderr.mockRestore(); + } + }); + + it.each(['completed', 'cancelled', 'failed'] as const)( + 'recalls the reaction on an isolated %s event', + (terminal) => { + const channel = createChannel(); + const attachReaction = vi.fn().mockResolvedValue(undefined); + const recallReaction = vi.fn().mockResolvedValue(undefined); + ( + channel as unknown as { + attachReaction: typeof attachReaction; + recallReaction: typeof recallReaction; + } + ).attachReaction = attachReaction; + ( + channel as unknown as { + attachReaction: typeof attachReaction; + recallReaction: typeof recallReaction; + } + ).recallReaction = recallReaction; + + const base = { + channelName: 'dingtalk', + chatId: 'cid-123', + sessionId: 'session-1', + messageId: 'message-1', + identity: { id: 'channel:dingtalk', displayName: 'dingtalk' }, + memoryScope: { + namespace: 'channel:dingtalk', + mode: 'metadata-only', + }, + } satisfies LifecycleBase; + + seedSeenMessage(channel, 'message-1'); + const lifecycle = getLifecycleHook(channel); + lifecycle({ ...base, type: 'started' }); + if (terminal === 'cancelled') { + lifecycle({ ...base, type: terminal, reason: 'cancel_command' }); + } else if (terminal === 'failed') { + lifecycle({ ...base, type: terminal, error: 'boom', phase: 'agent' }); + } else { + lifecycle({ ...base, type: terminal }); + } + + expect(recallReaction).toHaveBeenCalledOnce(); + expect(recallReaction).toHaveBeenCalledWith('message-1', 'cid-123'); + }, + ); + + it('recalls reactions when the session dies without terminal events', () => { + const channel = createChannel(); + const attachReaction = vi.fn().mockResolvedValue(undefined); + const recallReaction = vi.fn().mockResolvedValue(undefined); + ( + channel as unknown as { + attachReaction: typeof attachReaction; + recallReaction: typeof recallReaction; + } + ).attachReaction = attachReaction; + ( + channel as unknown as { + attachReaction: typeof attachReaction; + recallReaction: typeof recallReaction; + } + ).recallReaction = recallReaction; + const activeReactionKeys = ( + channel as unknown as { activeReactionKeys: Set } + ).activeReactionKeys; + + seedSeenMessage(channel, 'message-1'); + getLifecycleHook(channel)({ + type: 'started', + channelName: 'dingtalk', + chatId: 'cid-123', + sessionId: 'session-1', + messageId: 'message-1', + identity: { id: 'channel:dingtalk', displayName: 'dingtalk' }, + memoryScope: { namespace: 'channel:dingtalk', mode: 'metadata-only' }, + }); + expect(activeReactionKeys.size).toBe(1); + + channel.onSessionDied('session-1'); + + expect(recallReaction).toHaveBeenCalledWith('message-1', 'cid-123'); + expect(activeReactionKeys.size).toBe(0); + }); }); describe('DingtalkChannel.isUnroutableGroupMessage', () => { diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.ts b/packages/channels/dingtalk/src/DingtalkAdapter.ts index f940efb596..e5e812310d 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.ts @@ -7,6 +7,7 @@ import { DWClient, TOPIC_ROBOT, EventAck } from 'dingtalk-stream-sdk-nodejs'; import type { DWClientDownStream } from 'dingtalk-stream-sdk-nodejs'; import { ChannelBase, + isTerminalTaskLifecycleType, sanitizeLogText, sanitizeSenderName, } from '@qwen-code/channel-base'; @@ -17,6 +18,7 @@ import type { ChannelBaseOptions, Envelope, ChannelAgentBridge, + ChannelTaskLifecycleEvent, SessionTarget, } from '@qwen-code/channel-base'; @@ -107,6 +109,18 @@ export class DingtalkChannel extends ChannelBase { private dedupTimer?: ReturnType; /** Map conversationId → latest sessionWebhook URL for sending replies. */ private webhooks: Map = new Map(); + private activeReactionKeys = new Set(); + /** sessionId → reaction keys, so a dead session's reactions can be recalled. */ + private sessionReactionKeys = new Map< + string, + Map + >(); + /** + * Real inbound message ids (insertion-ordered, size-capped). Unlike the + * TTL-swept seenMessages dedup map, entries survive long queue waits, so a + * turn that starts minutes after its message arrived still gets a reaction. + */ + private inboundMessageIds = new Set(); /** * Token cache for proactive sends. The stream SDK only refreshes its token * on (re)connect, so a long-lived socket serves a stale one after ~2h. @@ -521,6 +535,8 @@ export class DingtalkChannel extends ChannelBase { if (this.dedupTimer) { clearInterval(this.dedupTimer); } + this.activeReactionKeys.clear(); + this.sessionReactionKeys.clear(); this.client.disconnect(); process.stderr.write(`[DingTalk:${this.name}] Disconnected.\n`); } @@ -534,22 +550,120 @@ export class DingtalkChannel extends ChannelBase { return !!chatId && !/^https?:\/\//i.test(chatId); } - protected override onPromptStart( + private reactionKey(messageId: string, conversationId: string): string { + return `${conversationId}:${messageId}`; + } + + private rememberInboundMessageId(msgId: string): void { + this.inboundMessageIds.delete(msgId); + this.inboundMessageIds.add(msgId); + if (this.inboundMessageIds.size > 1000) { + const oldest = this.inboundMessageIds.values().next().value; + if (oldest !== undefined) this.inboundMessageIds.delete(oldest); + } + } + + private logReactionFailure(action: string, err: unknown): void { + process.stderr.write( + `[DingTalk:${this.name}] ${action} failed: ${err instanceof Error ? err.message : err}\n`, + ); + } + + private startReaction( chatId: string, - _sessionId: string, messageId?: string, + sessionId?: string, ): void { if (!messageId || !this.isConversationId(chatId)) return; - this.attachReaction(messageId, chatId).catch(() => {}); + // Loop lifecycle events carry the internal job id as messageId; the + // emotion API only accepts ids of real inbound messages, so skip anything + // we never saw arrive. + if (!this.inboundMessageIds.has(messageId)) return; + const key = this.reactionKey(messageId, chatId); + if (this.activeReactionKeys.has(key)) return; + this.activeReactionKeys.add(key); + if (sessionId) { + let keys = this.sessionReactionKeys.get(sessionId); + if (!keys) { + keys = new Map(); + this.sessionReactionKeys.set(sessionId, keys); + } + keys.set(key, { messageId, chatId }); + } + this.attachReaction(messageId, chatId) + .then(() => { + if (!this.activeReactionKeys.has(key)) { + void this.recallReaction(messageId, chatId).catch((err) => { + this.logReactionFailure('late reaction recall', err); + }); + } + }) + .catch((err) => { + this.activeReactionKeys.delete(key); + this.logReactionFailure('reaction attach', err); + }); + } + + private stopReaction( + chatId: string, + messageId?: string, + sessionId?: string, + ): void { + if (!messageId || !this.isConversationId(chatId)) return; + const key = this.reactionKey(messageId, chatId); + if (sessionId) { + const keys = this.sessionReactionKeys.get(sessionId); + if (keys) { + keys.delete(key); + if (keys.size === 0) this.sessionReactionKeys.delete(sessionId); + } + } + if (!this.activeReactionKeys.delete(key)) return; + this.recallReaction(messageId, chatId).catch((err) => { + this.logReactionFailure('reaction recall', err); + }); + } + + /** Recall reactions left behind when a session dies without terminal lifecycle events. */ + override onSessionDied(sessionId: string): void { + const keys = this.sessionReactionKeys.get(sessionId); + if (keys) { + this.sessionReactionKeys.delete(sessionId); + for (const [key, { messageId, chatId }] of keys) { + if (this.activeReactionKeys.delete(key)) { + void this.recallReaction(messageId, chatId).catch((err) => { + this.logReactionFailure('session-death reaction recall', err); + }); + } + } + } + super.onSessionDied(sessionId); + } + + protected override onTaskLifecycle(event: ChannelTaskLifecycleEvent): void { + if (event.type === 'started') { + this.startReaction(event.chatId, event.messageId, event.sessionId); + return; + } + if (isTerminalTaskLifecycleType(event.type)) { + this.stopReaction(event.chatId, event.messageId, event.sessionId); + } + } + + protected override onPromptStart( + chatId: string, + sessionId: string, + messageId?: string, + ): void { + this.startReaction(chatId, messageId, sessionId); } protected override onPromptEnd( chatId: string, - _sessionId: string, + sessionId: string, messageId?: string, ): void { - if (!messageId || !this.isConversationId(chatId)) return; - this.recallReaction(messageId, chatId).catch(() => {}); + this.stopReaction(chatId, messageId, sessionId); } /** @@ -791,6 +905,7 @@ export class DingtalkChannel extends ChannelBase { } if (msgId) { this.seenMessages.set(msgId, Date.now()); + this.rememberInboundMessageId(msgId); } const isGroup = data.conversationType === '2'; diff --git a/packages/channels/feishu/src/FeishuAdapter.ts b/packages/channels/feishu/src/FeishuAdapter.ts index 76bdb77c91..1c4698eb57 100644 --- a/packages/channels/feishu/src/FeishuAdapter.ts +++ b/packages/channels/feishu/src/FeishuAdapter.ts @@ -5,7 +5,10 @@ import { randomUUID, timingSafeEqual } from 'node:crypto'; import { basename, join } from 'node:path'; import { tmpdir } from 'node:os'; import * as lark from '@larksuiteoapi/node-sdk'; -import { ChannelBase } from '@qwen-code/channel-base'; +import { + ChannelBase, + isTerminalTaskLifecycleType, +} from '@qwen-code/channel-base'; import { buildCardContent, extractTitle, splitChunks } from './markdown.js'; import { downloadMedia } from './media.js'; import type { @@ -13,6 +16,7 @@ import type { ChannelBaseOptions, Envelope, ChannelAgentBridge, + ChannelTaskLifecycleEvent, SessionTarget, } from '@qwen-code/channel-base'; @@ -45,6 +49,8 @@ interface FeishuMessageEvent { } /** Track per-session interactive card state. */ +type FeishuTerminalStatus = 'completed' | 'cancelled' | 'failed'; + interface CardSessionState { messageId: string; created: boolean; @@ -68,6 +74,9 @@ interface CardSessionState { /** Set synchronously in onCardAction so .then() callbacks can detect stop intent * before cancelSession resolves. Cleared on cancelSession failure. */ cancelling?: boolean; + /** Stop clicked before any terminal event — render 已停止生成 on every wind-down path. */ + userStopped?: boolean; + terminalStatus?: FeishuTerminalStatus; } /** Track seen message IDs to deduplicate retried events. */ @@ -76,6 +85,35 @@ const DEDUP_TTL_MS = 5 * 60 * 1000; /** Minimum interval between card updates (ms) to avoid API rate limiting. */ const CARD_UPDATE_INTERVAL_MS = 1500; +const FEISHU_RUNNING_STATUS_LABEL = '运行中...'; +const FEISHU_STOPPED_STATUS_LABEL = '已停止生成'; +const FEISHU_STOP_FAILED_STATUS_LABEL = '停止失败,请重试'; +const FEISHU_TRUNCATED_STATUS_LABEL = '内容过长,已截断'; +const FEISHU_TERMINAL_STATUS_LABELS: Record = { + completed: '已完成', + cancelled: '已取消', + failed: '已失败,请重试', +}; +const FEISHU_STATUS_STRINGS = [ + '生成中...', + FEISHU_RUNNING_STATUS_LABEL, + FEISHU_TERMINAL_STATUS_LABELS.completed, + FEISHU_TERMINAL_STATUS_LABELS.cancelled, + FEISHU_TERMINAL_STATUS_LABELS.failed, + FEISHU_STOPPED_STATUS_LABEL, + FEISHU_STOP_FAILED_STATUS_LABEL, + FEISHU_TRUNCATED_STATUS_LABEL, +] as const; +const escapeRegExp = (value: string) => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const FEISHU_STATUS_LABELS = `(?:${FEISHU_STATUS_STRINGS.map(escapeRegExp).join('|')})`; +/** A rendered status block: `---` divider line + `*label*` line, + * at line granularity anywhere in the joined card text. */ +const FEISHU_STATUS_BLOCK_RE = new RegExp( + `(?:^|\\n)---\\n\\*${FEISHU_STATUS_LABELS}\\*(?=\\n|$)`, + 'g', +); + const BASE_URL = 'https://open.feishu.cn/open-apis'; /** Validate Feishu ID format to prevent SSRF path traversal in URL interpolation. */ @@ -562,8 +600,12 @@ export class FeishuChannel extends ChannelBase { } let text = lines.join('\n').trim(); - // Strip streaming indicator - text = text.replace(/\n---\n\*生成中\.\.\.\*$/, ''); + // Strip status/label blocks wherever they appear — buildCardContent + // renders them as `---` + `*label*` blocks that end up trailing, leading + // (label-only stop cards), stacked (truncation notice + terminal label), + // or mid-string (collapsible preview joined before the panel body) — a + // $-anchored regex cannot cover those layouts. + text = text.replace(FEISHU_STATUS_BLOCK_RE, ''); // Strip greeting prefix like "好的,\n\n" text = text.replace(/^好的,]*><\/at>\s*\n*/, ''); return text.trim() || undefined; @@ -728,6 +770,7 @@ export class FeishuChannel extends ChannelBase { title: cardTitle, showStopButton: true, isStreaming: true, + statusLabel: this.statusLabelFor(), collapsible: this.collapsible, collapsibleThreshold: this.collapsibleThreshold, }); @@ -780,6 +823,7 @@ export class FeishuChannel extends ChannelBase { text: string, finished = false, inboundMsgId?: string, + statusLabel?: string, ): Promise { const token = await this.getTenantAccessToken(); if (!token) return false; @@ -791,6 +835,8 @@ export class FeishuChannel extends ChannelBase { title: cardTitle, showStopButton: !finished, isStreaming: !finished, + statusLabel: + statusLabel ?? (!finished ? this.statusLabelFor() : undefined), collapsible: this.collapsible, collapsibleThreshold: this.collapsibleThreshold, }); @@ -936,14 +982,12 @@ export class FeishuChannel extends ChannelBase { if (result.success) { const prefix = cs.atPrefix || this.msgToSenderName.get(inboundMsgId) || ''; - const stopText = prefix - ? `${prefix}\n\n*已停止生成*` - : '*已停止生成*'; this.updateCard( result.messageId, - stopText, + prefix, true, inboundMsgId, + this.stopLabelFor(cs.terminalStatus, cs.userStopped ?? false), ).catch(() => {}); } cs.creating = false; @@ -1016,6 +1060,105 @@ export class FeishuChannel extends ChannelBase { } } + private isKnownInboundMessageId(messageId: string): boolean { + return ( + this.msgToQuestion.has(messageId) || + this.msgToSenderName.has(messageId) || + this.msgToSenderId.has(messageId) || + this.cardSessions.has(messageId) || + this.stoppedMessages.has(messageId) + ); + } + + private knownInboundMessageId( + sessionId: string, + messageId?: string, + ): string | undefined { + if (messageId && this.isKnownInboundMessageId(messageId)) { + return messageId; + } + const mapped = this.sessionToInboundMsg.get(sessionId); + return mapped && this.isKnownInboundMessageId(mapped) ? mapped : undefined; + } + + private statusLabelFor(terminalStatus?: FeishuTerminalStatus): string { + return terminalStatus === undefined + ? FEISHU_RUNNING_STATUS_LABEL + : FEISHU_TERMINAL_STATUS_LABELS[terminalStatus]; + } + + private stopLabelFor( + terminalStatus?: FeishuTerminalStatus, + userInitiated = false, + ): string { + if (userInitiated || terminalStatus === undefined) { + return FEISHU_STOPPED_STATUS_LABEL; + } + return this.statusLabelFor(terminalStatus); + } + + private async finalizeStoppedCardUpdate( + inboundMsgId: string, + cardState: CardSessionState | undefined, + chatId: string, + ): Promise { + if ( + !cardState || + (!cardState.stopped && !this.stoppedMessages.has(inboundMsgId)) + ) { + return false; + } + + if (cardState.created && cardState.messageId) { + const prefix = + cardState.atPrefix || this.msgToSenderName.get(inboundMsgId) || ''; + const stopLabel = `*${this.stopLabelFor( + cardState.terminalStatus, + this.stoppedMessages.has(inboundMsgId), + )}*`; + const contentPart = cardState.accumulatedText.trim() + ? cardState.accumulatedText + '\n\n---\n' + stopLabel + : stopLabel; + const finalText = prefix ? `${prefix}\n\n${contentPart}` : contentPart; + const updated = await this.updateCard( + cardState.messageId, + finalText, + true, + inboundMsgId, + ); + if (!updated) { + await this.deleteCard(cardState.messageId); + await this.sendMessage(chatId, finalText); + } + } + + this.cleanupCard(inboundMsgId); + this.stoppedMessages.delete(inboundMsgId); + return true; + } + + protected override onTaskLifecycle(event: ChannelTaskLifecycleEvent): void { + if (!isTerminalTaskLifecycleType(event.type)) { + return; + } + + const inboundMsgId = this.knownInboundMessageId( + event.sessionId, + event.messageId, + ); + if (!inboundMsgId) return; + + const cardState = this.cardSessions.get(inboundMsgId); + if (!cardState) return; + + if (cardState.terminalStatus && cardState.terminalStatus !== event.type) { + process.stderr.write( + `[Feishu:${this.name}] conflicting terminal event ${event.type} after ${cardState.terminalStatus} for inbound=${inboundMsgId}\n`, + ); + } + cardState.terminalStatus ??= event.type; + } + protected override async onResponseComplete( chatId: string, fullText: string, @@ -1042,14 +1185,20 @@ export class FeishuChannel extends ChannelBase { // Prepend greeting with sender name const atSender = this.msgToSenderName.get(inboundMsgId); let displayText = atSender ? `${atSender}\n\n${fullText}` : fullText; + const completedLabel = this.statusLabelFor('completed'); + const completedSuffix = `\n\n---\n*${completedLabel}*`; // Enforce card size limit to avoid wasted API round-trips const MAX_FINAL_CARD_CHARS = 20_000; - if (displayText.length > MAX_FINAL_CARD_CHARS) { + if (displayText.length + completedSuffix.length > MAX_FINAL_CARD_CHARS) { const prefix = atSender ? `${atSender}\n\n` : ''; const suffix = '\n\n_(内容过长,已截断早期内容)_'; const fenceReserve = 4; // potential '```\n' prepend for fence rebalancing const maxBody = - MAX_FINAL_CARD_CHARS - prefix.length - suffix.length - fenceReserve; + MAX_FINAL_CARD_CHARS - + prefix.length - + suffix.length - + completedSuffix.length - + fenceReserve; displayText = prefix + fullText.slice(-maxBody) + suffix; // Re-balance code fences after truncation (line-by-line, handles indented fences) if (this.countFences(displayText) % 2 === 1) { @@ -1105,7 +1254,13 @@ export class FeishuChannel extends ChannelBase { displayText, true, inboundMsgId, + completedLabel, ); + if ( + await this.finalizeStoppedCardUpdate(inboundMsgId, cardState, chatId) + ) { + return; + } if (!updated) { // Fallback: try without tables (card table number limit, code-fence aware) const noTableText = this.stripTables( @@ -1117,7 +1272,13 @@ export class FeishuChannel extends ChannelBase { noTableText, true, inboundMsgId, + completedLabel, ); + if ( + await this.finalizeStoppedCardUpdate(inboundMsgId, cardState, chatId) + ) { + return; + } if (!retried) { // Final fallback: just mark as done with a short message let truncated = displayText.slice(0, 2000); @@ -1127,7 +1288,17 @@ export class FeishuChannel extends ChannelBase { truncated + '\n\n---\n*内容过长,已截断*', true, inboundMsgId, + completedLabel, ); + if ( + await this.finalizeStoppedCardUpdate( + inboundMsgId, + cardState, + chatId, + ) + ) { + return; + } if (!lastResort) { // All three updateCard attempts failed — delete orphaned card // before falling back to sendMessage @@ -1158,7 +1329,13 @@ export class FeishuChannel extends ChannelBase { displayText, true, inboundMsgId, + completedLabel, ); + if ( + await this.finalizeStoppedCardUpdate(inboundMsgId, cardState, chatId) + ) { + return; + } if (finalized) { this.cleanupCard(inboundMsgId); return; @@ -1180,16 +1357,20 @@ export class FeishuChannel extends ChannelBase { sessionId: string, messageId?: string, ): void { - if (messageId) { - this.sessionToInboundMsg.set(sessionId, messageId); - this.addReaction(messageId, 'OnIt').catch(() => {}); + const inboundMsgId = + messageId && this.isKnownInboundMessageId(messageId) + ? messageId + : undefined; + if (inboundMsgId) { + this.sessionToInboundMsg.set(sessionId, inboundMsgId); + this.addReaction(inboundMsgId, 'OnIt').catch(() => {}); // In blockStreaming mode, skip card creation — BlockStreamer handles delivery if (this.config.blockStreaming === 'on') return; // Create streaming card now that gating has passed - if (!this.cardSessions.has(messageId)) { - const atSender = this.msgToSenderName.get(messageId) || ''; + if (!this.cardSessions.has(inboundMsgId)) { + const atSender = this.msgToSenderName.get(inboundMsgId) || ''; const placeholderText = atSender ? `${atSender},思考中...` : '思考中...'; @@ -1201,14 +1382,19 @@ export class FeishuChannel extends ChannelBase { accumulatedText: '', lastUpdateAt: Date.now(), }; - this.cardSessions.set(messageId, cardState); + this.cardSessions.set(inboundMsgId, cardState); - this.createStreamingCard(chatId, placeholderText, undefined, messageId) + this.createStreamingCard( + chatId, + placeholderText, + undefined, + inboundMsgId, + ) .then((result) => { // Only check stopped (not cancelling) — cancelling is set before // cancelSession resolves, and the card must still be created so // handleStop can update it once cancelSession completes. - if (cardState.stopped || this.stoppedMessages.has(messageId)) { + if (cardState.stopped || this.stoppedMessages.has(inboundMsgId)) { // If abandoned by busy-wait timeout, delete the streaming card — // the response was already delivered via sendMessage. if (cardState.abandoned) { @@ -1226,20 +1412,21 @@ export class FeishuChannel extends ChannelBase { // Use cardState.atPrefix (captured by onCardAction before cleanupCard) const prefix = cardState.atPrefix || - this.msgToSenderName.get(messageId) || + this.msgToSenderName.get(inboundMsgId) || ''; - const stopText = prefix - ? `${prefix}\n\n*已停止生成*` - : '*已停止生成*'; this.updateCard( result.messageId, - stopText, + prefix, true, - messageId, + inboundMsgId, + this.stopLabelFor( + cardState.terminalStatus, + cardState.userStopped ?? false, + ), ).catch(() => {}); } cardState.creating = false; - this.cleanupCard(messageId); + this.cleanupCard(inboundMsgId); return; } if (result.success) { @@ -1256,7 +1443,7 @@ export class FeishuChannel extends ChannelBase { `[Feishu:${this.name}] Processing card error: ${err}\n`, ); cardState.creating = false; - this.cleanupCard(messageId); + this.cleanupCard(inboundMsgId); }); } } @@ -1267,12 +1454,10 @@ export class FeishuChannel extends ChannelBase { sessionId: string, messageId?: string, ): Promise { - if (messageId) { - this.removeReaction(messageId, 'OnIt').catch(() => {}); - } // Finalize card if onResponseComplete didn't run (prompt was cancelled) - const inboundMsgId = messageId || this.sessionToInboundMsg.get(sessionId); + const inboundMsgId = this.knownInboundMessageId(sessionId, messageId); if (inboundMsgId) { + this.removeReaction(inboundMsgId, 'OnIt').catch(() => {}); // Don't delete stoppedMessages here — let onResponseComplete / stale timer handle it. // Deleting here causes a race where the stop button's card callback loses the @sender prefix. const cs = this.cardSessions.get(inboundMsgId); @@ -1284,22 +1469,28 @@ export class FeishuChannel extends ChannelBase { } else if (cs.created) { cs.stopped = true; const atPrefix = this.msgToSenderName.get(inboundMsgId) || ''; - // Distinguish backend error (user didn't cancel) from user cancellation. - // User cancellation sets cs.stopped via onCardAction before onPromptEnd, - // so reaching here with !cs.stopped means the prompt failed unexpectedly. - const errorLabel = '*出错了,请重试*'; + const terminalStatus = + cs.terminalStatus ?? (cs.cancelling ? 'cancelled' : 'failed'); + // userStopped: a Stop click may lose the race against this wind-down + // path — still render 已停止生成 rather than 已取消/已失败. + const terminalLabel = this.stopLabelFor( + terminalStatus, + cs.userStopped ?? false, + ); const text = cs.accumulatedText - ? (atPrefix - ? `${atPrefix}\n\n${cs.accumulatedText}` - : cs.accumulatedText) + - '\n\n---\n' + - errorLabel - : (atPrefix ? `${atPrefix}\n\n` : '') + errorLabel; + ? atPrefix + ? `${atPrefix}\n\n${cs.accumulatedText}` + : cs.accumulatedText + : atPrefix || ''; // Must await updateCard before cleanupCard — updateCard reads // msgToQuestion after an await, which cleanupCard would delete. - await this.updateCard(cs.messageId, text, true, inboundMsgId).catch( - () => {}, - ); + await this.updateCard( + cs.messageId, + text, + true, + inboundMsgId, + terminalLabel, + ).catch(() => {}); this.cleanupCard(inboundMsgId); } else { // Card creation failed — fallback to plain message delivery @@ -1313,9 +1504,12 @@ export class FeishuChannel extends ChannelBase { // No accumulated text (e.g. immediate LLM error before first chunk) // — send a generic error so the user isn't left without feedback. const atPrefix = this.msgToSenderName.get(inboundMsgId) || ''; + const fallbackLabel = cs.terminalStatus + ? this.statusLabelFor(cs.terminalStatus) + : '出错了,请重试'; const errorText = atPrefix - ? `${atPrefix}\n\n*出错了,请重试*` - : '*出错了,请重试*'; + ? `${atPrefix}\n\n*${fallbackLabel}*` + : `*${fallbackLabel}*`; this.sendMessage(_chatId, errorText).catch(() => {}); process.stderr.write( `[Feishu:${this.name}] onPromptEnd: no card and no accumulated text for inbound=${inboundMsgId}, sent error fallback\n`, @@ -1497,15 +1691,14 @@ export class FeishuChannel extends ChannelBase { const inboundId = targetInboundMsgId; const handleStop = async () => { - let cancelSucceeded = true; - if (sessionId) { - await this.bridge.cancelSession(sessionId).catch((err) => { - cancelSucceeded = false; - process.stderr.write( - `[Feishu:${this.name}] cancelSession failed for msg=${inboundId}: ${err instanceof Error ? err.message : err}\n`, - ); - }); - } + const wasUserStop = !cardState.terminalStatus; + cardState.userStopped = wasUserStop; + const cancelSucceeded = sessionId + ? await this.requestActivePromptCancellation( + sessionId, + 'cancel_command', + ) + : true; // Only mark as stopped after cancelSession succeeds. If it failed, // don't set stopped=true — let the agent continue running normally. if (cancelSucceeded) { @@ -1513,8 +1706,11 @@ export class FeishuChannel extends ChannelBase { cardState.cancelling = false; this.stoppedMessages.add(inboundId); } else { - // Clear cancelling flag so .then() callbacks don't treat this as stopped + // Clear cancelling flag so .then() callbacks don't treat this as stopped. + // The stop didn't take — later wind-down paths must render the real + // terminal status, not 已停止生成. cardState.cancelling = false; + cardState.userStopped = false; } // If onResponseComplete is already finalizing the card, don't race with it. if (cardState.finalizing) return; @@ -1524,25 +1720,35 @@ export class FeishuChannel extends ChannelBase { const prefix = cardState.atPrefix || this.msgToSenderName.get(inboundId) || ''; const stopLabel = cancelSucceeded - ? '*已停止生成*' - : '*停止失败,请重试*'; + ? this.stopLabelFor(cardState.terminalStatus, wasUserStop) + : FEISHU_STOP_FAILED_STATUS_LABEL; const contentPart = cardState.accumulatedText.trim() - ? cardState.accumulatedText + '\n\n---\n' + stopLabel - : stopLabel; + ? cardState.accumulatedText + : ''; const finalText = prefix - ? `${prefix}\n\n${contentPart}` + ? contentPart + ? `${prefix}\n\n${contentPart}` + : prefix : contentPart; const updated = await this.updateCard( cardState.messageId, finalText, cancelSucceeded, inboundId, + stopLabel, ); // If updateCard failed and cancel succeeded, try to delete the orphaned // card and fall back to sendMessage to avoid leaving a stuck "生成中..." card. if (!updated && cancelSucceeded && chatId) { await this.deleteCard(cardState.messageId); - await this.sendMessage(chatId, finalText); + // Same `---` + label shape as rendered cards so extractCardText + // strips it from quote-reply context. + await this.sendMessage( + chatId, + finalText + ? `${finalText}\n\n---\n*${stopLabel}*` + : `---\n*${stopLabel}*`, + ); } } // Do NOT cleanupCard here — let onResponseComplete / onPromptEnd handle it. diff --git a/packages/channels/feishu/src/adapter.test.ts b/packages/channels/feishu/src/adapter.test.ts index 86e68693d8..c7cb8ef047 100644 --- a/packages/channels/feishu/src/adapter.test.ts +++ b/packages/channels/feishu/src/adapter.test.ts @@ -3,6 +3,7 @@ import { FeishuChannel } from './FeishuAdapter.js'; import type { ChannelAgentBridge, ChannelConfig, + ChannelTaskLifecycleEvent, SessionTarget, } from '@qwen-code/channel-base'; @@ -246,6 +247,119 @@ describe('FeishuChannel', () => { expect(result).toBe('Content'); }); + it('strips lifecycle running indicator', () => { + const card = { + body: { + elements: [{ tag: 'markdown', content: 'Content\n---\n*运行中...*' }], + }, + }; + const result = extractCardText(card); + expect(result).not.toContain('运行中'); + expect(result).toBe('Content'); + }); + + it('strips terminal lifecycle labels', () => { + for (const label of [ + '已完成', + '已取消', + '已失败,请重试', + '已停止生成', + ]) { + const card = { + body: { + elements: [ + { tag: 'markdown', content: `Content\n---\n*${label}*` }, + ], + }, + }; + const result = extractCardText(card); + expect(result).not.toContain(label); + expect(result).toBe('Content'); + } + }); + + it('keeps bare emphasized text matching a status label', () => { + const card = { + body: { + elements: [{ tag: 'markdown', content: 'Content\n*已完成*' }], + }, + }; + + const result = extractCardText(card); + + expect(result).toBe('Content\n*已完成*'); + }); + + it('strips truncation notice with terminal lifecycle label', () => { + // Real last-resort shape: the truncation notice block is baked into the + // card text and buildCardContent appends the label as its own block. + const card = { + body: { + elements: [ + { + tag: 'markdown', + content: 'Content\n\n---\n*内容过长,已截断*\n\n---\n*已完成*', + }, + ], + }, + }; + + const result = extractCardText(card); + + expect(result).toBe('Content'); + }); + + it('strips a terminal label joined before a collapsible panel body', () => { + // Finished collapsible card: the label lands in the preview element and + // sits mid-string once the elements are joined. + const card = { + body: { + elements: [ + { tag: 'markdown', content: 'Preview text\n\n---\n*已完成*' }, + { + tag: 'collapsible_panel', + elements: [{ tag: 'markdown', content: 'Rest of the answer' }], + }, + ], + }, + }; + + const result = extractCardText(card); + + expect(result).not.toContain('已完成'); + expect(result).toContain('Preview text'); + expect(result).toContain('Rest of the answer'); + }); + + it('strips the stop-failure label', () => { + const card = { + body: { + elements: [ + { + tag: 'markdown', + content: 'Partial answer\n\n---\n*停止失败,请重试*', + }, + ], + }, + }; + + const result = extractCardText(card); + + expect(result).toBe('Partial answer'); + }); + + it('returns undefined for a label-only stopped card', () => { + const card = { + body: { + elements: [{ tag: 'markdown', content: '\n\n---\n*已停止生成*' }], + }, + }; + + const result = extractCardText(card); + + expect(result).toBeUndefined(); + }); + it('returns undefined for empty card', () => { const result = extractCardText({}); expect(result).toBeUndefined(); @@ -396,6 +510,60 @@ describe('FeishuChannel', () => { }); }); + describe('prompt hook inbound IDs', () => { + it('ignores loop job ids that were not registered by processMessage', async () => { + const channel = createChannel(); + const createStreamingCard = vi.fn().mockResolvedValue({ + success: true, + messageId: 'om_valid_message_id', + }); + const addReaction = vi.fn().mockResolvedValue(undefined); + const removeReaction = vi.fn().mockResolvedValue(undefined); + + ( + channel as unknown as { + createStreamingCard: typeof createStreamingCard; + addReaction: typeof addReaction; + removeReaction: typeof removeReaction; + } + ).createStreamingCard = createStreamingCard; + (channel as unknown as { addReaction: typeof addReaction }).addReaction = + addReaction; + ( + channel as unknown as { removeReaction: typeof removeReaction } + ).removeReaction = removeReaction; + getPrivateMethod>(channel, 'msgToQuestion').set( + 'inbound_1', + 'question?', + ); + + getPrivateMethod< + (chatId: string, sessionId: string, messageId?: string) => void + >(channel, 'onPromptStart').call( + channel, + 'oc_chat_id', + 'session_1', + 'job-1', + ); + await getPrivateMethod< + (chatId: string, sessionId: string, messageId?: string) => Promise + >(channel, 'onPromptEnd').call( + channel, + 'oc_chat_id', + 'session_1', + 'job-1', + ); + + expect( + getPrivateMethod>(channel, 'sessionToInboundMsg') + .size, + ).toBe(0); + expect(addReaction).not.toHaveBeenCalled(); + expect(removeReaction).not.toHaveBeenCalled(); + expect(createStreamingCard).not.toHaveBeenCalled(); + }); + }); + describe('state machine: stop button during card creation', () => { let channel: FeishuChannel; @@ -418,11 +586,14 @@ describe('FeishuChannel', () => { lastUpdateAt: Date.now(), }); - // Mock bridge - const bridge = getPrivateMethod(channel, 'bridge'); - const cancelSessionSpy = vi - .spyOn(bridge, 'cancelSession') - .mockResolvedValue(undefined); + const cancelPromptSpy = vi.fn().mockResolvedValue(true); + ( + channel as unknown as { + requestActivePromptCancellation: ( + sessionId: string, + ) => Promise; + } + ).requestActivePromptCancellation = cancelPromptSpy; // Mock updateCard to not actually call HTTP const updateCardMock = vi.fn().mockResolvedValue(true); @@ -457,17 +628,90 @@ describe('FeishuChannel', () => { const state = cardSessions.get('inbound_1') as | Record | undefined; - // cancelling is set synchronously (stopped is deferred until cancelSession resolves) + // cancelling is set synchronously (stopped is deferred until cancellation resolves) expect(state?.['cancelling']).toBe(true); - // Wait for async handleStop to complete — stopped is set after cancelSession resolves + // Wait for async handleStop to complete — stopped is set after cancellation resolves await vi.waitFor(() => { expect(state?.['stopped']).toBe(true); }); - expect(cancelSessionSpy).toHaveBeenCalledWith('session_abc'); + expect(cancelPromptSpy).toHaveBeenCalledWith( + 'session_abc', + 'cancel_command', + ); expect(state?.['cancelling']).toBe(false); }); + it('keeps user stop label when cancellation lifecycle marks the card cancelled', async () => { + const cardSessions = getPrivateMethod< + Map> + >(channel, 'cardSessions'); + cardSessions.set('inbound_1', { + messageId: 'card_1', + created: true, + creating: false, + stopped: false, + accumulatedText: 'partial text', + lastUpdateAt: Date.now(), + }); + + const updateCard = vi.fn().mockResolvedValue(true); + (channel as unknown as { updateCard: typeof updateCard }).updateCard = + updateCard; + ( + channel as unknown as { + requestActivePromptCancellation: ( + sessionId: string, + ) => Promise; + } + ).requestActivePromptCancellation = vi + .fn() + .mockImplementation(async () => { + getPrivateMethod<(event: ChannelTaskLifecycleEvent) => void>( + channel, + 'onTaskLifecycle', + ).call(channel, { + type: 'cancelled', + reason: 'cancel_command', + channelName: 'feishu', + chatId: 'oc_chat_id', + sessionId: 'session_abc', + messageId: 'inbound_1', + identity: { id: 'channel:feishu', displayName: 'feishu' }, + memoryScope: { + namespace: 'channel:feishu', + mode: 'metadata-only', + }, + }); + return true; + }); + + getPrivateMethod>(channel, 'sessionToInboundMsg').set( + 'session_abc', + 'inbound_1', + ); + getPrivateMethod>(channel, 'msgToSenderId').set( + 'inbound_1', + 'user_open_id', + ); + + getPrivateMethod<(data: Record) => boolean>( + channel, + 'onCardAction', + ).call(channel, { + action: { value: { action: 'stop' } }, + context: { open_message_id: 'card_1' }, + operator: { open_id: 'user_open_id' }, + }); + + await vi.waitFor(() => { + expect(updateCard).toHaveBeenCalledTimes(1); + }); + // Terminal labels travel via the statusLabel param, not the card text. + expect(updateCard.mock.calls[0]![4]).toBe('已停止生成'); + expect(updateCard.mock.calls[0]![1]).not.toContain('已取消'); + }); + it('rejects stop from a different user (operator mismatch)', () => { const cardSessions = getPrivateMethod< Map> @@ -641,13 +885,17 @@ describe('FeishuChannel', () => { }); describe('onCardAction: cancelSession failure', () => { - it('shows "停止失败" when cancelSession throws', async () => { + it('shows stop failure status when cancelSession throws', async () => { const bridge = createMockBridge(); - (bridge.cancelSession as ReturnType).mockRejectedValueOnce( - new Error('session not found'), - ); const config = createConfig(); const channel = new FeishuChannel('test', config, bridge); + ( + channel as unknown as { + requestActivePromptCancellation: ( + sessionId: string, + ) => Promise; + } + ).requestActivePromptCancellation = vi.fn().mockResolvedValue(false); // Set up botOpenId and card state (channel as unknown as Record)['botOpenId'] = 'bot_123'; @@ -702,8 +950,71 @@ describe('FeishuChannel', () => { await new Promise((r) => setTimeout(r, 50)); expect(updateCardSpy).toHaveBeenCalled(); + expect(updateCardSpy.mock.calls[0][4]).toBe('停止失败,请重试'); const cardText = updateCardSpy.mock.calls[0][1] as string; - expect(cardText).toContain('停止失败'); + expect(cardText).not.toContain('已失败,请重试'); + }); + + it('uses divider status shape when stopped empty-card fallback sends a message', async () => { + const bridge = createMockBridge(); + const config = createConfig(); + const channel = new FeishuChannel('test', config, bridge); + ( + channel as unknown as { + requestActivePromptCancellation: ( + sessionId: string, + ) => Promise; + } + ).requestActivePromptCancellation = vi.fn().mockResolvedValue(true); + + const cardSessions = getPrivateMethod< + Map> + >(channel, 'cardSessions'); + cardSessions.set('inbound_1', { + messageId: 'card_1', + created: true, + creating: false, + stopped: false, + accumulatedText: '', + lastUpdateAt: Date.now(), + }); + getPrivateMethod>(channel, 'msgToSenderId').set( + 'inbound_1', + 'original_user', + ); + getPrivateMethod>(channel, 'sessionToInboundMsg').set( + 'session_1', + 'inbound_1', + ); + + (channel as unknown as Record)['updateCard'] = vi + .fn() + .mockResolvedValue(false); + (channel as unknown as Record)['deleteCard'] = vi + .fn() + .mockResolvedValue(undefined); + const sendMessage = vi.fn().mockResolvedValue(undefined); + (channel as unknown as Record)['sendMessage'] = + sendMessage; + + getPrivateMethod<(data: Record) => boolean>( + channel, + 'onCardAction', + ).call(channel, { + action: { value: { action: 'stop' } }, + context: { + open_message_id: 'card_1', + open_chat_id: 'oc_chat_id', + }, + operator: { open_id: 'original_user' }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledWith( + 'oc_chat_id', + '---\n*已停止生成*', + ); + }); }); }); @@ -867,9 +1178,9 @@ describe('FeishuChannel', () => { token: 'tenant-token', expiresAt: Date.now() + 3600_000, }; - const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue( - new Response('{}', { status: 200 }), - ); + const fetchSpy = vi + .spyOn(global, 'fetch') + .mockResolvedValue(new Response('{}', { status: 200 })); await channel.pushLoop( { @@ -977,6 +1288,474 @@ describe('FeishuChannel', () => { expect.stringContaining('partial response text'), ); }); + + it('records failed lifecycle state for prompt-end card finalization', async () => { + const channel = createChannel(); + const cardSessions = getPrivateMethod>( + channel, + 'cardSessions', + ); + cardSessions.set('inbound_1', { + messageId: 'om_valid_message_id', + created: true, + creating: false, + stopped: false, + accumulatedText: 'partial answer', + lastUpdateAt: Date.now(), + }); + + const updateCard = vi.fn().mockResolvedValue(true); + (channel as unknown as { updateCard: typeof updateCard }).updateCard = + updateCard; + + getPrivateMethod<(event: ChannelTaskLifecycleEvent) => void>( + channel, + 'onTaskLifecycle', + ).call(channel, { + type: 'failed', + channelName: 'feishu', + chatId: 'oc_chat_id', + sessionId: 'session_1', + messageId: 'inbound_1', + error: 'boom', + identity: { id: 'channel:feishu', displayName: 'feishu' }, + memoryScope: { namespace: 'channel:feishu', mode: 'metadata-only' }, + }); + + await getPrivateMethod< + (chatId: string, sessionId: string, messageId?: string) => Promise + >(channel, 'onPromptEnd').call( + channel, + 'oc_chat_id', + 'session_1', + 'inbound_1', + ); + + expect(updateCard.mock.calls[0]![4]).toBe('已失败,请重试'); + }); + + it('records cancelled lifecycle state for prompt-end card finalization', async () => { + const channel = createChannel(); + const cardSessions = getPrivateMethod>( + channel, + 'cardSessions', + ); + cardSessions.set('inbound_1', { + messageId: 'om_valid_message_id', + created: true, + creating: false, + stopped: false, + accumulatedText: 'partial answer', + lastUpdateAt: Date.now(), + }); + + const updateCard = vi.fn().mockResolvedValue(true); + (channel as unknown as { updateCard: typeof updateCard }).updateCard = + updateCard; + + getPrivateMethod<(event: ChannelTaskLifecycleEvent) => void>( + channel, + 'onTaskLifecycle', + ).call(channel, { + type: 'cancelled', + reason: 'cancel_command', + channelName: 'feishu', + chatId: 'oc_chat_id', + sessionId: 'session_1', + messageId: 'inbound_1', + identity: { id: 'channel:feishu', displayName: 'feishu' }, + memoryScope: { namespace: 'channel:feishu', mode: 'metadata-only' }, + }); + + await getPrivateMethod< + (chatId: string, sessionId: string, messageId?: string) => Promise + >(channel, 'onPromptEnd').call( + channel, + 'oc_chat_id', + 'session_1', + 'inbound_1', + ); + + expect(updateCard.mock.calls[0]![4]).toBe('已取消'); + }); + + it('keeps the first terminal lifecycle state for prompt-end card finalization', async () => { + const channel = createChannel(); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const cardSessions = getPrivateMethod>( + channel, + 'cardSessions', + ); + cardSessions.set('inbound_1', { + messageId: 'om_valid_message_id', + created: true, + creating: false, + stopped: false, + accumulatedText: 'answer', + lastUpdateAt: Date.now(), + }); + + const updateCard = vi.fn().mockResolvedValue(true); + (channel as unknown as { updateCard: typeof updateCard }).updateCard = + updateCard; + + const lifecycle = getPrivateMethod< + (event: ChannelTaskLifecycleEvent) => void + >(channel, 'onTaskLifecycle'); + const baseEvent = { + channelName: 'feishu', + chatId: 'oc_chat_id', + sessionId: 'session_1', + messageId: 'inbound_1', + identity: { id: 'channel:feishu', displayName: 'feishu' }, + memoryScope: { namespace: 'channel:feishu', mode: 'metadata-only' }, + } as const; + + lifecycle.call(channel, { + ...baseEvent, + type: 'completed', + } satisfies ChannelTaskLifecycleEvent); + lifecycle.call(channel, { + ...baseEvent, + type: 'cancelled', + reason: 'cancel_command', + } satisfies ChannelTaskLifecycleEvent); + + await getPrivateMethod< + (chatId: string, sessionId: string, messageId?: string) => Promise + >(channel, 'onPromptEnd').call( + channel, + 'oc_chat_id', + 'session_1', + 'inbound_1', + ); + + expect(updateCard.mock.calls[0]![4]).toBe('已完成'); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining( + 'conflicting terminal event cancelled after completed', + ), + ); + stderr.mockRestore(); + }); + + it('resolves the card via sessionToInboundMsg when the event has no messageId', async () => { + const channel = createChannel(); + const cardSessions = getPrivateMethod>( + channel, + 'cardSessions', + ); + cardSessions.set('inbound_1', { + messageId: 'om_valid_message_id', + created: true, + creating: false, + stopped: false, + accumulatedText: 'answer', + lastUpdateAt: Date.now(), + }); + getPrivateMethod>(channel, 'sessionToInboundMsg').set( + 'session_1', + 'inbound_1', + ); + + const updateCard = vi.fn().mockResolvedValue(true); + (channel as unknown as { updateCard: typeof updateCard }).updateCard = + updateCard; + + getPrivateMethod<(event: ChannelTaskLifecycleEvent) => void>( + channel, + 'onTaskLifecycle', + ).call(channel, { + type: 'cancelled', + reason: 'cancel_command', + channelName: 'feishu', + chatId: 'oc_chat_id', + sessionId: 'session_1', + identity: { id: 'channel:feishu', displayName: 'feishu' }, + memoryScope: { namespace: 'channel:feishu', mode: 'metadata-only' }, + }); + + await getPrivateMethod< + (chatId: string, sessionId: string, messageId?: string) => Promise + >(channel, 'onPromptEnd').call( + channel, + 'oc_chat_id', + 'session_1', + 'inbound_1', + ); + + expect(updateCard.mock.calls[0]![4]).toBe('已取消'); + }); + + it('treats prompt-end during stop cancellation as cancelled', async () => { + const channel = createChannel(); + const cardSessions = getPrivateMethod>( + channel, + 'cardSessions', + ); + cardSessions.set('inbound_1', { + messageId: 'om_valid_message_id', + created: true, + creating: false, + stopped: false, + cancelling: true, + accumulatedText: 'partial answer', + lastUpdateAt: Date.now(), + }); + + const updateCard = vi.fn().mockResolvedValue(true); + (channel as unknown as { updateCard: typeof updateCard }).updateCard = + updateCard; + + await getPrivateMethod< + (chatId: string, sessionId: string, messageId?: string) => Promise + >(channel, 'onPromptEnd').call( + channel, + 'oc_chat_id', + 'session_1', + 'inbound_1', + ); + + expect(updateCard.mock.calls[0]![4]).toBe('已取消'); + }); + + it('finalizes creating cards as failed instead of stopped after prompt end', async () => { + const channel = createChannel(); + let resolveCreateCard: + | ((value: { success: boolean; messageId: string }) => void) + | undefined; + const createCardPromise = new Promise<{ + success: boolean; + messageId: string; + }>((resolve) => { + resolveCreateCard = resolve; + }); + + const createStreamingCard = vi.fn().mockReturnValue(createCardPromise); + const updateCard = vi.fn().mockResolvedValue(true); + const addReaction = vi.fn().mockResolvedValue(undefined); + const removeReaction = vi.fn().mockResolvedValue(undefined); + + ( + channel as unknown as { + createStreamingCard: typeof createStreamingCard; + updateCard: typeof updateCard; + addReaction: typeof addReaction; + removeReaction: typeof removeReaction; + } + ).createStreamingCard = createStreamingCard; + (channel as unknown as { updateCard: typeof updateCard }).updateCard = + updateCard; + (channel as unknown as { addReaction: typeof addReaction }).addReaction = + addReaction; + ( + channel as unknown as { removeReaction: typeof removeReaction } + ).removeReaction = removeReaction; + getPrivateMethod>(channel, 'msgToQuestion').set( + 'inbound_1', + 'question?', + ); + + getPrivateMethod< + (chatId: string, sessionId: string, messageId?: string) => void + >(channel, 'onPromptStart').call( + channel, + 'oc_chat_id', + 'session_1', + 'inbound_1', + ); + + getPrivateMethod<(event: ChannelTaskLifecycleEvent) => void>( + channel, + 'onTaskLifecycle', + ).call(channel, { + type: 'failed', + channelName: 'feishu', + chatId: 'oc_chat_id', + sessionId: 'session_1', + messageId: 'inbound_1', + error: 'boom', + identity: { id: 'channel:feishu', displayName: 'feishu' }, + memoryScope: { namespace: 'channel:feishu', mode: 'metadata-only' }, + }); + + await getPrivateMethod< + (chatId: string, sessionId: string, messageId?: string) => Promise + >(channel, 'onPromptEnd').call( + channel, + 'oc_chat_id', + 'session_1', + 'inbound_1', + ); + + resolveCreateCard?.({ success: true, messageId: 'om_valid_message_id' }); + + await vi.waitFor(() => { + expect(updateCard).toHaveBeenCalledTimes(1); + }); + + expect(updateCard.mock.calls[0]![4]).toBe('已失败,请重试'); + expect(updateCard.mock.calls[0]![1]).not.toContain('已停止生成'); + }); + + it('finalizes creating cards as cancelled instead of stopped after prompt end', async () => { + const channel = createChannel(); + let resolveCreateCard: + | ((value: { success: boolean; messageId: string }) => void) + | undefined; + const createCardPromise = new Promise<{ + success: boolean; + messageId: string; + }>((resolve) => { + resolveCreateCard = resolve; + }); + + const createStreamingCard = vi.fn().mockReturnValue(createCardPromise); + const updateCard = vi.fn().mockResolvedValue(true); + const addReaction = vi.fn().mockResolvedValue(undefined); + const removeReaction = vi.fn().mockResolvedValue(undefined); + + ( + channel as unknown as { + createStreamingCard: typeof createStreamingCard; + updateCard: typeof updateCard; + addReaction: typeof addReaction; + removeReaction: typeof removeReaction; + } + ).createStreamingCard = createStreamingCard; + (channel as unknown as { updateCard: typeof updateCard }).updateCard = + updateCard; + (channel as unknown as { addReaction: typeof addReaction }).addReaction = + addReaction; + ( + channel as unknown as { removeReaction: typeof removeReaction } + ).removeReaction = removeReaction; + getPrivateMethod>(channel, 'msgToQuestion').set( + 'inbound_1', + 'question?', + ); + + getPrivateMethod< + (chatId: string, sessionId: string, messageId?: string) => void + >(channel, 'onPromptStart').call( + channel, + 'oc_chat_id', + 'session_1', + 'inbound_1', + ); + + getPrivateMethod<(event: ChannelTaskLifecycleEvent) => void>( + channel, + 'onTaskLifecycle', + ).call(channel, { + type: 'cancelled', + reason: 'cancel_command', + channelName: 'feishu', + chatId: 'oc_chat_id', + sessionId: 'session_1', + messageId: 'inbound_1', + identity: { id: 'channel:feishu', displayName: 'feishu' }, + memoryScope: { namespace: 'channel:feishu', mode: 'metadata-only' }, + }); + + await getPrivateMethod< + (chatId: string, sessionId: string, messageId?: string) => Promise + >(channel, 'onPromptEnd').call( + channel, + 'oc_chat_id', + 'session_1', + 'inbound_1', + ); + + resolveCreateCard?.({ success: true, messageId: 'om_valid_message_id' }); + + await vi.waitFor(() => { + expect(updateCard).toHaveBeenCalledTimes(1); + }); + + expect(updateCard.mock.calls[0]![4]).toBe('已取消'); + expect(updateCard.mock.calls[0]![1]).not.toContain('已停止生成'); + }); + + it('finalizes creating cards as completed after empty successful responses', async () => { + const channel = createChannel(); + let resolveCreateCard: + | ((value: { success: boolean; messageId: string }) => void) + | undefined; + const createCardPromise = new Promise<{ + success: boolean; + messageId: string; + }>((resolve) => { + resolveCreateCard = resolve; + }); + + const createStreamingCard = vi.fn().mockReturnValue(createCardPromise); + const updateCard = vi.fn().mockResolvedValue(true); + const addReaction = vi.fn().mockResolvedValue(undefined); + const removeReaction = vi.fn().mockResolvedValue(undefined); + + ( + channel as unknown as { + createStreamingCard: typeof createStreamingCard; + updateCard: typeof updateCard; + addReaction: typeof addReaction; + removeReaction: typeof removeReaction; + } + ).createStreamingCard = createStreamingCard; + (channel as unknown as { updateCard: typeof updateCard }).updateCard = + updateCard; + (channel as unknown as { addReaction: typeof addReaction }).addReaction = + addReaction; + ( + channel as unknown as { removeReaction: typeof removeReaction } + ).removeReaction = removeReaction; + getPrivateMethod>(channel, 'msgToQuestion').set( + 'inbound_1', + 'question?', + ); + + getPrivateMethod< + (chatId: string, sessionId: string, messageId?: string) => void + >(channel, 'onPromptStart').call( + channel, + 'oc_chat_id', + 'session_1', + 'inbound_1', + ); + + getPrivateMethod<(event: ChannelTaskLifecycleEvent) => void>( + channel, + 'onTaskLifecycle', + ).call(channel, { + type: 'completed', + channelName: 'feishu', + chatId: 'oc_chat_id', + sessionId: 'session_1', + messageId: 'inbound_1', + identity: { id: 'channel:feishu', displayName: 'feishu' }, + memoryScope: { namespace: 'channel:feishu', mode: 'metadata-only' }, + }); + + await getPrivateMethod< + (chatId: string, sessionId: string, messageId?: string) => Promise + >(channel, 'onPromptEnd').call( + channel, + 'oc_chat_id', + 'session_1', + 'inbound_1', + ); + + resolveCreateCard?.({ success: true, messageId: 'om_valid_message_id' }); + + await vi.waitFor(() => { + expect(updateCard).toHaveBeenCalledTimes(1); + }); + + expect(updateCard.mock.calls[0]![4]).toBe('已完成'); + expect(updateCard.mock.calls[0]![1]).not.toContain('已停止生成'); + }); }); describe('onResponseComplete: stopped card cleanup', () => { @@ -1020,6 +1799,165 @@ describe('FeishuChannel', () => { // Card session should be cleaned up expect(cardSessions.has('inbound_1')).toBe(false); }); + + it('marks completed cards with the completed status label', async () => { + const channel = createChannel(); + const sessionToInboundMsg = getPrivateMethod>( + channel, + 'sessionToInboundMsg', + ); + const cardSessions = getPrivateMethod>( + channel, + 'cardSessions', + ); + sessionToInboundMsg.set('session_1', 'inbound_1'); + cardSessions.set('inbound_1', { + messageId: 'om_valid_message_id', + created: true, + creating: false, + stopped: false, + accumulatedText: 'answer', + lastUpdateAt: Date.now(), + }); + + const updateCard = vi.fn().mockResolvedValue(true); + (channel as unknown as { updateCard: typeof updateCard }).updateCard = + updateCard; + + await getPrivateMethod< + (chatId: string, fullText: string, sessionId: string) => Promise + >(channel, 'onResponseComplete').call( + channel, + 'oc_chat_id', + 'final answer', + 'session_1', + ); + + expect(updateCard.mock.calls[0]![4]).toBe('已完成'); + }); + + it('keeps stop status when user stops during final card update', async () => { + const channel = createChannel(); + const sessionToInboundMsg = getPrivateMethod>( + channel, + 'sessionToInboundMsg', + ); + const cardSessions = getPrivateMethod< + Map> + >(channel, 'cardSessions'); + const msgToSenderId = getPrivateMethod>( + channel, + 'msgToSenderId', + ); + const msgToSenderName = getPrivateMethod>( + channel, + 'msgToSenderName', + ); + sessionToInboundMsg.set('session_1', 'inbound_1'); + msgToSenderId.set('inbound_1', 'original_user'); + msgToSenderName.set('inbound_1', '@sender'); + cardSessions.set('inbound_1', { + messageId: 'om_valid_message_id', + created: true, + creating: false, + stopped: false, + accumulatedText: 'partial answer', + lastUpdateAt: Date.now(), + }); + + let resolveFirstUpdate: (value: boolean) => void = () => {}; + const firstUpdate = new Promise((resolve) => { + resolveFirstUpdate = resolve; + }); + const updateCard = vi + .fn() + .mockReturnValueOnce(firstUpdate) + .mockResolvedValue(true); + (channel as unknown as { updateCard: typeof updateCard }).updateCard = + updateCard; + ( + channel as unknown as { + requestActivePromptCancellation: ( + sessionId: string, + ) => Promise; + } + ).requestActivePromptCancellation = vi.fn().mockResolvedValue(true); + + const complete = getPrivateMethod< + (chatId: string, fullText: string, sessionId: string) => Promise + >(channel, 'onResponseComplete').call( + channel, + 'oc_chat_id', + 'final answer', + 'session_1', + ); + + await vi.waitFor(() => { + expect(updateCard).toHaveBeenCalledTimes(1); + }); + + getPrivateMethod<(data: Record) => boolean>( + channel, + 'onCardAction', + ).call(channel, { + action: { value: { action: 'stop' } }, + context: { + open_message_id: 'om_valid_message_id', + open_chat_id: 'oc_chat_id', + }, + operator: { open_id: 'original_user' }, + }); + + await vi.waitFor(() => { + expect(cardSessions.get('inbound_1')?.['stopped']).toBe(true); + }); + resolveFirstUpdate(true); + await complete; + + expect(updateCard).toHaveBeenCalledTimes(2); + const stoppedCard = updateCard.mock.calls[1]![1] as string; + expect(stoppedCard).toContain('已停止生成'); + expect(stoppedCard).not.toContain('已完成'); + }); + + it('reserves final card space for the completed status label', async () => { + const channel = createChannel(); + const sessionToInboundMsg = getPrivateMethod>( + channel, + 'sessionToInboundMsg', + ); + const cardSessions = getPrivateMethod>( + channel, + 'cardSessions', + ); + sessionToInboundMsg.set('session_1', 'inbound_1'); + cardSessions.set('inbound_1', { + messageId: 'om_valid_message_id', + created: true, + creating: false, + stopped: false, + accumulatedText: 'answer', + lastUpdateAt: Date.now(), + }); + + const updateCard = vi.fn().mockResolvedValue(true); + (channel as unknown as { updateCard: typeof updateCard }).updateCard = + updateCard; + + await getPrivateMethod< + (chatId: string, fullText: string, sessionId: string) => Promise + >(channel, 'onResponseComplete').call( + channel, + 'oc_chat_id', + 'x'.repeat(20_000), + 'session_1', + ); + + const rendered = updateCard.mock.calls[0]![1] as string; + expect(updateCard.mock.calls[0]![4]).toBe('已完成'); + expect(rendered).not.toContain('已完成'); + expect(rendered.length).toBeLessThanOrEqual(20_000); + }); }); describe('webhook: JSON parse error logging', () => { diff --git a/packages/channels/feishu/src/markdown.test.ts b/packages/channels/feishu/src/markdown.test.ts index 6e4ae9e479..3d5cce89bf 100644 --- a/packages/channels/feishu/src/markdown.test.ts +++ b/packages/channels/feishu/src/markdown.test.ts @@ -31,6 +31,25 @@ describe('Feishu markdown utilities', () => { expect(card.body.elements[0]!.content).toContain('生成中...'); }); + it('uses a custom running status label', () => { + const card = buildCardContent('text', { + isStreaming: true, + statusLabel: '运行中...', + }) as unknown as CardStructure; + + expect(card.body.elements[0]!.content).toContain('运行中...'); + expect(card.body.elements[0]!.content).not.toContain('生成中...'); + }); + + it('uses a terminal status label without enabling streaming controls', () => { + const card = buildCardContent('text', { + statusLabel: '已完成', + }) as unknown as CardStructure; + + expect(card.body.elements[0]!.content).toContain('已完成'); + expect(card.body.elements.some((e) => e.tag === 'button')).toBe(false); + }); + it('adds stop button when showStopButton is true', () => { const card = buildCardContent('text', { showStopButton: true, @@ -69,6 +88,22 @@ describe('Feishu markdown utilities', () => { expect(panel).toBeDefined(); }); + it('keeps terminal status label visible in long collapsible content', () => { + const longText = 'a '.repeat(320); + const card = buildCardContent(longText, { + collapsible: true, + collapsibleThreshold: 500, + statusLabel: '已完成', + }) as unknown as CardStructure; + const panel = card.body.elements.find( + (e) => e.tag === 'collapsible_panel', + ); + + expect(panel).toBeDefined(); + expect(card.body.elements[0]!.content).toContain('已完成'); + expect(panel?.elements?.[0]?.content).not.toContain('已完成'); + }); + it('does not use collapsible for short content', () => { const card = buildCardContent('short', { collapsible: true, diff --git a/packages/channels/feishu/src/markdown.ts b/packages/channels/feishu/src/markdown.ts index 0dce1401ad..b000419454 100644 --- a/packages/channels/feishu/src/markdown.ts +++ b/packages/channels/feishu/src/markdown.ts @@ -72,6 +72,7 @@ export function buildCardContent( title?: string; showStopButton?: boolean; isStreaming?: boolean; + statusLabel?: string; collapsible?: boolean; collapsibleThreshold?: number; }, @@ -79,8 +80,10 @@ export function buildCardContent( const elements: Array> = []; // Main content + streaming indicator in one markdown block - const contentMd = options?.isStreaming - ? markdown + '\n\n---\n*生成中...*' + const statusLabel = + options?.statusLabel ?? (options?.isStreaming ? '生成中...' : undefined); + const contentMd = statusLabel + ? `${markdown}\n\n---\n*${statusLabel}*` : markdown; const threshold = options?.collapsibleThreshold || 500; @@ -114,10 +117,13 @@ export function buildCardContent( } const preview = markdown.slice(0, splitAt); const rest = markdown.slice(splitAt); + const previewContent = statusLabel + ? `${preview}\n\n---\n*${statusLabel}*` + : preview; elements.push({ tag: 'markdown', - content: preview, + content: previewContent, }); elements.push({ tag: 'collapsible_panel', diff --git a/packages/channels/plugin-example/README.md b/packages/channels/plugin-example/README.md index 6141b545f8..44860b2b83 100644 --- a/packages/channels/plugin-example/README.md +++ b/packages/channels/plugin-example/README.md @@ -117,4 +117,8 @@ Existing TypeScript plugins that explicitly type the adapter constructor or fact - **Streaming hooks** — override `onResponseChunk()` for progressive display (e.g., editing a message in-place) - Access control (allowlist, pairing, open), session routing, slash commands, crash recovery +## Lifecycle status + +The mock plugin protocol exposes streamed chunks and final outbound messages only. It does not model typing indicators, reactions, card updates, or any other status surface, so prompt and task lifecycle status are intentionally no-op for this example channel. + Full guide: [Channel Plugin Developer Guide](../../../docs/developers/channel-plugins.md) diff --git a/packages/channels/qqbot/src/send.test.ts b/packages/channels/qqbot/src/send.test.ts index 6532f7a24f..c75e2ad6bb 100644 --- a/packages/channels/qqbot/src/send.test.ts +++ b/packages/channels/qqbot/src/send.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { ChannelAgentBridge } from '@qwen-code/channel-base'; +import type { + ChannelAgentBridge, + ChannelTaskLifecycleEvent, +} from '@qwen-code/channel-base'; import { isValidChatId, hasMarkdownSyntax, splitText } from './QQChannel.js'; const { @@ -106,6 +109,7 @@ vi.mock('@qwen-code/channel-base', async () => { protected handleInbound(_env: unknown): Promise { return Promise.resolve(); } + protected onTaskLifecycle(_event: unknown): void {} }, SessionRouter: class { restoreSessions(): Promise { @@ -830,6 +834,75 @@ describe('sendMessage', () => { }); }); +describe('lifecycle status hooks', () => { + function makeChannel(): QQChannelInstance { + return new QQChannel( + 'test-bot', + { + type: 'qq', + token: '', + senderPolicy: 'open' as const, + allowedUsers: [], + sessionScope: 'user' as const, + cwd: '/tmp', + groupPolicy: 'disabled' as const, + groups: {}, + appID: 'test-app-id', + appSecret: 'test-secret', + }, + {} as unknown as ChannelAgentBridge, + ); + } + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('keeps prompt lifecycle hooks as explicit no-ops', () => { + const ch = makeChannel(); + const chp = ch as unknown as { + onPromptStart: ( + chatId: string, + sessionId: string, + messageId?: string, + ) => void; + onPromptEnd: ( + chatId: string, + sessionId: string, + messageId?: string, + ) => void; + }; + + expect(() => { + chp.onPromptStart('test-chat-id', 'session-1', 'msg-1'); + chp.onPromptEnd('test-chat-id', 'session-1', 'msg-1'); + }).not.toThrow(); + + expect(mockSendQQMessage).not.toHaveBeenCalled(); + }); + + it('does not synthesize task lifecycle status messages', () => { + const ch = makeChannel(); + const chp = ch as unknown as { + onTaskLifecycle: (event: ChannelTaskLifecycleEvent) => void; + }; + + expect(() => { + chp.onTaskLifecycle({ + type: 'started', + channelName: 'qqbot', + chatId: 'test-chat-id', + sessionId: 'session-1', + messageId: 'msg-1', + identity: { id: 'channel:qqbot', displayName: 'qqbot' }, + memoryScope: { namespace: 'channel:qqbot', mode: 'metadata-only' }, + } satisfies ChannelTaskLifecycleEvent); + }).not.toThrow(); + + expect(mockSendQQMessage).not.toHaveBeenCalled(); + }); +}); + describe('gateway reconnect timer', () => { function makeChannel(): QQChannelInstance { return new QQChannel( diff --git a/packages/channels/telegram/src/TelegramAdapter.test.ts b/packages/channels/telegram/src/TelegramAdapter.test.ts index ed7e58d867..18d4f9529e 100644 --- a/packages/channels/telegram/src/TelegramAdapter.test.ts +++ b/packages/channels/telegram/src/TelegramAdapter.test.ts @@ -3,9 +3,15 @@ import { TelegramChannel } from './TelegramAdapter.js'; import type { ChannelAgentBridge, ChannelConfig, + ChannelTaskLifecycleEvent, Envelope, } from '@qwen-code/channel-base'; +type LifecycleBase = Omit< + Extract, + 'type' +>; + type TestTelegramMessage = { from: { id: number; first_name: string; last_name?: string }; chat: { id: number; type: string }; @@ -16,10 +22,14 @@ type TestTelegramMessage = { type TestTelegramEntity = { type: string; offset: number; length: number }; class TestTelegramChannel extends TelegramChannel { - startTyping(chatId: string): void { + beginTyping(chatId: string): void { this.onPromptStart(chatId); } + emitLifecycle(event: ChannelTaskLifecycleEvent): void { + this.onTaskLifecycle(event); + } + buildTestEnvelope( msg: TestTelegramMessage, text: string, @@ -135,8 +145,8 @@ describe('TelegramChannel', () => { const channel = createChannel(); const bot = installFakeBot(channel); - channel.startTyping('chat-1'); - channel.startTyping('chat-2'); + channel.beginTyping('chat-1'); + channel.beginTyping('chat-2'); expect(bot.api.sendChatAction).toHaveBeenCalledTimes(2); channel.disconnect(); @@ -148,6 +158,130 @@ describe('TelegramChannel', () => { expect(bot.api.sendChatAction).toHaveBeenCalledTimes(2); }); + it('maps lifecycle start and terminal events to typing', () => { + const channel = createChannel(); + const bot = installFakeBot(channel); + + const baseEvent = { + channelName: 'telegram', + chatId: 'chat-1', + sessionId: 'session-1', + messageId: 'message-1', + identity: { id: 'channel:telegram', displayName: 'telegram' }, + memoryScope: { namespace: 'channel:telegram', mode: 'metadata-only' }, + } satisfies LifecycleBase; + + channel.emitLifecycle({ ...baseEvent, type: 'started' }); + channel.emitLifecycle({ ...baseEvent, type: 'started' }); + expect(bot.api.sendChatAction).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(4000); + expect(bot.api.sendChatAction).toHaveBeenCalledTimes(2); + + channel.emitLifecycle({ ...baseEvent, type: 'completed' }); + channel.emitLifecycle({ + ...baseEvent, + type: 'failed', + error: 'boom', + phase: 'agent', + }); + + vi.advanceTimersByTime(4000); + expect(bot.api.sendChatAction).toHaveBeenCalledTimes(2); + }); + + it('keeps typing active until every session in the chat reaches terminal state', () => { + const channel = createChannel(); + const bot = installFakeBot(channel); + + const baseEvent = { + channelName: 'telegram', + chatId: 'chat-1', + messageId: 'message-1', + identity: { id: 'channel:telegram', displayName: 'telegram' }, + memoryScope: { namespace: 'channel:telegram', mode: 'metadata-only' }, + } satisfies Omit; + + channel.emitLifecycle({ + ...baseEvent, + sessionId: 'session-1', + type: 'started', + }); + channel.emitLifecycle({ + ...baseEvent, + sessionId: 'session-2', + type: 'started', + }); + expect(bot.api.sendChatAction).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(4000); + expect(bot.api.sendChatAction).toHaveBeenCalledTimes(2); + + channel.emitLifecycle({ + ...baseEvent, + sessionId: 'session-1', + type: 'completed', + }); + + vi.advanceTimersByTime(4000); + expect(bot.api.sendChatAction).toHaveBeenCalledTimes(3); + + channel.emitLifecycle({ + ...baseEvent, + sessionId: 'session-2', + type: 'cancelled', + reason: 'cancel_command', + }); + + vi.advanceTimersByTime(4000); + expect(bot.api.sendChatAction).toHaveBeenCalledTimes(3); + }); + + it('clears typing when a session dies without a terminal event', () => { + const channel = createChannel({}, { removeSessionId: vi.fn() }); + const bot = installFakeBot(channel); + + channel.emitLifecycle({ + channelName: 'telegram', + chatId: 'chat-1', + sessionId: 'session-1', + messageId: 'message-1', + identity: { id: 'channel:telegram', displayName: 'telegram' }, + memoryScope: { namespace: 'channel:telegram', mode: 'metadata-only' }, + type: 'started', + }); + expect(bot.api.sendChatAction).toHaveBeenCalledTimes(1); + + channel.onSessionDied('session-1'); + + vi.advanceTimersByTime(4000); + expect(bot.api.sendChatAction).toHaveBeenCalledTimes(1); + }); + + it('treats typing status API failures as best-effort', () => { + const channel = createChannel(); + const bot = installFakeBot(channel); + bot.api.sendChatAction.mockImplementation(() => { + throw new Error('telegram unavailable'); + }); + + expect(() => channel.beginTyping('chat-1')).not.toThrow(); + expect(() => + channel.emitLifecycle({ + channelName: 'telegram', + chatId: 'chat-2', + sessionId: 'session-2', + messageId: 'message-2', + identity: { id: 'channel:telegram', displayName: 'telegram' }, + memoryScope: { namespace: 'channel:telegram', mode: 'metadata-only' }, + type: 'started', + }), + ).not.toThrow(); + + vi.advanceTimersByTime(4000); + expect(bot.api.sendChatAction).toHaveBeenCalledTimes(4); + }); + it('registers the Telegram command menu before polling starts', async () => { const channel = createChannel(); const bot = installFakeBot(channel); diff --git a/packages/channels/telegram/src/TelegramAdapter.ts b/packages/channels/telegram/src/TelegramAdapter.ts index ae85c1a71e..d1a8dd095b 100644 --- a/packages/channels/telegram/src/TelegramAdapter.ts +++ b/packages/channels/telegram/src/TelegramAdapter.ts @@ -8,12 +8,16 @@ import { telegramFormat, splitHtmlForTelegram, } from 'telegram-markdown-formatter'; -import { ChannelBase } from '@qwen-code/channel-base'; +import { + ChannelBase, + isTerminalTaskLifecycleType, +} from '@qwen-code/channel-base'; import type { - ChannelConfig, - ChannelBaseOptions, - Envelope, ChannelAgentBridge, + ChannelBaseOptions, + ChannelConfig, + ChannelTaskLifecycleEvent, + Envelope, SessionTarget, } from '@qwen-code/channel-base'; @@ -275,24 +279,66 @@ export class TelegramChannel extends ChannelBase { /** Per-chat typing interval — repeats every 4s since Telegram expires it after 5s. */ private typingIntervals = new Map>(); + private activeTypingSessions = new Map>(); - protected override onPromptStart(chatId: string): void { - // Clear any stale interval (shouldn't happen, but safe) - const existing = this.typingIntervals.get(chatId); - if (existing) clearInterval(existing); - - const sendTyping = () => - this.bot.api.sendChatAction(chatId, 'typing').catch(() => {}); - sendTyping(); - this.typingIntervals.set(chatId, setInterval(sendTyping, 4000)); + private sendTyping(chatId: string): void { + try { + void this.bot.api.sendChatAction(chatId, 'typing').catch(() => {}); + } catch { + // Best-effort typing indicator. + } } - protected override onPromptEnd(chatId: string): void { - const interval = this.typingIntervals.get(chatId); - if (interval) { - clearInterval(interval); - this.typingIntervals.delete(chatId); + private startTyping(chatId: string, sessionId = chatId): void { + const sessions = this.activeTypingSessions.get(chatId) ?? new Set(); + sessions.add(sessionId); + this.activeTypingSessions.set(chatId, sessions); + if (this.typingIntervals.has(chatId)) return; + this.sendTyping(chatId); + this.typingIntervals.set( + chatId, + setInterval(() => this.sendTyping(chatId), 4000), + ); + } + + private stopTyping(chatId: string, sessionId = chatId): void { + const sessions = this.activeTypingSessions.get(chatId); + if (sessions) { + sessions.delete(sessionId); + if (sessions.size > 0) return; + this.activeTypingSessions.delete(chatId); } + const interval = this.typingIntervals.get(chatId); + if (!interval) return; + clearInterval(interval); + this.typingIntervals.delete(chatId); + } + + protected override onTaskLifecycle(event: ChannelTaskLifecycleEvent): void { + if (event.type === 'started') { + this.startTyping(event.chatId, event.sessionId); + return; + } + if (isTerminalTaskLifecycleType(event.type)) { + this.stopTyping(event.chatId, event.sessionId); + } + } + + protected override onPromptStart(chatId: string, sessionId?: string): void { + this.startTyping(chatId, sessionId); + } + + protected override onPromptEnd(chatId: string, sessionId?: string): void { + this.stopTyping(chatId, sessionId); + } + + override onSessionDied(sessionId: string): void { + for (const [chatId, sessions] of this.activeTypingSessions) { + if (sessions.has(sessionId)) { + this.stopTyping(chatId, sessionId); + } + } + super.onSessionDied(sessionId); } async sendMessage(chatId: string, text: string): Promise { @@ -338,6 +384,7 @@ export class TelegramChannel extends ChannelBase { clearInterval(interval); } this.typingIntervals.clear(); + this.activeTypingSessions.clear(); this.bot.stop(); } diff --git a/packages/channels/weixin/src/WeixinAdapter.test.ts b/packages/channels/weixin/src/WeixinAdapter.test.ts new file mode 100644 index 0000000000..18cc23c4ce --- /dev/null +++ b/packages/channels/weixin/src/WeixinAdapter.test.ts @@ -0,0 +1,198 @@ +import { EventEmitter } from 'node:events'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +const apiMocks = vi.hoisted(() => ({ + getConfig: vi.fn(), + sendTyping: vi.fn(), +})); + +vi.mock('./api.js', async () => { + const actual = await vi.importActual('./api.js'); + return { + ...actual, + getConfig: apiMocks.getConfig, + sendTyping: apiMocks.sendTyping, + }; +}); + +import { WeixinChannel } from './WeixinAdapter.js'; +import type { + ChannelAgentBridge, + ChannelConfig, + ChannelTaskLifecycleEvent, +} from '@qwen-code/channel-base'; + +type LifecycleBase = Omit< + Extract, + 'type' +>; + +class TestWeixinChannel extends WeixinChannel { + emitLifecycle(event: ChannelTaskLifecycleEvent): void { + this.onTaskLifecycle(event); + } +} + +const config: ChannelConfig = { + type: 'weixin', + token: 'token', + senderPolicy: 'open', + allowedUsers: [], + sessionScope: 'user', + cwd: process.cwd(), + groupPolicy: 'disabled', + groups: {}, +}; + +function createChannel( + configOverrides: Partial = {}, +): TestWeixinChannel { + const bridge = Object.assign(new EventEmitter(), { + newSession: vi.fn(), + loadSession: vi.fn(), + prompt: vi.fn(), + cancelSession: vi.fn(), + availableCommands: [], + }); + + return new TestWeixinChannel( + 'weixin', + { ...config, ...configOverrides }, + bridge as unknown as ChannelAgentBridge, + ); +} + +function deferredPromise() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('WeixinChannel', () => { + beforeEach(() => { + apiMocks.getConfig.mockReset(); + apiMocks.sendTyping.mockReset(); + }); + + it('maps lifecycle start and terminal events to typing state', () => { + const channel = createChannel(); + const setTyping = vi.fn().mockResolvedValue(undefined); + (channel as unknown as { setTyping: typeof setTyping }).setTyping = + setTyping; + + const baseEvent = { + channelName: 'weixin', + chatId: 'user-1', + sessionId: 'session-1', + messageId: 'message-1', + identity: { id: 'channel:weixin', displayName: 'weixin' }, + memoryScope: { namespace: 'channel:weixin', mode: 'metadata-only' }, + } satisfies LifecycleBase; + + channel.emitLifecycle({ ...baseEvent, type: 'started' }); + channel.emitLifecycle({ ...baseEvent, type: 'started' }); + channel.emitLifecycle({ ...baseEvent, type: 'cancelled', reason: 'clear' }); + channel.emitLifecycle({ ...baseEvent, type: 'completed' }); + + expect(setTyping).toHaveBeenNthCalledWith(1, 'user-1', true); + expect(setTyping).toHaveBeenNthCalledWith(2, 'user-1', false); + expect(setTyping).toHaveBeenCalledTimes(2); + }); + + it('clears failed start typing state so a later started event can retry', async () => { + const channel = createChannel(); + const chatId = 'user-retry'; + const activeTypingChats = ( + channel as unknown as { activeTypingChats: Set } + ).activeTypingChats; + + apiMocks.getConfig.mockResolvedValue({ typing_ticket: 'ticket-1' }); + apiMocks.sendTyping + .mockRejectedValueOnce(new Error('send failed')) + .mockResolvedValueOnce({}); + + const baseEvent = { + channelName: 'weixin', + chatId, + sessionId: 'session-2', + messageId: 'message-2', + identity: { id: 'channel:weixin', displayName: 'weixin' }, + memoryScope: { namespace: 'channel:weixin', mode: 'metadata-only' }, + } satisfies LifecycleBase; + + channel.emitLifecycle({ ...baseEvent, type: 'started' }); + + await vi.waitFor(() => { + expect(apiMocks.sendTyping).toHaveBeenCalledTimes(1); + expect(activeTypingChats.has(chatId)).toBe(false); + }); + + channel.emitLifecycle({ ...baseEvent, type: 'started' }); + + await vi.waitFor(() => { + expect(apiMocks.sendTyping).toHaveBeenCalledTimes(2); + expect(activeTypingChats.has(chatId)).toBe(true); + }); + }); + + it('stops typing again when a late lifecycle start resolves after terminal cleanup', async () => { + const channel = createChannel(); + const start = deferredPromise(); + const setTyping = vi + .fn() + .mockReturnValueOnce(start.promise) + .mockResolvedValueOnce(true); + (channel as unknown as { setTyping: typeof setTyping }).setTyping = + setTyping; + + const baseEvent = { + channelName: 'weixin', + chatId: 'user-late-start', + sessionId: 'session-3', + messageId: 'message-3', + identity: { id: 'channel:weixin', displayName: 'weixin' }, + memoryScope: { namespace: 'channel:weixin', mode: 'metadata-only' }, + } satisfies LifecycleBase; + + channel.emitLifecycle({ ...baseEvent, type: 'started' }); + channel.emitLifecycle({ ...baseEvent, type: 'completed' }); + + expect(setTyping).toHaveBeenNthCalledWith(1, 'user-late-start', true); + expect(setTyping).toHaveBeenNthCalledWith(2, 'user-late-start', false); + + start.resolve(true); + + await vi.waitFor(() => { + expect(setTyping).toHaveBeenNthCalledWith(3, 'user-late-start', false); + expect(setTyping).toHaveBeenCalledTimes(3); + }); + }); + + it('clears active typing state on disconnect', () => { + const channel = createChannel(); + const setTyping = vi.fn().mockResolvedValue(true); + (channel as unknown as { setTyping: typeof setTyping }).setTyping = + setTyping; + const activeTypingChats = ( + channel as unknown as { activeTypingChats: Set } + ).activeTypingChats; + + channel.emitLifecycle({ + type: 'started', + channelName: 'weixin', + chatId: 'user-disconnect', + sessionId: 'session-4', + messageId: 'message-4', + identity: { id: 'channel:weixin', displayName: 'weixin' }, + memoryScope: { namespace: 'channel:weixin', mode: 'metadata-only' }, + }); + expect(activeTypingChats.has('user-disconnect')).toBe(true); + + channel.disconnect(); + + expect(activeTypingChats.has('user-disconnect')).toBe(false); + }); +}); diff --git a/packages/channels/weixin/src/WeixinAdapter.ts b/packages/channels/weixin/src/WeixinAdapter.ts index bb3172d0c0..40b4e66491 100644 --- a/packages/channels/weixin/src/WeixinAdapter.ts +++ b/packages/channels/weixin/src/WeixinAdapter.ts @@ -7,12 +7,16 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { randomUUID } from 'node:crypto'; import { basename, join } from 'node:path'; import { tmpdir } from 'node:os'; -import { ChannelBase } from '@qwen-code/channel-base'; +import { + ChannelBase, + isTerminalTaskLifecycleType, +} from '@qwen-code/channel-base'; import type { ChannelConfig, ChannelBaseOptions, Envelope, ChannelAgentBridge, + ChannelTaskLifecycleEvent, } from '@qwen-code/channel-base'; import { loadAccount, DEFAULT_BASE_URL } from './accounts.js'; import { startPollLoop, getContextToken } from './monitor.js'; @@ -32,6 +36,7 @@ function escapeRegex(s: string): string { export class WeixinChannel extends ChannelBase { private abortController: AbortController | null = null; + private activeTypingChats = new Set(); private baseUrl: string; private token: string = ''; @@ -129,11 +134,21 @@ export class WeixinChannel extends ChannelBase { } protected override onPromptStart(chatId: string): void { - this.setTyping(chatId, true).catch(() => {}); + this.startTyping(chatId); } protected override onPromptEnd(chatId: string): void { - this.setTyping(chatId, false).catch(() => {}); + this.stopTyping(chatId); + } + + protected override onTaskLifecycle(event: ChannelTaskLifecycleEvent): void { + if (event.type === 'started') { + this.startTyping(event.chatId); + return; + } + if (isTerminalTaskLifecycleType(event.type)) { + this.stopTyping(event.chatId); + } } private async handleInboundWithMedia( @@ -277,9 +292,35 @@ export class WeixinChannel extends ChannelBase { this.abortController.abort(); this.abortController = null; } + this.activeTypingChats.clear(); } - private async setTyping(userId: string, typing: boolean): Promise { + private startTyping(chatId: string): void { + if (this.activeTypingChats.has(chatId)) return; + this.activeTypingChats.add(chatId); + const controller = this.abortController; + void this.setTyping(chatId, true).then((started) => { + // Disconnect (or reconnect) raced the request — don't fire a + // post-disconnect setTyping(false). + if (controller !== this.abortController || controller?.signal.aborted) { + return; + } + if (!started) { + this.activeTypingChats.delete(chatId); + return; + } + if (!this.activeTypingChats.has(chatId)) { + void this.setTyping(chatId, false); + } + }); + } + + private stopTyping(chatId: string): void { + if (!this.activeTypingChats.delete(chatId)) return; + void this.setTyping(chatId, false); + } + + private async setTyping(userId: string, typing: boolean): Promise { try { let ticket = typingTickets.get(userId); if (!ticket) { @@ -295,15 +336,17 @@ export class WeixinChannel extends ChannelBase { typingTickets.set(userId, ticket); } } - if (!ticket) return; + if (!ticket) return false; await sendTyping(this.baseUrl, this.token, { ilink_user_id: userId, typing_ticket: ticket, status: typing ? TypingStatus.TYPING : TypingStatus.CANCEL, }); + return true; } catch { // Typing is best-effort — don't fail the message flow + return false; } } } diff --git a/packages/cli/src/commands/channel/config-utils.test.ts b/packages/cli/src/commands/channel/config-utils.test.ts index 98e4063e6c..c2c65165b6 100644 --- a/packages/cli/src/commands/channel/config-utils.test.ts +++ b/packages/cli/src/commands/channel/config-utils.test.ts @@ -167,6 +167,17 @@ describe('parseChannelConfig', () => { expect(result.groups).toEqual({ g1: { mentionKeywords: ['@bot'] } }); }); + it('drops empty identity and memory scope objects', async () => { + const result = await parseChannelConfig('bot', { + type: 'bare', + identity: { id: '', displayName: null, description: undefined }, + memoryScope: { namespace: '', mode: undefined }, + }); + + expect(result.identity).toBeUndefined(); + expect(result.memoryScope).toBeUndefined(); + }); + it('rejects a non-object identity', async () => { await expect( parseChannelConfig('bot', { type: 'bare', identity: 'ops' }), diff --git a/packages/cli/src/commands/channel/config-utils.ts b/packages/cli/src/commands/channel/config-utils.ts index b21ae7f0c6..763c85f994 100644 --- a/packages/cli/src/commands/channel/config-utils.ts +++ b/packages/cli/src/commands/channel/config-utils.ts @@ -69,7 +69,7 @@ function parseObjectStringFields( } result[field] = fieldValue; } - return result; + return Object.keys(result).length > 0 ? result : undefined; } function parseMemoryScopeConfig(