mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
feat(channels): show lifecycle status in adapters (#6114)
* 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 <qwen-coder@alibabacloud.com> * 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 <qwen-coder@alibabacloud.com> * 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 <qwen-coder@alibabacloud.com> * 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 <qwen-coder@alibabacloud.com> * 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 <qwen-coder@alibabacloud.com> * 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 <qwen-coder@alibabacloud.com> * 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 <shaojin.wensj@alibaba-inc.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
e1e99f76ff
commit
6509e8de08
23 changed files with 3918 additions and 167 deletions
149
.qwen/design/2026-07-01-channel-lifecycle-status-adapters.md
Normal file
149
.qwen/design/2026-07-01-channel-lifecycle-status-adapters.md
Normal file
|
|
@ -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.
|
||||
42
.qwen/design/2026-07-01-channel-lifecycle-status-umbrella.md
Normal file
42
.qwen/design/2026-07-01-channel-lifecycle-status-umbrella.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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`
|
||||
1094
.qwen/plans/2026-07-01-channel-lifecycle-status-adapters.md
Normal file
1094
.qwen/plans/2026-07-01-channel-lifecycle-status-adapters.md
Normal file
File diff suppressed because it is too large
Load diff
30
.qwen/plans/2026-07-01-channel-lifecycle-status-umbrella.md
Normal file
30
.qwen/plans/2026-07-01-channel-lifecycle-status-umbrella.md
Normal file
|
|
@ -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`.
|
||||
|
|
@ -67,6 +67,10 @@ class TestChannel extends ChannelBase {
|
|||
this.registerCancelCommand();
|
||||
}
|
||||
|
||||
cancelPromptForTest(sessionId: string): Promise<boolean> {
|
||||
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<string>((_resolve, reject) => {
|
||||
rejectPrompt = reject;
|
||||
});
|
||||
(bridge.prompt as ReturnType<typeof vi.fn>).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<string>((resolve) => {
|
||||
resolvePrompt = resolve;
|
||||
});
|
||||
let resolveCancel!: () => void;
|
||||
const pendingCancel = new Promise<void>((resolve) => {
|
||||
resolveCancel = resolve;
|
||||
});
|
||||
(bridge.prompt as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
pendingPrompt,
|
||||
);
|
||||
(bridge.cancelSession as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<string>((resolve) => {
|
||||
resolvePrompt = resolve;
|
||||
});
|
||||
(bridge.prompt as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mock
|
||||
.calls[0]![0] as string;
|
||||
const maps = ch as unknown as {
|
||||
collectBuffers: Map<string, unknown>;
|
||||
};
|
||||
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<string>((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<string>((_resolve, reject) => {
|
||||
rejectPrompt = reject;
|
||||
});
|
||||
let rejectCancel!: (err: Error) => void;
|
||||
const pendingCancel = new Promise<void>((_resolve, reject) => {
|
||||
rejectCancel = reject;
|
||||
});
|
||||
(bridge.prompt as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
pendingPrompt,
|
||||
);
|
||||
(bridge.cancelSession as ReturnType<typeof vi.fn>).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<string>((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<typeof vi.fn>).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<string>((_resolve, reject) => {
|
||||
rejectPrompt = reject;
|
||||
});
|
||||
let rejectCancel!: (err: Error) => void;
|
||||
const pendingCancel = new Promise<void>((_resolve, reject) => {
|
||||
rejectCancel = reject;
|
||||
});
|
||||
(bridge.prompt as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
pendingPrompt,
|
||||
);
|
||||
(bridge.cancelSession as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
||||
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<void> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export {
|
|||
sanitizePromptText,
|
||||
sanitizeLogText,
|
||||
} from './sanitize.js';
|
||||
export { isTerminalTaskLifecycleType } from './types.js';
|
||||
export type {
|
||||
Attachment,
|
||||
BlockStreamingChunkConfig,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<ChannelTaskLifecycleEvent, { type: 'started' }>,
|
||||
'type'
|
||||
>;
|
||||
|
||||
const dingtalkSdkMock = vi.hoisted(() => ({
|
||||
instances: [] as unknown[],
|
||||
|
|
@ -48,6 +56,7 @@ vi.mock('@qwen-code/channel-base', async () => {
|
|||
protected config: Record<string, unknown>;
|
||||
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<string, unknown>)[
|
||||
'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<string> }
|
||||
).inboundMessageIds.add(messageId);
|
||||
}
|
||||
|
||||
function deferredPromise<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((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<void>();
|
||||
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<string> }
|
||||
).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<string> }
|
||||
).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<string> }
|
||||
).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', () => {
|
||||
|
|
|
|||
|
|
@ -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<typeof setInterval>;
|
||||
/** Map conversationId → latest sessionWebhook URL for sending replies. */
|
||||
private webhooks: Map<string, string> = new Map();
|
||||
private activeReactionKeys = new Set<string>();
|
||||
/** sessionId → reaction keys, so a dead session's reactions can be recalled. */
|
||||
private sessionReactionKeys = new Map<
|
||||
string,
|
||||
Map<string, { messageId: string; chatId: string }>
|
||||
>();
|
||||
/**
|
||||
* 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<string>();
|
||||
/**
|
||||
* 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';
|
||||
|
|
|
|||
|
|
@ -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<FeishuTerminalStatus, string> = {
|
||||
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 "好的,<at id=xxx></at>\n\n"
|
||||
text = text.replace(/^好的,<at[^>]*><\/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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Record<string, unknown>> = [];
|
||||
|
||||
// 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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
protected onTaskLifecycle(_event: unknown): void {}
|
||||
},
|
||||
SessionRouter: class {
|
||||
restoreSessions(): Promise<void> {
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -3,9 +3,15 @@ import { TelegramChannel } from './TelegramAdapter.js';
|
|||
import type {
|
||||
ChannelAgentBridge,
|
||||
ChannelConfig,
|
||||
ChannelTaskLifecycleEvent,
|
||||
Envelope,
|
||||
} from '@qwen-code/channel-base';
|
||||
|
||||
type LifecycleBase = Omit<
|
||||
Extract<ChannelTaskLifecycleEvent, { type: 'started' }>,
|
||||
'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<LifecycleBase, 'sessionId'>;
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<string, ReturnType<typeof setInterval>>();
|
||||
private activeTypingSessions = new Map<string, Set<string>>();
|
||||
|
||||
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<void> {
|
||||
|
|
@ -338,6 +384,7 @@ export class TelegramChannel extends ChannelBase {
|
|||
clearInterval(interval);
|
||||
}
|
||||
this.typingIntervals.clear();
|
||||
this.activeTypingSessions.clear();
|
||||
this.bot.stop();
|
||||
}
|
||||
|
||||
|
|
|
|||
198
packages/channels/weixin/src/WeixinAdapter.test.ts
Normal file
198
packages/channels/weixin/src/WeixinAdapter.test.ts
Normal file
|
|
@ -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<typeof import('./api.js')>('./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<ChannelTaskLifecycleEvent, { type: 'started' }>,
|
||||
'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<ChannelConfig> = {},
|
||||
): 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<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((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<string> }
|
||||
).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<boolean>();
|
||||
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<string> }
|
||||
).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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string>();
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ function parseObjectStringFields<Field extends string>(
|
|||
}
|
||||
result[field] = fieldValue;
|
||||
}
|
||||
return result;
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function parseMemoryScopeConfig(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue