.qwen/plans/2026-07-01-channel-lifecycle-status-adapters.md
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Show task lifecycle status through Telegram, Weixin, DingTalk, and Feishu using each platform's existing native status surface.
Architecture: Keep the work adapter-local. P0 adds
ChannelTaskLifecycleEvent and onTaskLifecycle; this plan maps those events
to existing typing, reaction, and card paths without changing the shared channel
contract again. Because P0 still calls legacy prompt/streaming hooks, adapter
helpers must be idempotent and must not double-append streamed Feishu content.
Tech Stack: TypeScript, ESM, Vitest, existing channel packages under
packages/channels/*.
npx vitest run ....npm run build and npm run typecheck before submitting the PR.packages/channels/telegram/src/TelegramAdapter.ts: add lifecycle
mapping and idempotent typing helpers.packages/channels/telegram/src/TelegramAdapter.test.ts: add direct
lifecycle tests.packages/channels/weixin/src/WeixinAdapter.ts: add lifecycle mapping
and idempotent typing helpers.packages/channels/weixin/src/WeixinAdapter.test.ts: add
lifecycle typing tests if no adapter-level test already exists.packages/channels/dingtalk/src/DingtalkAdapter.ts: add lifecycle
mapping and idempotent reaction helpers.packages/channels/dingtalk/src/DingtalkAdapter.test.ts: add lifecycle
reaction tests.packages/channels/feishu/src/markdown.ts: add a minimal card status
label option.packages/channels/feishu/src/markdown.test.ts: test running and
terminal labels.packages/channels/feishu/src/FeishuAdapter.ts: store terminal state
from lifecycle and render explicit card labels.packages/channels/feishu/src/adapter.test.ts: test completed,
cancelled, and failed labels without double-streaming.Files:
.qwen/design/2026-07-01-channel-lifecycle-status-adapters.mdpackages/channels/base/src/types.tspackages/channels/base/src/ChannelBase.tsInterfaces:
Consumes: P0's exported ChannelTaskLifecycleEvent type.
Produces: a working branch where adapter packages can import
ChannelTaskLifecycleEvent from @qwen-code/channel-base.
Step 1: Verify P0 lifecycle exists
Run:
rg -n "ChannelTaskLifecycleEvent|onTaskLifecycle" packages/channels/base/src
Expected: packages/channels/base/src/types.ts defines
ChannelTaskLifecycleEvent, and packages/channels/base/src/ChannelBase.ts
defines protected onTaskLifecycle(...).
Run:
git branch --show-current
rg -n "ChannelTaskLifecycleEvent|onTaskLifecycle" packages/channels/base/src
Expected: the lifecycle symbols exist before any adapter code is edited. If they do not exist, switch to the P0 branch or wait for the P0 PR to merge, then rebase this feature branch on that base.
Run:
git status --short
Expected: no source changes from this task. Do not commit if the tree is clean.
Files:
packages/channels/telegram/src/TelegramAdapter.tspackages/channels/telegram/src/TelegramAdapter.test.tsInterfaces:
Consumes:
type ChannelTaskLifecycleEvent from @qwen-code/channel-base.
Produces:
TelegramChannel.onTaskLifecycle(event: ChannelTaskLifecycleEvent): void.
Step 1: Write failing lifecycle tests
In packages/channels/telegram/src/TelegramAdapter.test.ts, import the
lifecycle type and add a test helper:
import type {
ChannelAgentBridge,
ChannelConfig,
ChannelTaskLifecycleEvent,
Envelope,
} from '@qwen-code/channel-base';
class TestTelegramChannel extends TelegramChannel {
startTyping(chatId: string): void {
this.onPromptStart(chatId, 'session-1', 'message-1');
}
emitLifecycle(event: ChannelTaskLifecycleEvent): void {
this.onTaskLifecycle(event);
}
buildTestEnvelope(
msg: TestTelegramMessage,
text: string,
entities?: TestTelegramEntity[],
): Envelope {
return (
this as unknown as {
buildEnvelope: (
msg: TestTelegramMessage,
text: string,
entities?: TestTelegramEntity[],
) => Envelope;
}
).buildEnvelope(msg, text, entities);
}
}
Add this test:
it('maps lifecycle start and terminal events to typing', () => {
const channel = createChannel();
const bot = installFakeBot(channel);
const baseEvent = {
channelName: 'telegram',
chatId: 'chat-1',
sessionId: 'session-1',
messageId: 'message-1',
identity: { id: 'channel:telegram', displayName: 'telegram' },
memoryScope: { namespace: 'channel:telegram', mode: 'metadata-only' },
} satisfies Omit<ChannelTaskLifecycleEvent, 'type'>;
channel.emitLifecycle({ ...baseEvent, type: 'started' });
channel.emitLifecycle({ ...baseEvent, type: 'started' });
expect(bot.api.sendChatAction).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(4000);
expect(bot.api.sendChatAction).toHaveBeenCalledTimes(2);
channel.emitLifecycle({ ...baseEvent, type: 'completed' });
channel.emitLifecycle({ ...baseEvent, type: 'failed', error: 'boom' });
vi.advanceTimersByTime(4000);
expect(bot.api.sendChatAction).toHaveBeenCalledTimes(2);
});
Run:
cd packages/channels/telegram && npx vitest run src/TelegramAdapter.test.ts
Expected: fail because onTaskLifecycle is not implemented in Telegram.
In packages/channels/telegram/src/TelegramAdapter.ts, update the type import:
import type { ChannelTaskLifecycleEvent } from '@qwen-code/channel-base';
Replace the typing hook body with shared helpers:
private startTyping(chatId: string): void {
if (this.typingIntervals.has(chatId)) return;
const sendTyping = () =>
this.bot.api.sendChatAction(chatId, 'typing').catch(() => {});
sendTyping();
this.typingIntervals.set(chatId, setInterval(sendTyping, 4000));
}
private stopTyping(chatId: string): void {
const interval = this.typingIntervals.get(chatId);
if (!interval) return;
clearInterval(interval);
this.typingIntervals.delete(chatId);
}
protected override onTaskLifecycle(event: ChannelTaskLifecycleEvent): void {
if (event.type === 'started') {
this.startTyping(event.chatId);
return;
}
if (
event.type === 'completed' ||
event.type === 'cancelled' ||
event.type === 'failed'
) {
this.stopTyping(event.chatId);
}
}
protected override onPromptStart(chatId: string): void {
this.startTyping(chatId);
}
protected override onPromptEnd(chatId: string): void {
this.stopTyping(chatId);
}
Run:
cd packages/channels/telegram && npx vitest run src/TelegramAdapter.test.ts
Expected: pass.
Run:
git add packages/channels/telegram/src/TelegramAdapter.ts packages/channels/telegram/src/TelegramAdapter.test.ts
git commit -m "feat(channels): map telegram lifecycle to typing"
Files:
packages/channels/weixin/src/WeixinAdapter.tspackages/channels/weixin/src/WeixinAdapter.test.tsInterfaces:
Consumes:
type ChannelTaskLifecycleEvent from @qwen-code/channel-base.
Produces:
WeixinChannel.onTaskLifecycle(event: ChannelTaskLifecycleEvent): void.
Step 1: Write failing tests
If no adapter-level test exists, create
packages/channels/weixin/src/WeixinAdapter.test.ts with the local mocks needed
to instantiate WeixinChannel. Add a test-only subclass:
class TestWeixinChannel extends WeixinChannel {
emitLifecycle(event: ChannelTaskLifecycleEvent): void {
this.onTaskLifecycle(event);
}
}
Add the behavior test:
it('maps lifecycle start and terminal events to typing state', () => {
const channel = createChannel();
const setTyping = vi.fn().mockResolvedValue(undefined);
(channel as unknown as { setTyping: typeof setTyping }).setTyping =
setTyping;
const baseEvent = {
channelName: 'weixin',
chatId: 'user-1',
sessionId: 'session-1',
messageId: 'message-1',
identity: { id: 'channel:weixin', displayName: 'weixin' },
memoryScope: { namespace: 'channel:weixin', mode: 'metadata-only' },
} satisfies Omit<ChannelTaskLifecycleEvent, 'type'>;
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);
});
Run:
cd packages/channels/weixin && npx vitest run src/WeixinAdapter.test.ts
Expected: fail because the lifecycle hook is not implemented.
In packages/channels/weixin/src/WeixinAdapter.ts, import the lifecycle type and
add a per-chat active set:
import type { ChannelTaskLifecycleEvent } from '@qwen-code/channel-base';
private activeTypingChats = new Set<string>();
Replace prompt hook bodies with:
private startTyping(chatId: string): void {
if (this.activeTypingChats.has(chatId)) return;
this.activeTypingChats.add(chatId);
this.setTyping(chatId, true).catch(() => {
this.activeTypingChats.delete(chatId);
});
}
private stopTyping(chatId: string): void {
if (!this.activeTypingChats.delete(chatId)) return;
this.setTyping(chatId, false).catch(() => {});
}
protected override onTaskLifecycle(event: ChannelTaskLifecycleEvent): void {
if (event.type === 'started') {
this.startTyping(event.chatId);
return;
}
if (
event.type === 'completed' ||
event.type === 'cancelled' ||
event.type === 'failed'
) {
this.stopTyping(event.chatId);
}
}
protected override onPromptStart(chatId: string): void {
this.startTyping(chatId);
}
protected override onPromptEnd(chatId: string): void {
this.stopTyping(chatId);
}
Run:
cd packages/channels/weixin && npx vitest run src/WeixinAdapter.test.ts src/api.test.ts src/send.test.ts
Expected: pass.
Run:
git add packages/channels/weixin/src/WeixinAdapter.ts packages/channels/weixin/src/WeixinAdapter.test.ts
git commit -m "feat(channels): map weixin lifecycle to typing"
Files:
packages/channels/dingtalk/src/DingtalkAdapter.tspackages/channels/dingtalk/src/DingtalkAdapter.test.tsInterfaces:
Consumes:
type ChannelTaskLifecycleEvent from @qwen-code/channel-base.
Produces:
DingtalkChannel.onTaskLifecycle(event: ChannelTaskLifecycleEvent): void.
Step 1: Write failing lifecycle tests
In packages/channels/dingtalk/src/DingtalkAdapter.test.ts, import
ChannelTaskLifecycleEvent and add a lifecycle hook accessor:
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);
}
Add tests:
it('maps lifecycle start and terminal events to the eye reaction', () => {
const channel = createChannel();
const attachReaction = vi.fn().mockResolvedValue(undefined);
const recallReaction = vi.fn().mockResolvedValue(undefined);
(
channel as unknown as {
attachReaction: typeof attachReaction;
recallReaction: typeof recallReaction;
}
).attachReaction = attachReaction;
(
channel as unknown as {
attachReaction: typeof attachReaction;
recallReaction: typeof recallReaction;
}
).recallReaction = recallReaction;
const event = {
channelName: 'dingtalk',
chatId: 'cid-123',
sessionId: 'session-1',
messageId: 'message-1',
identity: { id: 'channel:dingtalk', displayName: 'dingtalk' },
memoryScope: { namespace: 'channel:dingtalk', mode: 'metadata-only' },
} satisfies Omit<ChannelTaskLifecycleEvent, 'type'>;
const lifecycle = getLifecycleHook(channel);
lifecycle({ ...event, type: 'started' });
lifecycle({ ...event, type: 'started' });
lifecycle({ ...event, type: 'failed', error: 'boom' });
lifecycle({ ...event, type: 'completed' });
expect(attachReaction).toHaveBeenCalledOnce();
expect(attachReaction).toHaveBeenCalledWith('message-1', 'cid-123');
expect(recallReaction).toHaveBeenCalledOnce();
expect(recallReaction).toHaveBeenCalledWith('message-1', 'cid-123');
});
it('does not attach lifecycle reactions without a conversation id', () => {
const channel = createChannel();
const attachReaction = vi.fn().mockResolvedValue(undefined);
(channel as unknown as { attachReaction: typeof attachReaction })
.attachReaction = attachReaction;
getLifecycleHook(channel)({
type: 'started',
channelName: 'dingtalk',
chatId: 'HTTPS://oapi.dingtalk.com/robot/send?access_token=token',
sessionId: 'session-1',
messageId: 'message-1',
identity: { id: 'channel:dingtalk', displayName: 'dingtalk' },
memoryScope: { namespace: 'channel:dingtalk', mode: 'metadata-only' },
});
expect(attachReaction).not.toHaveBeenCalled();
});
Run:
cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts
Expected: fail because lifecycle reactions are not implemented.
In packages/channels/dingtalk/src/DingtalkAdapter.ts, import the lifecycle type
and add a reaction key set:
import type { ChannelTaskLifecycleEvent } from '@qwen-code/channel-base';
private activeReactionKeys = new Set<string>();
Replace prompt hook bodies with helpers:
private reactionKey(messageId: string, conversationId: string): string {
return `${conversationId}:${messageId}`;
}
private startReaction(chatId: string, messageId?: string): void {
if (!messageId || !this.isConversationId(chatId)) return;
const key = this.reactionKey(messageId, chatId);
if (this.activeReactionKeys.has(key)) return;
this.activeReactionKeys.add(key);
this.attachReaction(messageId, chatId).catch(() => {
this.activeReactionKeys.delete(key);
});
}
private stopReaction(chatId: string, messageId?: string): void {
if (!messageId || !this.isConversationId(chatId)) return;
const key = this.reactionKey(messageId, chatId);
if (!this.activeReactionKeys.delete(key)) return;
this.recallReaction(messageId, chatId).catch(() => {});
}
protected override onTaskLifecycle(event: ChannelTaskLifecycleEvent): void {
if (event.type === 'started') {
this.startReaction(event.chatId, event.messageId);
return;
}
if (
event.type === 'completed' ||
event.type === 'cancelled' ||
event.type === 'failed'
) {
this.stopReaction(event.chatId, event.messageId);
}
}
protected override onPromptStart(
chatId: string,
_sessionId: string,
messageId?: string,
): void {
this.startReaction(chatId, messageId);
}
protected override onPromptEnd(
chatId: string,
_sessionId: string,
messageId?: string,
): void {
this.stopReaction(chatId, messageId);
}
Run:
cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts src/markdown.test.ts
Expected: pass.
Run:
git add packages/channels/dingtalk/src/DingtalkAdapter.ts packages/channels/dingtalk/src/DingtalkAdapter.test.ts
git commit -m "feat(channels): map dingtalk lifecycle to reactions"
Files:
packages/channels/feishu/src/markdown.tspackages/channels/feishu/src/markdown.test.tsInterfaces:
Produces:
buildCardContent(markdown, { statusLabel?: string }).
Step 1: Write failing markdown tests
In packages/channels/feishu/src/markdown.test.ts, add:
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);
});
Run:
cd packages/channels/feishu && npx vitest run src/markdown.test.ts
Expected: fail because statusLabel is not accepted.
In packages/channels/feishu/src/markdown.ts, extend the options object:
statusLabel?: string;
Replace the current content markdown calculation with:
const statusLabel =
options?.statusLabel ?? (options?.isStreaming ? '生成中...' : undefined);
const contentMd = statusLabel
? `${markdown}\n\n---\n*${statusLabel}*`
: markdown;
Run:
cd packages/channels/feishu && npx vitest run src/markdown.test.ts
Expected: pass.
Run:
git add packages/channels/feishu/src/markdown.ts packages/channels/feishu/src/markdown.test.ts
git commit -m "feat(channels): add feishu card status labels"
Files:
packages/channels/feishu/src/FeishuAdapter.tspackages/channels/feishu/src/adapter.test.tsInterfaces:
Consumes:
type ChannelTaskLifecycleEvent from @qwen-code/channel-base.
Consumes:
buildCardContent(markdown, { statusLabel?: string }) from Task 5.
Produces: explicit Feishu card labels for completed, cancelled, and failed states.
Step 1: Write failing adapter tests
In packages/channels/feishu/src/adapter.test.ts, add tests that patch
updateCard and call lifecycle directly:
it('records failed lifecycle state for prompt-end card finalization', async () => {
const channel = createChannel();
const cardSessions = getPrivateMethod<Map<string, unknown>>(
channel,
'cardSessions',
);
cardSessions.set('inbound_1', {
messageId: 'om_valid_message_id',
created: true,
creating: false,
stopped: false,
accumulatedText: 'partial answer',
lastUpdateAt: Date.now(),
});
const updateCard = vi.fn().mockResolvedValue(true);
(channel as unknown as { updateCard: typeof updateCard }).updateCard =
updateCard;
getPrivateMethod<(event: ChannelTaskLifecycleEvent) => void>(
channel,
'onTaskLifecycle',
).call(channel, {
type: 'failed',
channelName: 'feishu',
chatId: 'oc_chat_id',
sessionId: 'session_1',
messageId: 'inbound_1',
error: 'boom',
identity: { id: 'channel:feishu', displayName: 'feishu' },
memoryScope: { namespace: 'channel:feishu', mode: 'metadata-only' },
});
await getPrivateMethod<
(chatId: string, sessionId: string, messageId?: string) => Promise<void>
>(channel, 'onPromptEnd').call(
channel,
'oc_chat_id',
'session_1',
'inbound_1',
);
expect(updateCard.mock.calls[0]![1]).toContain('已失败,请重试');
});
Add the same shape for cancelled:
it('records cancelled lifecycle state for prompt-end card finalization', async () => {
const channel = createChannel();
const cardSessions = getPrivateMethod<Map<string, unknown>>(
channel,
'cardSessions',
);
cardSessions.set('inbound_1', {
messageId: 'om_valid_message_id',
created: true,
creating: false,
stopped: false,
accumulatedText: 'partial answer',
lastUpdateAt: Date.now(),
});
const updateCard = vi.fn().mockResolvedValue(true);
(channel as unknown as { updateCard: typeof updateCard }).updateCard =
updateCard;
getPrivateMethod<(event: ChannelTaskLifecycleEvent) => void>(
channel,
'onTaskLifecycle',
).call(channel, {
type: 'cancelled',
reason: 'cancel_command',
channelName: 'feishu',
chatId: 'oc_chat_id',
sessionId: 'session_1',
messageId: 'inbound_1',
identity: { id: 'channel:feishu', displayName: 'feishu' },
memoryScope: { namespace: 'channel:feishu', mode: 'metadata-only' },
});
await getPrivateMethod<
(chatId: string, sessionId: string, messageId?: string) => Promise<void>
>(channel, 'onPromptEnd').call(
channel,
'oc_chat_id',
'session_1',
'inbound_1',
);
expect(updateCard.mock.calls[0]![1]).toContain('已取消');
});
Add a completed test by mocking the final updateCard call in
onResponseComplete:
it('marks completed cards with the completed status label', async () => {
const channel = createChannel();
const sessionToInboundMsg = getPrivateMethod<Map<string, string>>(
channel,
'sessionToInboundMsg',
);
const cardSessions = getPrivateMethod<Map<string, unknown>>(
channel,
'cardSessions',
);
sessionToInboundMsg.set('session_1', 'inbound_1');
cardSessions.set('inbound_1', {
messageId: 'om_valid_message_id',
created: true,
creating: false,
stopped: false,
accumulatedText: 'answer',
lastUpdateAt: Date.now(),
});
const updateCard = vi.fn().mockResolvedValue(true);
(channel as unknown as { updateCard: typeof updateCard }).updateCard =
updateCard;
await getPrivateMethod<
(chatId: string, fullText: string, sessionId: string) => Promise<void>
>(channel, 'onResponseComplete').call(
channel,
'oc_chat_id',
'final answer',
'session_1',
);
expect(updateCard.mock.calls[0]![1]).toContain('已完成');
});
Run:
cd packages/channels/feishu && npx vitest run src/adapter.test.ts
Expected: fail because Feishu does not store lifecycle terminal state or render the new labels.
In packages/channels/feishu/src/FeishuAdapter.ts, import the lifecycle type and
extend CardSessionState:
import type { ChannelTaskLifecycleEvent } from '@qwen-code/channel-base';
type FeishuTerminalStatus = 'completed' | 'cancelled' | 'failed';
interface CardSessionState {
terminalStatus?: FeishuTerminalStatus;
}
If CardSessionState already exists, only add the terminalStatus property to
the existing interface.
Add this method to FeishuAdapter.ts:
protected override onTaskLifecycle(event: ChannelTaskLifecycleEvent): void {
if (
event.type !== 'completed' &&
event.type !== 'cancelled' &&
event.type !== 'failed'
) {
return;
}
const inboundMsgId =
event.messageId || this.sessionToInboundMsg.get(event.sessionId);
if (!inboundMsgId) return;
const cardState = this.cardSessions.get(inboundMsgId);
if (!cardState) return;
cardState.terminalStatus = event.type;
}
Do not process text_chunk in onTaskLifecycle in this task. The base channel
still calls onResponseChunk immediately after emitting the lifecycle chunk, so
handling both paths would duplicate Feishu card content.
Add a helper:
private statusLabelFor(terminalStatus?: FeishuTerminalStatus): string {
switch (terminalStatus) {
case 'completed':
return '已完成';
case 'cancelled':
return '已取消';
case 'failed':
return '已失败,请重试';
default:
return '运行中...';
}
}
Update createStreamingCard and non-final updateCard calls to use the running
label:
const card = buildCardContent(text, {
title: cardTitle,
showStopButton: true,
isStreaming: true,
statusLabel: this.statusLabelFor(),
collapsible: this.collapsible,
collapsibleThreshold: this.collapsibleThreshold,
});
Update updateCard so final calls can pass a terminal label:
private async updateCard(
messageId: string,
text: string,
finished = false,
inboundMsgId?: string,
statusLabel?: string,
): Promise<boolean> {
const card = buildCardContent(text, {
title: cardTitle,
showStopButton: !finished,
isStreaming: !finished,
statusLabel,
collapsible: this.collapsible,
collapsibleThreshold: this.collapsibleThreshold,
});
}
When onResponseComplete finalizes a card, pass the completed label:
await this.updateCard(
cardState.messageId,
`${displayText}\n\n---\n*${this.statusLabelFor('completed')}*`,
true,
inboundMsgId,
);
When onPromptEnd finalizes a failed or cancelled card, use the stored terminal
state:
const terminalStatus = cs.terminalStatus || 'failed';
const terminalLabel = this.statusLabelFor(terminalStatus);
const text = cs.accumulatedText
? (atPrefix
? `${atPrefix}\n\n${cs.accumulatedText}`
: cs.accumulatedText) +
'\n\n---\n' +
`*${terminalLabel}*`
: (atPrefix ? `${atPrefix}\n\n` : '') + `*${terminalLabel}*`;
Do not append the label both in text and through statusLabel on the same
call. Use the existing text-append style in onPromptEnd and use
statusLabel for normal card builder paths.
Run:
cd packages/channels/feishu && npx vitest run src/markdown.test.ts src/adapter.test.ts
Expected: pass.
Run:
git add packages/channels/feishu/src/markdown.ts packages/channels/feishu/src/markdown.test.ts packages/channels/feishu/src/FeishuAdapter.ts packages/channels/feishu/src/adapter.test.ts
git commit -m "feat(channels): show feishu lifecycle card status"
Files:
.github/pull_request_template.md.qwen/pr-drafts/channel-lifecycle-status-adapters.mdInterfaces:
Consumes: all prior adapter commits.
Produces: verified branch ready for PR.
Step 1: Run focused channel tests
Run:
cd packages/channels/telegram && npx vitest run src/TelegramAdapter.test.ts
cd packages/channels/weixin && npx vitest run src/WeixinAdapter.test.ts src/api.test.ts src/send.test.ts
cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts src/markdown.test.ts
cd packages/channels/feishu && npx vitest run src/markdown.test.ts src/adapter.test.ts
Expected: all pass.
Run:
npm run build
npm run typecheck
Expected: both pass.
Run:
git status --short
git diff --stat main...HEAD
Expected: only the design/plan and four channel adapter areas changed.
Use .github/pull_request_template.md. Keep the description prose-based and do
not hard-wrap paragraphs. The reviewer test plan should say:
## Reviewer Test Plan
- Verify Telegram shows typing while a task is running and clears typing when it completes, is cancelled, or fails.
- Verify Weixin sends typing true while a task is running and typing false for completed, cancelled, and failed terminal states.
- Verify DingTalk keeps the existing eye reaction behavior: attach while running and recall on completed, cancelled, or failed, with no terminal emoji.
- Verify Feishu cards show running, completed, cancelled, and failed labels without duplicating streamed content.
Run:
git push -u origin feat/channel-lifecycle-status-adapters
gh pr create --fill
Expected: PR opens against the repository default branch.
text_chunk lifecycle is intentionally not consumed
directly because the base still calls the legacy onResponseChunk hook in the
same path. This prevents duplicate card content while preserving existing
streaming behavior.