diff --git a/docs/design/dingtalk-dynamic-lifecycle-tags.md b/docs/design/dingtalk-dynamic-lifecycle-tags.md new file mode 100644 index 00000000000..4bff5570051 --- /dev/null +++ b/docs/design/dingtalk-dynamic-lifecycle-tags.md @@ -0,0 +1,52 @@ +# DingTalk dynamic lifecycle tags + +## Goal + +Expose agent progress consistently on both the inbound DingTalk message and the interactive response card without changing the card template or exposing raw tool input, output, or reasoning. + +## Lifecycle + +- Start with two tags: `👀` and `🤔 Thinking`. +- Keep `👀` fixed while replacing only the status tag. +- Map tool events to `📖 Reading`, `🔎 Searching`, `🖥️ Running`, `🛠️ Editing`, `🛠️ Working`, or `⚠️ Retrying`. +- Map response text to `✍️ Replying`. +- On a terminal event, recall both transient tags before adding exactly one of `✅ Done`, `❌ Failed`, or `⏹️ Stopped`. + +Reaction operations for one inbound message use a desired-state drain. A newer phase overwrites any pending phase, and a terminal event preempts every phase that has not reached DingTalk yet. An already in-flight API request cannot be cancelled, so the drain re-reads desired state after each response and removes any obsolete tag before settling. If a status recall fails, the replacement is skipped to avoid stacking contradictory statuses. + +The same lifecycle mapping drives a replaceable first line in the interactive response card body. A newly created card starts at `🤔 Thinking`; tool activity replaces that line with the mapped phase before any response text exists, and the first response chunk changes it to `✍️ Replying`. While a response streams, its content appears below that phase line. Duplicate phase events are coalesced. + +## ACP tool-kind projection contract + +| ACP kind | Phase | English | Chinese | +| ------------------ | ----------- | ------------------- | --------------- | +| `read` | `reading` | `📖 Reading` | `📖 读取中` | +| `edit` | `editing` | `🛠️ Editing` | `🛠️ 编辑中` | +| `delete` | `deleting` | `🗑️ Deleting` | `🗑️ 删除中` | +| `move` | `moving` | `📦 Moving` | `📦 移动中` | +| `search` | `searching` | `🔎 Searching` | `🔎 搜索中` | +| `execute` | `running` | `🖥️ Running` | `🖥️ 执行中` | +| `think` | `thinking` | `🤔 Thinking` | `🤔 思考中` | +| `fetch` | `fetching` | `🌐 Fetching` | `🌐 获取中` | +| `switch_mode` | `switching` | `🔄 Switching mode` | `🔄 切换模式中` | +| `other` or unknown | `working` | `🛠️ Working` | `🛠️ 处理中` | + +The projection first exactly matches standard ACP kinds, then uses legacy and third-party Bridge aliases. `other` remains the final fallback. Reactions and active card bodies use the same localized phase label. Distinguishing ACP `Agent` from `Other` requires new protocol metadata; the current protocol normalizes both to `other`. + +The running card displays only the allowlisted phase label. ACP tool titles are not projected because built-in tools may derive them from commands, paths, or parameters. Reactions remain phase-only. The local bridge retains the safe tool kind from the initial event so kindless terminal updates can drive `Retrying` or return to `Thinking`; meta-only shell-progress heartbeats remain ignored and do not create another response boundary. + +The running card's `statusLine` contains only the configured model and elapsed time. On completion, the process line is removed from the body so only the final assistant response remains; the existing terminal state, model, and elapsed time stay in `statusLine`. Tool descriptions, paths, commands, parameters, raw input, raw output, and model reasoning are never added to card content. + +Phase and terminal labels use the effective Qwen display language after environment override, configured-language selection, and `auto` system-language detection. Presentation language never changes the agent prompt or tool-call schema. + +When named-task attribution supplies a source label, the phase remains the first running-state line and the escaped source label stays above the response content throughout running, streaming, fallback, and terminal card states. + +## Delivery modes + +Lifecycle presentation is driven by channel lifecycle events, independently of response delivery. Plain replies, interactive status cards, and block-streaming cards therefore share the same inbound-message tag behavior. Interactive cards also project the current phase into the body; block streaming does not create a status card and continues to rely on the inbound-message tags for progress. + +Reaction failures and status-card metadata failures are isolated from each other and from response delivery. + +## Cleanup + +Prompt cleanup, session death, and adapter disconnect recall both transient tags without adding a terminal result when the real outcome is unknown. diff --git a/docs/plans/2026-08-31-dingtalk-lifecycle-delivery-convergence.md b/docs/plans/2026-08-31-dingtalk-lifecycle-delivery-convergence.md new file mode 100644 index 00000000000..08bd25575ab --- /dev/null +++ b/docs/plans/2026-08-31-dingtalk-lifecycle-delivery-convergence.md @@ -0,0 +1,280 @@ +# DingTalk Lifecycle Delivery Convergence Implementation Plan + +> Historical plan: later product review approved a bounded tool-title summary in the active card and a minimal `AcpBridge` partial-update fix. The final contract is `docs/design/dingtalk-dynamic-lifecycle-tags.md`; the phase-only steps below preserve the original implementation sequence rather than the final scope. + +> **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:** Deliver the approved phase-only DingTalk lifecycle prototype without exposing tool details, accumulating stale reactions, mis-resolving `auto` language, or dropping current-main source attribution. + +**Architecture:** Keep lifecycle classification in the DingTalk adapter and project only a finite phase enum into reactions and the interactive card. Replace per-event reaction queueing with one desired-state drain per inbound message, where new phases overwrite pending phases and terminal states preempt them. Resolve the effective CLI language before constructing any channel, then merge current `origin/main` and preserve its source-label contract. + +**Tech Stack:** TypeScript, Vitest, DingTalk emotion API, DingTalk interactive cards, Qwen CLI i18n. + +**Spec:** `docs/design/dingtalk-dynamic-lifecycle-tags.md` + +## Global Constraints + +- The UI may display lifecycle phase labels and assistant response text only; it must not display tool title, description, path, command, parameters, output, or reasoning. +- `👀` remains stable while a single replaceable phase reaction is active. +- Phase updates are latest-wins; `completed`, `failed`, and `cancelled` preempt pending phases. +- A failed recall prevents a contradictory replacement from being attached. +- Status cards are created on `started`, stream response text below the phase, and remove the phase at terminal completion. +- Preserve current-main named-task source labels above response content in running, streaming, fallback, and terminal cards while keeping the lifecycle phase first during active runs. +- Do not change the DingTalk card template or add dependencies. + +--- + +### Task 1: Enforce the phase-only presentation boundary + +**Files:** + +- Modify: `packages/channels/base/src/ChannelBase.test.ts` +- Modify: `packages/channels/base/src/ChannelBase.ts` +- Modify: `packages/channels/base/src/types.ts` +- Modify: `packages/channels/dingtalk/src/presentation-phase.ts` +- Modify: `packages/channels/dingtalk/src/DingtalkAdapter.test.ts` +- Modify: `packages/channels/dingtalk/src/DingtalkAdapter.ts` +- Modify: `packages/channels/dingtalk/src/interaction-presenter.ts` +- Modify: `packages/channels/dingtalk/src/status-card-controller.test.ts` +- Modify: `packages/channels/dingtalk/src/status-card-controller.ts` + +**Interfaces:** + +- Consumes: `ChannelTaskLifecycleEvent` with sanitized `kind`, `title`, and `status`. +- Produces: `lifecyclePresentationPhase(event): DingtalkPresentationPhase | undefined`; no tool-detail presentation type or card-detail API. + +- [ ] **Step 1: Change the ChannelBase regression test so raw input, including `rawInput.description`, is absent from emitted lifecycle events while the adapter-owned tool callback still receives the original event.** + +```ts +expect(lifecycleToolCall!.toolCall).not.toHaveProperty('description'); +expect(lifecycleToolCall!.toolCall).not.toHaveProperty('rawInput'); +expect(ch.toolCalls[0]!.event.rawInput).toEqual({ + command: 'echo $SECRET', + description: 'Check disk health\nwithout exposing commands', +}); +``` + +- [ ] **Step 2: Run the focused ChannelBase test and verify RED because the current sanitizer emits `description`.** + +Run: `cd packages/channels/base && npx vitest run src/ChannelBase.test.ts -t "raw tool input"` + +Expected: FAIL showing the lifecycle event still contains `description`. + +- [ ] **Step 3: Change DingTalk tests to send a tool event whose title and description contain sensitive literals, then assert the card update receives only the mapped phase and card content contains none of those literals.** + +```ts +expect(updateStatusCardPhase).toHaveBeenCalledWith('run-1', 'running'); +expect(updateStatusCardTool).not.toBeDefined(); +expect(streamedContent).toBe('🖥️ 执行中'); +expect(streamedContent).not.toContain('/private/project'); +expect(streamedContent).not.toContain('grep SECRET'); +``` + +- [ ] **Step 4: Run the focused DingTalk tests and verify RED because the current tool-detail path is called and rendered.** + +Run: `cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts src/status-card-controller.test.ts -t "phase|detail|card body"` + +Expected: FAIL showing `updateStatusCardTool` or a Markdown detail bullet. + +- [ ] **Step 5: Remove `SanitizedToolCallEvent.description`, the `rawInput.description` extraction, `DingtalkToolPresentation`, `lifecycleToolPresentation`, `updateStatusCardTool`, status-card detail collections, and the DingTalk prompt instruction that asks the model to produce descriptions. Route every tool event through `lifecyclePresentationPhase` only.** + +```ts +const presentationPhase = lifecyclePresentationPhase(event); +if (event.runId && presentationPhase) { + this.interactionPresenter?.updateStatusCardPhase( + event.runId, + presentationPhase, + ); +} +``` + +- [ ] **Step 6: Run the three focused test files and verify GREEN.** + +Run: `cd packages/channels/base && npx vitest run src/ChannelBase.test.ts && cd ../../dingtalk && npx vitest run src/DingtalkAdapter.test.ts src/status-card-controller.test.ts` + +Expected: all tests pass with no sensitive literal in any card payload assertion. + +### Task 2: Coalesce reaction transitions and preempt with terminal state + +**Files:** + +- Modify: `packages/channels/dingtalk/src/DingtalkAdapter.test.ts` +- Modify: `packages/channels/dingtalk/src/DingtalkAdapter.ts` + +**Interfaces:** + +- Consumes: phase and terminal lifecycle events for one stable inbound message. +- Produces: one reaction drain whose mutable desired phase is overwritten by newer phases and whose terminal request has priority. + +- [ ] **Step 1: Add a failing latest-wins test that blocks a phase recall, emits `searching`, `running`, and `replying`, releases the recall, and expects only `replying` to be attached.** + +```ts +expect(attachReaction.mock.calls.map(([, , tag]) => tag.name)).toEqual([ + '👀', + '🤔 Thinking', + '✍️ Replying', +]); +``` + +- [ ] **Step 2: Run that test and verify RED because the existing `tail` enqueues and attaches every intermediate phase.** + +Run: `cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts -t "latest phase"` + +Expected: FAIL with intermediate `Searching` and `Running` attachments. + +- [ ] **Step 3: Add a failing terminal-preemption test that blocks the initial eye attach, emits multiple phases and `cancelled`, releases the attach, and expects no transient status attachment before `⏹️ Stopped`.** + +```ts +expect(attachReaction.mock.calls.map(([, , tag]) => tag.name)).toEqual([ + '👀', + '⏹️ Stopped', +]); +``` + +- [ ] **Step 4: Run that test and verify RED because the queued start and phase operations currently run before terminal cleanup.** + +Run: `cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts -t "terminal preempts"` + +Expected: FAIL with obsolete transient attachments. + +- [ ] **Step 5: Replace per-phase `enqueueReaction` calls with mutable state (`desiredStatusTag`, `terminalTag`, `drainScheduled`) and a single `drainReactionState` loop. Re-read desired state after each awaited recall and before each attach; if terminal is set, clear the current status and eye, attach exactly the terminal tag, then forget the state.** + +```ts +state.desiredStatusTag = tag; +this.scheduleReactionDrain(state); + +while (this.reactionStates.get(state.key) === state) { + if (state.terminalTag) { + await this.finishReactionState(state); + return; + } + const desired = state.desiredStatusTag; + if (!desired || desired.name === state.statusTag?.name) return; + if (state.statusTag && !(await this.recallReaction(...))) return; + if (state.terminalTag) continue; + const latest = state.desiredStatusTag; + if (latest && (await this.attachReaction(..., latest)) !== false) { + state.statusTag = latest; + } +} +``` + +- [ ] **Step 6: Run all DingTalk adapter tests and verify GREEN, including recall-failure and disconnect/session-death cleanup cases.** + +Run: `cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts` + +Expected: all adapter tests pass and no test observes an obsolete terminal-delayed phase. + +### Task 3: Resolve the effective display language at channel construction + +**Files:** + +- Modify: `packages/cli/src/i18n/index.ts` +- Modify: `packages/cli/src/commands/channel/daemon-worker.test.ts` +- Modify: `packages/cli/src/commands/channel/daemon-worker.ts` +- Modify: `packages/cli/src/commands/channel/start.test.ts` +- Modify: `packages/cli/src/commands/channel/start.ts` + +**Interfaces:** + +- Consumes: `QWEN_CODE_LANG`, `general.language`, and system locale. +- Produces: resolved `SupportedLanguage` in `ChannelBaseOptions.displayLanguage`; never the literal `auto`. + +- [ ] **Step 1: Add failing command tests for `general.language: 'auto'`, mocking system detection to `zh`, and assert `displayLanguage: 'zh'`; add an env-precedence case where `QWEN_CODE_LANG=zh` overrides an English setting.** + +```ts +expect(createChannel).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({ displayLanguage: 'zh' }), +); +``` + +- [ ] **Step 2: Run the two command test files and verify RED because the current code forwards `auto` or the raw setting.** + +Run: `cd packages/cli && npx vitest run src/commands/channel/start.test.ts src/commands/channel/daemon-worker.test.ts` + +Expected: FAIL with `displayLanguage: 'auto'` or the lower-priority setting. + +- [ ] **Step 3: Export the existing `resolveLanguage` helper and pass `resolveLanguage(resolveLanguageSetting(configuredLanguage))` from both direct-start and daemon-worker entry points.** + +```ts +const displayLanguage = resolveLanguage( + resolveLanguageSetting(settings.merged.general?.language as string), +); +``` + +- [ ] **Step 4: Run the i18n and command tests and verify GREEN.** + +Run: `cd packages/cli && npx vitest run src/i18n/index.test.ts src/commands/channel/start.test.ts src/commands/channel/daemon-worker.test.ts` + +Expected: all tests pass with explicit, auto-detected, and environment-overridden language cases. + +### Task 4: Integrate current main without regressing source labels + +**Files:** + +- Modify on merge conflict: `packages/channels/dingtalk/src/interaction-presenter.ts` +- Modify on merge conflict: `packages/channels/dingtalk/src/interaction-presenter.test.ts` +- Modify on merge conflict if required: `packages/channels/dingtalk/src/DingtalkAdapter.ts` +- Modify: `docs/design/dingtalk-dynamic-lifecycle-tags.md` + +**Interfaces:** + +- Consumes: current-main `registerRun(..., sourceLabel?)` and `getResponseSourceLabel(sessionId)`. +- Produces: source-prefixed lifecycle card content plus the approved localized phase labels. + +- [ ] **Step 1: Commit the green convergence changes so the dirty worktree is recoverable, then merge `origin/main` without rewriting published history.** + +Run: `git merge --no-edit origin/main` + +Expected: the known interaction-presenter conflict is surfaced for explicit resolution. + +- [ ] **Step 2: Resolve the conflict by retaining the sixth `sourceLabel` argument and `withSourcePrefix` behavior from current main while retaining `updateStatusCardPhase` and phase-first active content.** + +```ts +this.interactionPresenter?.registerRun( + event.runId, + event.owner.id, + inboundOwner.target, + event.sessionId, + inboundOwner.sender, + this.getResponseSourceLabel(event.sessionId), +); +``` + +- [ ] **Step 3: Update the design to state phase-only safety, desired-state coalescing, terminal preemption, effective language resolution, and source-label composition.** + +- [ ] **Step 4: Run the focused DingTalk presenter/controller/adapter tests and verify GREEN after the merge.** + +Run: `cd packages/channels/dingtalk && npx vitest run src/interaction-presenter.test.ts src/status-card-controller.test.ts src/DingtalkAdapter.test.ts` + +Expected: all tests pass, including source labels through running, streaming, and terminal cards. + +### Task 5: Verify and deliver the existing Draft PR + +**Files:** + +- Update remote PR: `https://github.com/QwenLM/qwen-code/pull/10504` + +**Interfaces:** + +- Consumes: the final merged worktree and repository PR template. +- Produces: pushed branch, current PR body, CI/readback evidence, and an explicit live-E2E evidence boundary. + +- [ ] **Step 1: Format changed files, run `git diff --check`, and run focused tests, build, typecheck, and lint from the final tree.** + +Run: `npx prettier --check && git diff --check && npm run build && npm run typecheck && npm run lint` + +Expected: every command exits 0. + +- [ ] **Step 2: Perform the repository self-audit over the complete `origin/main...HEAD` diff until two consecutive passes find no issue; any fix resets the clean-pass count and reruns verification.** + +- [ ] **Step 3: Request an independent code review against exact final SHAs and fix every Critical or Important finding before proceeding.** + +- [ ] **Step 4: Push the branch normally, update the Draft PR body from `.github/pull_request_template.md`, and read back the PR head, body, checks, and mergeability with `gh`.** + +- [ ] **Step 5: Attempt live DingTalk E2E only if valid credentials are available without exposing them. If DingTalk returns credential error `40096`, record live delivery as unverified rather than treating local/loopback evidence as equivalent.** + +- [ ] **Step 6: Report the exact delivered state, remaining external gates (review/CI/live credentials), and preserve the worktree for PR iteration.** diff --git a/docs/plans/2026-09-01-dingtalk-granular-kind-phases.md b/docs/plans/2026-09-01-dingtalk-granular-kind-phases.md new file mode 100644 index 00000000000..d16799010a8 --- /dev/null +++ b/docs/plans/2026-09-01-dingtalk-granular-kind-phases.md @@ -0,0 +1,414 @@ +# 钉钉工具类型精细阶段实施计划 + +> 状态说明:后续产品评审批准了在活动卡片阶段行展示受限的工具 title,并要求最小化修复 `AcpBridge` partial heartbeat 回归。最终契约以 `docs/design/dingtalk-dynamic-lifecycle-tags.md` 为准;下文保留最初的分步计划和当时的范围判断,不能作为当前实现边界。 + +> **供执行 Agent 使用:** 必须使用 `superpowers:subagent-driven-development`(推荐)或 `superpowers:executing-plans`,逐项执行本计划。所有步骤使用复选框(`- [ ]`)跟踪进度。 + +**目标:** 将钉钉对标准 ACP 初始 `tool_call` 工具类型的粗粒度兜底展示改为精确、本地化的生命周期阶段,同时保留现有的最新状态优先机制和旧 Bridge 兼容性。 + +**架构:** 分类逻辑继续留在钉钉展示层。先精确匹配封闭的 ACP `ToolKind` 值,仅对第三方或旧版 Bridge 保留当前正则兼容逻辑;得到有限的阶段枚举后,继续复用现有 reaction 和互动卡片投影链路。 + +**技术栈:** TypeScript、Vitest、钉钉互动卡片和消息 reaction、Agent Client Protocol 工具调用生命周期事件。 + +**设计文档:** `docs/design/dingtalk-dynamic-lifecycle-tags.md` + +## 全局约束 + +- 本次作为独立的后续变更,不扩大已经评审收敛的生命周期交付 PR。 +- 不修改 Provider 流式响应、模型行为、ACP Schema、`ChannelTaskLifecycleEvent`、`ChannelBase.ts`、`AcpBridge.ts` 生产代码或跨包接口。 +- 本版本不改变工具 title 的展示策略,也不展示 description、路径、命令、参数、输出或模型推理。 +- 保留最新阶段优先、终态抢占、来源标签组合和状态卡终态收敛行为。 +- 保留现有正则兼容逻辑所支持的旧版和第三方 Bridge 输入。 +- Core `Kind.Agent` 会被标准化为 ACP `other`,因此本版本不区分 `Agent` 和 `Other`,两者仍映射为 `working`。 +- 有效展示语言以 `zh` 开头时使用中文标签,否则使用英文标签。 +- 除设计文档和被 Git 忽略的 E2E 报告外,实现改动仅限 `packages/channels/dingtalk`。 + +## 信号可见性边界 + +- `qwen channel start` 使用 `AcpBridge`。标准初始 `tool_call` 携带 `kind`、`title` 和 `status`,现有链路会将其转成 `ToolCallEvent`,再由 `ChannelBase.dispatchToolCall` 投影为 `ChannelTaskLifecycleEvent`;因此本计划中的初始 kind 映射不需要修改 `ChannelBase.ts`。 +- 本地 `AcpBridge` 当前不消费 `tool_call_update`。工具完成或失败后的 `completed`、`failed` 状态不能保证及时到达 ChannelBase;本版本不修复这一点,也不以它作为验收条件。 +- `Kind.Agent` 在 ACP 层被标准化为 `other`,因此本版本不能将 Agent 映射为独立阶段。 +- `TodoWrite` 使用 `plan` 更新而不是 `tool_call`,因此不会触发 `think` 工具阶段。 +- 本版本增加只读契约测试,证明标准初始 kind 能穿过 `AcpBridge`;如果该测试不通过,应停止 DingTalk 映射实现并重新评估范围,不得用展示层猜测弥补上游信号缺失。 + +## 目标映射 + +| ACP kind | 内部阶段 | 英文标签 | 中文标签 | +| ---------------- | ----------- | ------------------- | --------------- | +| `read` | `reading` | `📖 Reading` | `📖 读取中` | +| `edit` | `editing` | `🛠️ Editing` | `🛠️ 编辑中` | +| `delete` | `deleting` | `🗑️ Deleting` | `🗑️ 删除中` | +| `move` | `moving` | `📦 Moving` | `📦 移动中` | +| `search` | `searching` | `🔎 Searching` | `🔎 搜索中` | +| `execute` | `running` | `🖥️ Running` | `🖥️ 执行中` | +| `think` | `thinking` | `🤔 Thinking` | `🤔 思考中` | +| `fetch` | `fetching` | `🌐 Fetching` | `🌐 获取中` | +| `switch_mode` | `switching` | `🔄 Switching mode` | `🔄 切换模式中` | +| `other` 或未知值 | `working` | `🛠️ Working` | `🛠️ 处理中` | + +对于实际到达 DingTalk 层的生命周期事件,优先级保持不变: + +1. `text_chunk` 映射为 `replying`。 +2. 工具事件状态包含 `fail` 或 `error` 时,无论 kind 是什么都映射为 `retrying`;本地 `tool_call_update` 不在本版本的可见性保证内。 +3. 工具事件状态包含 `complete` 或 `success` 时,无论 kind 是什么都映射为 `thinking`;本地 `tool_call_update` 不在本版本的可见性保证内。 +4. 其他工具调用状态按上表进行精确匹配或兼容回退。 + +--- + +### Task 1:锁定 AcpBridge 初始 kind 透传契约 + +**文件:** + +- 修改:`packages/channels/base/src/AcpBridge.test.ts:130-490` + +**接口:** + +- 输入:标准 ACP `session/update` 中的初始 `tool_call`。 +- 输出:现有 `AcpBridge` 发出的 `ToolCallEvent`;本任务不修改生产实现。 + +- [ ] **步骤 1:增加标准初始 kind 透传契约测试** + +在 `AcpBridge.test.ts` 中增加表驱动测试,直接向 `handleSessionUpdate` 输入标准初始工具事件: + +```ts +it('forwards standard initial ACP tool kinds unchanged', () => { + const bridge = new AcpBridge({ + cliEntryPath: '/tmp/qwen', + cwd: '/tmp', + }) as unknown as TestableAcpBridge; + const toolCall = vi.fn(); + bridge.on('toolCall', toolCall); + + const kinds = [ + 'read', + 'edit', + 'delete', + 'move', + 'search', + 'execute', + 'think', + 'fetch', + 'switch_mode', + 'other', + ]; + for (const kind of kinds) { + bridge.handleSessionUpdate({ + sessionId: 'session-1', + update: { + sessionUpdate: 'tool_call', + toolCallId: `tool-${kind}`, + kind, + title: kind, + status: 'in_progress', + }, + }); + } + + expect(toolCall.mock.calls.map(([event]) => event.kind)).toEqual(kinds); +}); +``` + +- [ ] **步骤 2:运行契约测试并确认现有实现直接通过** + +执行: + +```bash +cd packages/channels/base && npx vitest run src/AcpBridge.test.ts -t "standard initial ACP tool kinds" +``` + +预期:测试直接通过,证明下一任务不需要修改 `ChannelBase.ts` 或 `AcpBridge.ts` 生产代码。如果失败,停止执行后续任务并重新评估范围。 + +- [ ] **步骤 3:暂存契约测试,随下一任务一起形成一个行为提交** + +本步骤不单独提交;契约测试将与任务 2 的 DingTalk 映射测试和最小实现一起提交,避免产生只有测试、没有用户可见行为的中间提交。 + +### Task 2:增加 ACP kind 精确分类 + +**文件:** + +- 修改:`packages/channels/dingtalk/src/presentation-phase.ts:3-73` +- 新增:`packages/channels/dingtalk/src/presentation-phase.test.ts` + +**接口:** + +- 输入:来自 `@qwen-code/channel-base` 的 `ChannelTaskLifecycleEvent.toolCall.kind` 和 `.status`。 +- 输出:`lifecyclePresentationPhase(event): DingtalkPresentationPhase | undefined`,新增 `deleting`、`moving`、`fetching` 和 `switching`。 +- 输出:`presentationPhaseLabel(phase, language): string`,为每个阶段提供中英文标签。 + +- [ ] **步骤 1:为每个标准 ACP kind 编写失败的表驱动测试** + +创建 `presentation-phase.test.ts`,使用辅助函数构造最小工具生命周期事件,并断言完整映射: + +```ts +import { describe, expect, it } from 'vitest'; +import type { ChannelTaskLifecycleEvent } from '@qwen-code/channel-base'; +import { + lifecyclePresentationPhase, + presentationPhaseLabel, +} from './presentation-phase.js'; + +function toolEvent( + kind: string, + status = 'in_progress', +): ChannelTaskLifecycleEvent { + return { + channelName: 'dingtalk', + chatId: 'chat-1', + sessionId: 'session-1', + identity: { id: 'channel:dingtalk', displayName: 'dingtalk' }, + memoryScope: { + namespace: 'channel:dingtalk', + mode: 'metadata-only', + }, + type: 'tool_call', + toolCall: { + sessionId: 'session-1', + toolCallId: `tool-${kind}`, + kind, + title: kind, + status, + }, + }; +} + +describe('lifecyclePresentationPhase', () => { + it.each([ + ['read', 'reading'], + ['edit', 'editing'], + ['delete', 'deleting'], + ['move', 'moving'], + ['search', 'searching'], + ['execute', 'running'], + ['think', 'thinking'], + ['fetch', 'fetching'], + ['switch_mode', 'switching'], + ['other', 'working'], + ] as const)('maps ACP kind %s to %s', (kind, phase) => { + expect(lifecyclePresentationPhase(toolEvent(kind))).toBe(phase); + }); +}); +``` + +- [ ] **步骤 2:补充优先级、本地化和兼容性失败测试** + +增加以下断言: + +```ts +expect(lifecyclePresentationPhase(toolEvent('fetch', 'failed'))).toBe( + 'retrying', +); +expect(lifecyclePresentationPhase(toolEvent('delete', 'completed'))).toBe( + 'thinking', +); +expect(presentationPhaseLabel('fetching', 'zh-CN')).toBe('🌐 获取中'); +expect(presentationPhaseLabel('deleting', 'en-US')).toBe('🗑️ Deleting'); +expect(presentationPhaseLabel('moving', 'zh')).toBe('📦 移动中'); +expect(presentationPhaseLabel('switching', 'en')).toBe('🔄 Switching mode'); + +// 保留此前已支持的非标准 Bridge kind。 +expect(lifecyclePresentationPhase(toolEvent('run_shell_command'))).toBe( + 'running', +); +expect(lifecyclePresentationPhase(toolEvent('web_search'))).toBe('searching'); +expect(lifecyclePresentationPhase(toolEvent('write_file'))).toBe('editing'); +expect(lifecyclePresentationPhase(toolEvent('read_file'))).toBe('reading'); +expect(lifecyclePresentationPhase(toolEvent('custom_tool'))).toBe('working'); +``` + +- [ ] **步骤 3:运行新增测试并确认 RED** + +执行: + +```bash +cd packages/channels/dingtalk && npx vitest run src/presentation-phase.test.ts +``` + +预期:测试失败,因为新增阶段和标签尚不存在,`delete`、`move`、`think`、`fetch`、`switch_mode` 当前都会回退到 `working`。 + +- [ ] **步骤 4:实现精确匹配优先、旧逻辑兜底的映射** + +扩展 `DingtalkPresentationPhase` 和中英文标签表,然后将当前纯正则分类器替换为: + +```ts +function toolPresentationPhase(kind: string): DingtalkPresentationPhase { + switch (kind.trim().toLowerCase()) { + case 'read': + return 'reading'; + case 'edit': + return 'editing'; + case 'delete': + return 'deleting'; + case 'move': + return 'moving'; + case 'search': + return 'searching'; + case 'execute': + return 'running'; + case 'think': + return 'thinking'; + case 'fetch': + return 'fetching'; + case 'switch_mode': + return 'switching'; + case 'other': + return 'working'; + default: + if (/read/iu.test(kind)) return 'reading'; + if (/search|browser|web/iu.test(kind)) return 'searching'; + if (/shell|exec|command|run/iu.test(kind)) return 'running'; + if (/edit|write|patch/iu.test(kind)) return 'editing'; + return 'working'; + } +} +``` + +- [ ] **步骤 5:运行聚焦测试并确认 GREEN** + +执行: + +```bash +cd packages/channels/dingtalk && npx vitest run src/presentation-phase.test.ts +``` + +预期:所有映射、优先级、本地化和兼容性用例通过。 + +- [ ] **步骤 6:将上游契约和分类器作为一个独立评审单元提交** + +```bash +git add packages/channels/base/src/AcpBridge.test.ts packages/channels/dingtalk/src/presentation-phase.ts packages/channels/dingtalk/src/presentation-phase.test.ts +git commit -m "feat(dingtalk): refine tool lifecycle phases" +``` + +### Task 3:验证两种钉钉展示表面并更新设计契约 + +**文件:** + +- 修改:`packages/channels/dingtalk/src/DingtalkAdapter.test.ts:2480-2550` +- 修改:`packages/channels/dingtalk/src/status-card-controller.test.ts:175-205` +- 修改:`docs/design/dingtalk-dynamic-lifecycle-tags.md:9-27` +- 新增:`.qwen/e2e-tests/dingtalk-granular-kind-phases.md`(Git 忽略的验收材料) + +**接口:** + +- 输入:任务 2 扩展后的 `DingtalkPresentationPhase`。 +- 输出:保持 `DingtalkInteractionPresenter.updateStatusCardPhase(runId, phase)` 接口不变,并继续通过 `presentationPhaseLabel` 生成 reaction 标签。 +- 输出:证明同一个生命周期事件在 reaction 和互动卡片中呈现相同阶段的设计与 E2E 证据。 + +- [ ] **步骤 1:扩展 Adapter 投影测试** + +更新现有生命周期投影测试,依次发送 `fetch`、`delete`、`move`、`think` 和 `switch_mode` 事件,并断言: + +```ts +expect(updateStatusCardPhase.mock.calls).toEqual([ + ['run-1', 'fetching'], + ['run-1', 'deleting'], + ['run-1', 'moving'], + ['run-1', 'thinking'], + ['run-1', 'switching'], + ['run-1', 'replying'], +]); +``` + +同时断言对应 emotion 请求依次使用 `🌐 获取中`、`🗑️ 删除中`、`📦 移动中`、`🤔 思考中` 和 `🔄 切换模式中`,并保持既有 emotion/background ID 不变。 + +- [ ] **步骤 2:扩展状态卡阶段更新测试** + +依次调用 `StatusCardController.updateRunPhase`,传入 `fetching`、`deleting`、`moving` 和 `switching`。检查 `openOrUpdateStream` 调用,确认每次更新只替换首行阶段,同时保留已有正文、来源前缀、run 身份和 `finalize: false`。 + +- [ ] **步骤 3:运行钉钉展示相关测试** + +执行: + +```bash +cd packages/channels/dingtalk && npx vitest run src/presentation-phase.test.ts src/DingtalkAdapter.test.ts src/status-card-controller.test.ts src/interaction-presenter.test.ts +``` + +预期:所有测试通过;reaction 和卡片正文中的 title 展示行为与当前版本保持一致。 + +- [ ] **步骤 4:更新受版本控制的设计契约** + +将本计划的“目标映射”表补充到设计文档,并明确: + +- 先精确匹配 ACP kind,再执行兼容别名匹配; +- reaction 和活动状态卡正文投影相同的本地化阶段; +- `other` 继续作为最终兜底; +- 如果未来要区分 `Agent` 和 `Other`,需要增加协议元数据; +- 本版本不改变工具 title 的展示策略。 + +- [ ] **步骤 5:编写并试运行 E2E 计划** + +创建 `.qwen/e2e-tests/dingtalk-granular-kind-phases.md`,覆盖: + +1. 发送会触发 `web_fetch` 的天气查询;该工具执行期间,入站消息 reaction 和活动卡片都应显示 `🌐 获取中`,不再显示通用的 `🛠️ 处理中`。 +2. 发送工作区检查请求并触发读取或搜索工具;根据实际 ACP kind 显示 `📖 读取中` 或 `🔎 搜索中`。 +3. 发送命令执行任务;显示 `🖥️ 执行中`。 +4. 收到第一个回答文本块后;显示 `✍️ 回复中`。 +5. 任务完成后;活动阶段行从卡片中移除,终态状态行保留,reaction 切换为现有终态标签。 +6. 卡片稳定渲染后再记录客户端截图;首屏渲染延迟需要单独记录。 + +先使用全局 `qwen` CLI 试运行相同请求,确认模型实际选择的工具;然后使用非 Gateway 钉钉机器人运行当前分支,模型凭据从 Gateway 环境注入。 + +- [ ] **步骤 6:提交展示证据和设计契约** + +```bash +git add packages/channels/dingtalk/src/DingtalkAdapter.test.ts packages/channels/dingtalk/src/status-card-controller.test.ts docs/design/dingtalk-dynamic-lifecycle-tags.md +git commit -m "test(dingtalk): cover granular lifecycle phases" +``` + +### Task 4:最终验证与交付门禁 + +**文件:** + +- 仅执行验证;计划外不修改 `packages/channels/dingtalk` 之外的生产代码。 + +**接口:** + +- 输入:任务 1 至任务 3 的提交以及 E2E 报告。 +- 输出:一个小范围、独立 follow-up PR 所需的评审证据。 + +- [ ] **步骤 1:执行包级验证** + +```bash +(cd packages/channels/base && npx vitest run src/AcpBridge.test.ts -t "standard initial ACP tool kinds") +(cd packages/channels/dingtalk && npx vitest run src/presentation-phase.test.ts src/DingtalkAdapter.test.ts src/status-card-controller.test.ts src/interaction-presenter.test.ts) +``` + +预期:所有指定测试通过。 + +- [ ] **步骤 2:执行仓库交付验证** + +在仓库根目录执行: + +```bash +npm run build +npm run typecheck +npm run lint +git diff --check origin/main +``` + +预期:所有命令退出码均为 0,并且没有空白字符错误。 + +- [ ] **步骤 3:执行仓库自审** + +完整阅读 diff 和新增测试文件,不带预设目标地检查:每个标准 ACP kind 是否都有中英文标签、兼容回退是否保留旧行为、title 展示策略是否保持不变、是否意外改变其他生命周期或 reaction 行为。连续两轮检查均未发现问题后停止;任何修复都会将干净轮次清零,并要求重新执行步骤 1 和步骤 2。 + +- [ ] **步骤 4:执行客户端可见的钉钉 E2E** + +执行任务 3 的 E2E 计划,将时间戳、实际阶段序列、最终回答和稳定渲染截图追加到被忽略的报告中。仅有 Stream 连接成功或钉钉 API 更新成功不算通过,必须获得客户端可见的 reaction 和卡片状态证据。 + +- [ ] **步骤 5:准备独立后续 PR** + +生命周期交付 PR 合并后,从已合并的默认分支创建后续分支。使用 `.github/pull_request_template.md`,描述用户可见行为,不引用实现符号;Reviewer Test Plan 至少说明: + +- Web Fetch 显示 `🌐 获取中`,不再显示通用“处理中”。 +- 删除、移动、思考和模式切换具有独立标签。 +- 现有读取、搜索、执行、编辑和终态行为保持不变。 +- Agent/Other 仍是已知限制。 + +只有在获得明确交付授权后才执行 push 和创建 PR;完成后回读远端 head、PR 正文和检查状态,再报告交付结果。 + +## 明确延期项 + +- 完整按 `toolCallId` 合并所有 partial `tool_call_update` 字段仍延期;当前最小修复只转发带 `kind` 的更新,并忽略不带 `kind` 的 heartbeat,避免空字段降级当前阶段。 +- 将 ACP `_meta.provenance` 或 `toolName` 安全地传到 Channel 生命周期,使 `Agent` 与普通 `Other` 可以区分。 +- 为 `plan` 事件定义单独的钉钉展示语义;本版本不把 TodoWrite 猜测为 `think`。 diff --git a/packages/channels/base/src/AcpBridge.test.ts b/packages/channels/base/src/AcpBridge.test.ts index 92e7788f7e4..eaf8d6ec249 100644 --- a/packages/channels/base/src/AcpBridge.test.ts +++ b/packages/channels/base/src/AcpBridge.test.ts @@ -117,6 +117,7 @@ type TestableAcpBridge = AcpBridge & { }; knownSessionIds: Set; sessionBindingTokens: Map; + toolCallKindsBySession: Map>; channelLoopMcpServer: unknown; channelLoopToolHandlers: ChannelLoopToolHandler[]; channelLoopMcpRegistered: boolean; @@ -1066,6 +1067,216 @@ describe('AcpBridge', () => { ); }); + it('forwards standard initial ACP tool kinds unchanged', () => { + const bridge = new AcpBridge({ + cliEntryPath: '/tmp/qwen', + cwd: '/tmp', + }) as unknown as TestableAcpBridge; + const toolCall = vi.fn(); + bridge.on('toolCall', toolCall); + + const kinds = [ + 'read', + 'edit', + 'delete', + 'move', + 'search', + 'execute', + 'think', + 'fetch', + 'switch_mode', + 'other', + ]; + for (const kind of kinds) { + bridge.handleSessionUpdate({ + sessionId: 'session-1', + update: { + sessionUpdate: 'tool_call', + toolCallId: `tool-${kind}`, + kind, + title: kind, + status: 'in_progress', + }, + }); + } + + expect(toolCall.mock.calls.map(([event]) => event.kind)).toEqual(kinds); + }); + + it('forwards a refined tool update without creating another response boundary', () => { + const bridge = new AcpBridge({ + cliEntryPath: '/tmp/qwen', + cwd: '/tmp', + }) as unknown as TestableAcpBridge; + const toolCall = vi.fn(); + const responseBoundary = vi.fn(); + bridge.on('toolCall', toolCall); + bridge.on('responseBoundary', responseBoundary); + + bridge.handleSessionUpdate({ + sessionId: 'session-1', + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tool-web-fetch', + kind: 'other', + title: 'web_fetch', + status: 'pending', + }, + }); + bridge.handleSessionUpdate({ + sessionId: 'session-1', + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-web-fetch', + kind: 'fetch', + title: 'WebFetch: weather', + status: 'in_progress', + }, + }); + + expect(toolCall.mock.calls.map(([event]) => event.kind)).toEqual([ + 'other', + 'fetch', + ]); + expect(responseBoundary).toHaveBeenCalledOnce(); + }); + + it('restores the initial kind on a kindless terminal tool update', () => { + const bridge = new AcpBridge({ + cliEntryPath: '/tmp/qwen', + cwd: '/tmp', + }) as unknown as TestableAcpBridge; + const toolCall = vi.fn(); + const responseBoundary = vi.fn(); + bridge.on('toolCall', toolCall); + bridge.on('responseBoundary', responseBoundary); + + bridge.handleSessionUpdate({ + sessionId: 'session-1', + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tool-shell', + kind: 'execute', + title: 'Run shell: echo $SECRET', + status: 'in_progress', + rawInput: { command: 'echo $SECRET' }, + }, + }); + toolCall.mockClear(); + responseBoundary.mockClear(); + + bridge.handleSessionUpdate({ + sessionId: 'session-1', + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-shell', + status: 'completed', + content: [ + { type: 'content', content: { type: 'text', text: 'secret' } }, + ], + rawOutput: 'secret', + }, + }); + + expect(toolCall).toHaveBeenCalledOnce(); + expect(toolCall).toHaveBeenCalledWith({ + sessionId: 'session-1', + toolCallId: 'tool-shell', + kind: 'execute', + title: '', + status: 'completed', + rawInput: undefined, + }); + expect(responseBoundary).not.toHaveBeenCalled(); + }); + + it('does not retain kinds from terminal initial tool calls', () => { + const bridge = new AcpBridge({ + cliEntryPath: '/tmp/qwen', + cwd: '/tmp', + }) as unknown as TestableAcpBridge; + + bridge.handleSessionUpdate({ + sessionId: 'session-1', + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tool-search', + kind: 'search', + title: 'Search', + status: 'completed', + }, + }); + + expect(bridge.toolCallKindsBySession.has('session-1')).toBe(false); + }); + + it('ignores meta-only shell progress heartbeats', () => { + const bridge = new AcpBridge({ + cliEntryPath: '/tmp/qwen', + cwd: '/tmp', + }) as unknown as TestableAcpBridge; + const toolCall = vi.fn(); + const responseBoundary = vi.fn(); + bridge.on('toolCall', toolCall); + bridge.on('responseBoundary', responseBoundary); + + bridge.handleSessionUpdate({ + sessionId: 'session-1', + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tool-shell', + kind: 'execute', + title: 'Run shell', + status: 'in_progress', + }, + }); + bridge.handleSessionUpdate({ + sessionId: 'session-1', + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-shell', + status: 'in_progress', + _meta: { + toolName: 'run_shell_command', + shellProgress: { type: 'shell_progress', elapsedMs: 1_000 }, + }, + }, + }); + + expect(toolCall).toHaveBeenCalledOnce(); + expect(toolCall).toHaveBeenLastCalledWith( + expect.objectContaining({ kind: 'execute', title: 'Run shell' }), + ); + expect(responseBoundary).toHaveBeenCalledOnce(); + }); + + it('forwards kindful terminal updates that carry shell progress metadata', () => { + const bridge = new AcpBridge({ + cliEntryPath: '/tmp/qwen', + cwd: '/tmp', + }) as unknown as TestableAcpBridge; + const toolCall = vi.fn(); + bridge.on('toolCall', toolCall); + + bridge.handleSessionUpdate({ + sessionId: 'session-1', + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-shell', + kind: 'execute', + status: 'completed', + _meta: { + shellProgress: { type: 'shell_progress', elapsedMs: 1_000 }, + }, + }, + }); + + expect(toolCall).toHaveBeenCalledOnce(); + expect(toolCall).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'execute', status: 'completed' }), + ); + }); + it('preserves text when tool calls are not pending', async () => { const bridge = new AcpBridge({ cliEntryPath: '/tmp/qwen', diff --git a/packages/channels/base/src/AcpBridge.ts b/packages/channels/base/src/AcpBridge.ts index aa866be55ce..3b18263201c 100644 --- a/packages/channels/base/src/AcpBridge.ts +++ b/packages/channels/base/src/AcpBridge.ts @@ -91,6 +91,10 @@ export class AcpBridge extends EventEmitter implements ChannelAgentBridge { private readonly channelLoopToolHandlers: ChannelLoopToolHandler[] = []; private readonly knownSessionIds = new Set(); private readonly sessionBindingTokens = new Map(); + private readonly toolCallKindsBySession = new Map< + string, + Map + >(); private channelLoopMcpRegistered = false; private channelLoopMcpRegistration: Promise | null = null; private readonly pendingPermissions = new Map< @@ -155,6 +159,7 @@ export class AcpBridge extends EventEmitter implements ChannelAgentBridge { this.resolvePendingPermissions(); this.knownSessionIds.clear(); this.sessionBindingTokens.clear(); + this.toolCallKindsBySession.clear(); this.connection = null; this.child = null; this.emit('disconnected', code, signal); @@ -394,6 +399,7 @@ export class AcpBridge extends EventEmitter implements ChannelAgentBridge { } if (!this.knownSessionIds.delete(sessionId)) return; this.sessionBindingTokens.delete(sessionId); + this.toolCallKindsBySession.delete(sessionId); this.resolvePendingPermissions(sessionId); const conn = this.connection; @@ -423,6 +429,7 @@ export class AcpBridge extends EventEmitter implements ChannelAgentBridge { this.resolvePendingPermissions(); this.knownSessionIds.clear(); this.sessionBindingTokens.clear(); + this.toolCallKindsBySession.clear(); if (this.child) { this.child.kill(); this.child = null; @@ -482,19 +489,51 @@ export class AcpBridge extends EventEmitter implements ChannelAgentBridge { } break; } - case 'tool_call': { + case 'tool_call': + case 'tool_call_update': { + const toolCallId = (update['toolCallId'] as string) || ''; + if (!toolCallId) break; + const explicitKind = + typeof update['kind'] === 'string' ? update['kind'] : ''; + const meta = update['_meta'] as Record | undefined; + if ( + type === 'tool_call_update' && + !explicitKind && + update['status'] === 'in_progress' && + meta?.['shellProgress'] !== undefined + ) { + break; + } + let sessionKinds = this.toolCallKindsBySession.get(sessionId); + const kind = explicitKind || sessionKinds?.get(toolCallId); + if (!kind) break; + if (type === 'tool_call' || explicitKind) { + const kinds = sessionKinds ?? new Map(); + kinds.set(toolCallId, kind); + this.toolCallKindsBySession.set(sessionId, kinds); + sessionKinds = kinds; + } const event: ToolCallEvent = { sessionId, - toolCallId: update['toolCallId'] as string, - kind: (update['kind'] as string) || '', + toolCallId, + kind, title: (update['title'] as string) || '', status: (update['status'] as string) || 'pending', rawInput: update['rawInput'] as Record | undefined, }; - if (event.status === 'pending' || event.status === 'in_progress') { + if ( + type === 'tool_call' && + (event.status === 'pending' || event.status === 'in_progress') + ) { this.emitResponseBoundary(sessionId); } this.emit('toolCall', event); + if (event.status === 'completed' || event.status === 'failed') { + sessionKinds?.delete(toolCallId); + if (sessionKinds?.size === 0) { + this.toolCallKindsBySession.delete(sessionId); + } + } break; } case 'plan': { diff --git a/packages/channels/base/src/ChannelBase.test.ts b/packages/channels/base/src/ChannelBase.test.ts index aaef5773bb6..f0edde14e81 100644 --- a/packages/channels/base/src/ChannelBase.test.ts +++ b/packages/channels/base/src/ChannelBase.test.ts @@ -14216,7 +14216,10 @@ describe('ChannelBase', () => { kind: `run_shell_command\n${'k'.repeat(100)}`, title: `Run shell command: echo $SECRET\n${'x'.repeat(100)}`, status: `running\n${'s'.repeat(100)}`, - rawInput: { command: 'echo $SECRET' }, + rawInput: { + command: 'echo $SECRET', + description: 'Check disk health\nwithout exposing commands', + }, }); return Promise.resolve('done'); }, @@ -14234,6 +14237,7 @@ describe('ChannelBase', () => { toolCallId: 'tool-1', }), }); + expect(lifecycleToolCall!.toolCall).not.toHaveProperty('description'); expect(lifecycleToolCall!.toolCall).not.toHaveProperty('rawInput'); expect(lifecycleToolCall!.toolCall.kind).not.toContain('\n'); expect(lifecycleToolCall!.toolCall.status).not.toContain('\n'); @@ -14248,7 +14252,10 @@ describe('ChannelBase', () => { Array.from(lifecycleToolCall!.toolCall.title).length, ).toBeLessThanOrEqual(81); expect(ch.toolCalls[0]!.event).toMatchObject({ - rawInput: { command: 'echo $SECRET' }, + rawInput: { + command: 'echo $SECRET', + description: 'Check disk health\nwithout exposing commands', + }, }); }); diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index 6981a0a1d34..6b4d258d077 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -223,6 +223,8 @@ interface ChannelMemoryRecallSelection { export interface ChannelBaseOptions { router?: SessionRouter; proxy?: string; + /** Qwen UI language used by adapter-owned presentation. */ + displayLanguage?: string; /** Adapter-owned persistent state directory. */ stateDir?: string; channelMemory?: ChannelMemoryCallbacks; @@ -1217,7 +1219,7 @@ export abstract class ChannelBase { abstract connect(): Promise; abstract sendMessage(chatId: string, text: string): Promise; - abstract disconnect(): void; + abstract disconnect(): void | Promise; /** * Thread-targeted delivery. Polling adapters override this to post comments diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.test.ts b/packages/channels/dingtalk/src/DingtalkAdapter.test.ts index 7812fc9037e..d3bfc87d104 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.test.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.test.ts @@ -234,6 +234,7 @@ type DingtalkChannelInstance = InstanceType; function createChannel( overrides: Record = {}, + options: Record = {}, ): DingtalkChannelInstance { return new DingtalkChannel( 'test-dingtalk', @@ -253,6 +254,7 @@ function createChannel( ...overrides, } as never, {} as never, + options as never, ); } @@ -325,6 +327,17 @@ it('adds outbound image instructions without replacing custom instructions', () expect(instructions).toContain('[IMAGE: /absolute/path/to/file.png]'); }); +it('does not change agent instructions for a Chinese display language', () => { + const channel = createChannel({}, { displayLanguage: 'zh-CN' }); + const instructions = ( + channel as unknown as { config: { instructions: string } } + ).config.instructions; + + expect(instructions).not.toContain( + "Write every tool call's description in Simplified Chinese", + ); +}); + it('validates interactive card config in the adapter', () => { expect(() => createChannel({ @@ -1047,7 +1060,7 @@ describe('DingtalkChannel prompt reactions', () => { vi.unstubAllEnvs(); }); - it('maps lifecycle start and terminal events to the eye reaction', () => { + it('keeps duplicate lifecycle events idempotent', async () => { const channel = createChannel(); const attachReaction = vi.fn().mockResolvedValue(undefined); const recallReaction = vi.fn().mockResolvedValue(undefined); @@ -1081,10 +1094,17 @@ describe('DingtalkChannel prompt reactions', () => { 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'); + await vi.waitFor(() => { + expect(attachReaction).toHaveBeenCalledTimes(2); + expect(recallReaction).toHaveBeenCalledOnce(); + }); + expect(attachReaction.mock.calls.map(([, , tag]) => tag.name)).toEqual([ + '👀', + '❌ Failed', + ]); + expect(recallReaction.mock.calls.map(([, , tag]) => tag.name)).toEqual([ + '👀', + ]); expect( ( channel as unknown as { mentionTargets: Map } @@ -1092,7 +1112,362 @@ describe('DingtalkChannel prompt reactions', () => { ).toBe(false); }); - it('recalls again when a late lifecycle attach resolves after terminal cleanup', async () => { + it('keeps the eye while rotating status tags and leaves only Done', async () => { + const channel = createChannel(); + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation((input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith('https://oapi.dingtalk.com/gettoken')) { + return Promise.resolve( + new Response( + JSON.stringify({ + errcode: 0, + access_token: 'proactive-token', + expires_in: 7200, + }), + { status: 200 }, + ), + ); + } + return Promise.resolve(new Response('{}', { status: 200 })); + }); + 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' }); + await vi.waitFor(() => { + const replies = fetchSpy.mock.calls.filter(([input]) => + String(input).endsWith('/emotion/reply'), + ); + expect(replies).toHaveLength(2); + }); + lifecycle({ + ...base, + type: 'tool_call', + toolCall: { + sessionId: 'session-1', + toolCallId: 'tool-1', + kind: 'read_file', + title: 'Read package.json', + status: 'in_progress', + }, + }); + await vi.waitFor(() => { + const replies = fetchSpy.mock.calls.filter(([input]) => + String(input).endsWith('/emotion/reply'), + ); + expect(replies).toHaveLength(3); + }); + lifecycle({ ...base, type: 'text_chunk', chunk: 'Answer' }); + await vi.waitFor(() => { + const replies = fetchSpy.mock.calls.filter(([input]) => + String(input).endsWith('/emotion/reply'), + ); + expect(replies).toHaveLength(4); + }); + lifecycle({ ...base, type: 'completed' }); + + await vi.waitFor(() => { + const emotionCalls = fetchSpy.mock.calls.filter(([input]) => + String(input).startsWith( + 'https://api.dingtalk.com/v1.0/robot/emotion/', + ), + ); + expect( + emotionCalls.map(([input, init]) => ({ + action: new URL(String(input)).pathname.split('/').at(-1), + name: ( + JSON.parse(String((init as RequestInit).body)) as { + emotionName: string; + } + ).emotionName, + })), + ).toEqual([ + { action: 'reply', name: '👀' }, + { action: 'reply', name: '🤔 Thinking' }, + { action: 'recall', name: '🤔 Thinking' }, + { action: 'reply', name: '📖 Reading' }, + { action: 'recall', name: '📖 Reading' }, + { action: 'reply', name: '✍️ Replying' }, + { action: 'recall', name: '✍️ Replying' }, + { action: 'recall', name: '👀' }, + { action: 'reply', name: '✅ Done' }, + ]); + }); + }); + + it('localizes every lifecycle reaction tag from the Qwen display language', async () => { + const channel = createChannel({}, { displayLanguage: 'zh-CN' }); + 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' }); + await vi.waitFor(() => expect(attachReaction).toHaveBeenCalledTimes(2)); + lifecycle({ + ...base, + type: 'tool_call', + toolCall: { + sessionId: 'session-1', + toolCallId: 'tool-1', + kind: 'shell', + title: 'Shell', + status: 'in_progress', + }, + }); + await vi.waitFor(() => expect(attachReaction).toHaveBeenCalledTimes(3)); + lifecycle({ ...base, type: 'text_chunk', chunk: 'Answer' }); + await vi.waitFor(() => expect(attachReaction).toHaveBeenCalledTimes(4)); + lifecycle({ ...base, type: 'completed' }); + + await vi.waitFor(() => expect(attachReaction).toHaveBeenCalledTimes(5)); + expect(attachReaction.mock.calls.map(([, , tag]) => tag.name)).toEqual([ + '👀', + '🤔 思考中', + '🖥️ 执行中', + '✍️ 回复中', + '✅ 已完成', + ]); + }); + + it.each([ + { type: 'failed' as const, expected: '❌ 失败' }, + { type: 'cancelled' as const, expected: '⏹️ 已停止' }, + ])( + 'localizes the $type terminal reaction tag from the Qwen display language', + async ({ type, expected }) => { + const channel = createChannel({}, { displayLanguage: 'zh' }); + 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 (type === 'failed') { + lifecycle({ ...base, type, error: 'boom', phase: 'agent' }); + } else { + lifecycle({ ...base, type, reason: 'cancel_command' }); + } + + await vi.waitFor(() => expect(attachReaction).toHaveBeenCalledTimes(2)); + expect(attachReaction.mock.calls.at(-1)?.[2].name).toBe(expected); + }, + ); + + it.each([ + ['interactive status cards', {}], + ['block streaming cards', { blockStreaming: 'on' }], + ])('keeps lifecycle tags enabled for %s', async (_name, overrides) => { + const channel = createChannel(overrides); + 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' }); + await vi.waitFor(() => expect(attachReaction).toHaveBeenCalledTimes(2)); + lifecycle({ ...base, type: 'text_chunk', chunk: 'Answer' }); + await vi.waitFor(() => expect(attachReaction).toHaveBeenCalledTimes(3)); + lifecycle({ ...base, type: 'completed' }); + + await vi.waitFor(() => expect(attachReaction).toHaveBeenCalledTimes(4)); + expect(attachReaction.mock.calls.map(([, , tag]) => tag.name)).toEqual([ + '👀', + '🤔 Thinking', + '✍️ Replying', + '✅ Done', + ]); + }); + + it.each([ + { kind: 'read_file', status: 'in_progress', expected: '📖 Reading' }, + { kind: 'search', status: 'in_progress', expected: '🔎 Searching' }, + { kind: 'shell', status: 'in_progress', expected: '🖥️ Running' }, + { kind: 'edit', status: 'in_progress', expected: '🛠️ Editing' }, + { kind: 'other', status: 'in_progress', expected: '🛠️ Working' }, + { kind: 'read_file', status: 'failed', expected: '⚠️ Retrying' }, + { kind: 'read_file', status: 'completed', expected: '🤔 Thinking' }, + ])( + 'maps $kind/$status tool activity to $expected', + async ({ kind, status, expected }) => { + const channel = createChannel(); + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation((input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith('https://oapi.dingtalk.com/gettoken')) { + return Promise.resolve( + Response.json({ + errcode: 0, + access_token: 'proactive-token', + expires_in: 7200, + }), + ); + } + return Promise.resolve(Response.json({})); + }); + 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' }); + lifecycle({ + ...base, + type: 'tool_call', + toolCall: { + sessionId: 'session-1', + toolCallId: 'tool-1', + kind, + title: 'Tool activity', + status, + }, + }); + + await vi.waitFor(() => { + const replies = fetchSpy.mock.calls + .filter(([input]) => String(input).endsWith('/emotion/reply')) + .map( + ([, init]) => + JSON.parse(String((init as RequestInit).body)) as { + emotionName: string; + }, + ); + expect(replies.at(-1)?.emotionName).toBe(expected); + }); + }, + ); + + it.each([ + { type: 'completed' as const, expected: '✅ Done' }, + { type: 'failed' as const, expected: '❌ Failed' }, + { type: 'cancelled' as const, expected: '⏹️ Stopped' }, + ])('leaves only $expected after $type', async ({ type, expected }) => { + const channel = createChannel(); + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation((input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith('https://oapi.dingtalk.com/gettoken')) { + return Promise.resolve( + Response.json({ + errcode: 0, + access_token: 'proactive-token', + expires_in: 7200, + }), + ); + } + return Promise.resolve(Response.json({})); + }); + 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 (type === 'failed') { + lifecycle({ ...base, type, error: 'boom', phase: 'agent' }); + } else if (type === 'cancelled') { + lifecycle({ ...base, type, reason: 'cancel_command' }); + } else { + lifecycle({ ...base, type }); + } + + await vi.waitFor(() => { + const replies = fetchSpy.mock.calls + .filter(([input]) => String(input).endsWith('/emotion/reply')) + .map( + ([, init]) => + JSON.parse(String((init as RequestInit).body)) as { + emotionName: string; + }, + ); + expect(replies.at(-1)?.emotionName).toBe(expected); + }); + }); + + it('serializes terminal cleanup after a pending attach', async () => { const channel = createChannel(); const attach = deferredPromise(); const attachReaction = vi @@ -1125,17 +1500,259 @@ describe('DingtalkChannel prompt reactions', () => { seedSeenMessage(channel, 'message-2'); const lifecycle = getLifecycleHook(channel); lifecycle({ ...event, type: 'started' }); + await vi.waitFor(() => expect(attachReaction).toHaveBeenCalledOnce()); lifecycle({ ...event, type: 'cancelled', reason: 'cancel_command' }); - expect(attachReaction).toHaveBeenNthCalledWith(1, 'message-2', 'cid-456'); - expect(recallReaction).toHaveBeenNthCalledWith(1, 'message-2', 'cid-456'); + expect(recallReaction).not.toHaveBeenCalled(); attach.resolve(); await vi.waitFor(() => { - expect(recallReaction).toHaveBeenNthCalledWith(2, 'message-2', 'cid-456'); - expect(recallReaction).toHaveBeenCalledTimes(2); + expect(attachReaction).toHaveBeenCalledTimes(2); + expect(recallReaction).toHaveBeenCalledOnce(); + }); + expect(attachReaction.mock.calls.map(([, , tag]) => tag.name)).toEqual([ + '👀', + '⏹️ Stopped', + ]); + expect(recallReaction.mock.calls.map(([, , tag]) => tag.name)).toEqual([ + '👀', + ]); + }); + + it('attaches only the latest phase while a prior replacement is pending', async () => { + const channel = createChannel(); + const pendingRecall = deferredPromise(); + const attachReaction = vi.fn().mockResolvedValue(undefined); + const recallReaction = vi + .fn() + .mockReturnValueOnce(pendingRecall.promise) + .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-latest', + sessionId: 'session-latest', + messageId: 'message-latest', + identity: { id: 'channel:dingtalk', displayName: 'dingtalk' }, + memoryScope: { namespace: 'channel:dingtalk', mode: 'metadata-only' }, + } satisfies LifecycleBase; + const toolCall = (kind: string) => ({ + ...base, + type: 'tool_call' as const, + toolCall: { + sessionId: base.sessionId, + toolCallId: `tool-${kind}`, + kind, + title: 'Tool activity', + status: 'in_progress', + }, + }); + + seedSeenMessage(channel, base.messageId); + const lifecycle = getLifecycleHook(channel); + lifecycle({ ...base, type: 'started' }); + await vi.waitFor(() => expect(attachReaction).toHaveBeenCalledTimes(2)); + + lifecycle(toolCall('read_file')); + await vi.waitFor(() => expect(recallReaction).toHaveBeenCalledOnce()); + lifecycle(toolCall('search')); + lifecycle(toolCall('shell')); + lifecycle({ ...base, type: 'text_chunk', chunk: 'Answer' }); + + pendingRecall.resolve(); + + await vi.waitFor(() => expect(attachReaction).toHaveBeenCalledTimes(3)); + expect(attachReaction.mock.calls.map(([, , tag]) => tag.name)).toEqual([ + '👀', + '🤔 Thinking', + '✍️ Replying', + ]); + }); + + it('drains a phase queued while a no-op drain clears its schedule flag', async () => { + const channel = createChannel(); + const attachReaction = vi.fn().mockResolvedValue(true); + const recallReaction = vi.fn().mockResolvedValue(true); + ( + 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-drain-handoff', + sessionId: 'session-drain-handoff', + messageId: 'message-drain-handoff', + identity: { id: 'channel:dingtalk', displayName: 'dingtalk' }, + memoryScope: { namespace: 'channel:dingtalk', mode: 'metadata-only' }, + } satisfies LifecycleBase; + const toolCall = (kind: string) => ({ + ...base, + type: 'tool_call' as const, + toolCall: { + sessionId: base.sessionId, + toolCallId: `tool-${kind}`, + kind, + title: 'Tool activity', + status: 'in_progress' as const, + }, + }); + + seedSeenMessage(channel, base.messageId); + const lifecycle = getLifecycleHook(channel); + lifecycle({ ...base, type: 'started' }); + await vi.waitFor(() => expect(attachReaction).toHaveBeenCalledTimes(2)); + + lifecycle({ + ...toolCall('read_file'), + toolCall: { + ...toolCall('read_file').toolCall, + status: 'completed', + }, + }); + queueMicrotask(() => lifecycle(toolCall('search'))); + + await vi.waitFor(() => expect(attachReaction).toHaveBeenCalledTimes(3)); + expect(attachReaction.mock.calls.map(([, , tag]) => tag.name)).toEqual([ + '👀', + '🤔 Thinking', + '🔎 Searching', + ]); + }); + + it('lets a terminal event preempt phases pending behind the initial attach', async () => { + const channel = createChannel(); + const pendingEye = deferredPromise(); + const attachReaction = vi + .fn() + .mockReturnValueOnce(pendingEye.promise) + .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-terminal', + sessionId: 'session-terminal', + messageId: 'message-terminal', + identity: { id: 'channel:dingtalk', displayName: 'dingtalk' }, + memoryScope: { namespace: 'channel:dingtalk', mode: 'metadata-only' }, + } satisfies LifecycleBase; + + seedSeenMessage(channel, base.messageId); + const lifecycle = getLifecycleHook(channel); + lifecycle({ ...base, type: 'started' }); + await vi.waitFor(() => expect(attachReaction).toHaveBeenCalledOnce()); + lifecycle({ + ...base, + type: 'tool_call', + toolCall: { + sessionId: base.sessionId, + toolCallId: 'tool-read', + kind: 'read_file', + title: 'Read', + status: 'in_progress', + }, + }); + lifecycle({ ...base, type: 'text_chunk', chunk: 'Answer' }); + lifecycle({ ...base, type: 'cancelled', reason: 'cancel_command' }); + + pendingEye.resolve(); + + await vi.waitFor(() => expect(attachReaction).toHaveBeenCalledTimes(2)); + expect(attachReaction.mock.calls.map(([, , tag]) => tag.name)).toEqual([ + '👀', + '⏹️ Stopped', + ]); + }); + + it('does not lose terminal cleanup when a pending status recall is rejected', async () => { + const channel = createChannel(); + const pendingRecall = deferredPromise(); + const attachReaction = vi.fn().mockResolvedValue(true); + const recallReaction = vi + .fn() + .mockReturnValueOnce(pendingRecall.promise) + .mockResolvedValue(true); + ( + 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-rejected-recall', + sessionId: 'session-rejected-recall', + messageId: 'message-rejected-recall', + identity: { id: 'channel:dingtalk', displayName: 'dingtalk' }, + memoryScope: { namespace: 'channel:dingtalk', mode: 'metadata-only' }, + } satisfies LifecycleBase; + + seedSeenMessage(channel, base.messageId); + const lifecycle = getLifecycleHook(channel); + lifecycle({ ...base, type: 'started' }); + await vi.waitFor(() => expect(attachReaction).toHaveBeenCalledTimes(2)); + lifecycle({ + ...base, + type: 'tool_call', + toolCall: { + sessionId: base.sessionId, + toolCallId: 'tool-read', + kind: 'read_file', + title: 'Read', + status: 'in_progress', + }, }); + await vi.waitFor(() => expect(recallReaction).toHaveBeenCalledOnce()); + lifecycle({ ...base, type: 'cancelled', reason: 'cancel_command' }); + + pendingRecall.resolve(false); + + await vi.waitFor(() => expect(attachReaction).toHaveBeenCalledTimes(3)); + expect(attachReaction.mock.calls.at(-1)?.[2].name).toBe('⏹️ Stopped'); + expect( + (channel as unknown as { reactionStates: Map }) + .reactionStates.size, + ).toBe(0); }); it('does not attach lifecycle reactions without a conversation id', () => { @@ -1158,15 +1775,32 @@ describe('DingtalkChannel prompt reactions', () => { expect(attachReaction).not.toHaveBeenCalled(); }); - it('clears active lifecycle reactions on disconnect', () => { + it('clears active lifecycle reactions on disconnect', async () => { const channel = createChannel(); const attachReaction = vi.fn().mockResolvedValue(undefined); + const pendingRecall = deferredPromise(); + const recallReaction = vi + .fn() + .mockReturnValueOnce(pendingRecall.promise) + .mockResolvedValue(true); ( - channel as unknown as { attachReaction: typeof attachReaction } + channel as unknown as { + attachReaction: typeof attachReaction; + recallReaction: typeof recallReaction; + } ).attachReaction = attachReaction; + ( + channel as unknown as { + attachReaction: typeof attachReaction; + recallReaction: typeof recallReaction; + } + ).recallReaction = recallReaction; const activeReactionKeys = ( channel as unknown as { activeReactionKeys: Set } ).activeReactionKeys; + const reactionStates = ( + channel as unknown as { reactionStates: Map } + ).reactionStates; seedSeenMessage(channel, 'message-1'); getLifecycleHook(channel)({ @@ -1179,10 +1813,75 @@ describe('DingtalkChannel prompt reactions', () => { memoryScope: { namespace: 'channel:dingtalk', mode: 'metadata-only' }, }); expect(activeReactionKeys.size).toBe(1); + await vi.waitFor(() => expect(attachReaction).toHaveBeenCalledTimes(2)); - channel.disconnect(); + let disconnected = false; + const disconnecting = Promise.resolve(channel.disconnect()).then(() => { + disconnected = true; + }); expect(activeReactionKeys.size).toBe(0); + expect(reactionStates.size).toBe(0); + await vi.waitFor(() => expect(recallReaction).toHaveBeenCalledOnce()); + expect(disconnected).toBe(false); + + pendingRecall.resolve(true); + await disconnecting; + + expect(recallReaction).toHaveBeenCalledTimes(2); + expect(recallReaction.mock.calls.map(([, , tag]) => tag.name)).toEqual([ + '🤔 Thinking', + '👀', + ]); + }); + + it('aborts a stuck emotion request so disconnect settles', async () => { + const channel = createChannel(); + ( + channel as unknown as { config: { clientSecret?: string } } + ).config.clientSecret = undefined; + const timeoutController = new AbortController(); + const timeoutSpy = vi + .spyOn(AbortSignal, 'timeout') + .mockReturnValue(timeoutController.signal); + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation((_input, init) => { + const signal = init?.signal; + if (!signal) return Promise.reject(new Error('missing timeout signal')); + return new Promise((_resolve, reject) => { + signal.addEventListener( + 'abort', + () => reject(signal.reason ?? new Error('request aborted')), + { once: true }, + ); + }); + }); + + seedSeenMessage(channel, 'message-timeout'); + getLifecycleHook(channel)({ + type: 'started', + channelName: 'dingtalk', + chatId: 'cid-timeout', + sessionId: 'session-timeout', + messageId: 'message-timeout', + identity: { id: 'channel:dingtalk', displayName: 'dingtalk' }, + memoryScope: { namespace: 'channel:dingtalk', mode: 'metadata-only' }, + }); + await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledOnce()); + + let disconnected = false; + const disconnecting = channel.disconnect().then(() => { + disconnected = true; + }); + await Promise.resolve(); + expect(disconnected).toBe(false); + + timeoutController.abort(new DOMException('Timed out', 'TimeoutError')); + await disconnecting; + + expect(timeoutSpy).toHaveBeenCalledWith(15_000); + expect(disconnected).toBe(true); }); it('skips uppercase webhook URLs when starting a prompt', () => { @@ -1201,7 +1900,7 @@ describe('DingtalkChannel prompt reactions', () => { expect(attachReaction).not.toHaveBeenCalled(); }); - it('still attaches reactions for conversation IDs', () => { + it('still attaches reactions for conversation IDs', async () => { const channel = createChannel(); const attachReaction = vi.fn().mockResolvedValue(undefined); ( @@ -1215,7 +1914,11 @@ describe('DingtalkChannel prompt reactions', () => { 'message-1', ); - expect(attachReaction).toHaveBeenCalledWith('message-1', 'cid-123'); + await vi.waitFor(() => expect(attachReaction).toHaveBeenCalledTimes(2)); + expect(attachReaction.mock.calls.map(([, , tag]) => tag.name)).toEqual([ + '👀', + '🤔 Thinking', + ]); }); it('skips uppercase webhook URLs when ending a prompt', () => { @@ -1297,15 +2000,60 @@ describe('DingtalkChannel prompt reactions', () => { 'session-1', 'message-1', ); - expect(attachReaction).toHaveBeenCalledTimes(2); + await vi.waitFor(() => expect(attachReaction).toHaveBeenCalledTimes(3)); } finally { stderr.mockRestore(); } }); + it('drops queued status updates when the initial eye reaction is rejected', async () => { + const channel = createChannel(); + const eye = deferredPromise(); + const attachReaction = vi + .fn() + .mockReturnValueOnce(eye.promise) + .mockResolvedValue(undefined); + ( + channel as unknown as { attachReaction: typeof attachReaction } + ).attachReaction = attachReaction; + const activeReactionKeys = ( + channel as unknown as { activeReactionKeys: Set } + ).activeReactionKeys; + 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' }); + lifecycle({ + ...base, + type: 'tool_call', + toolCall: { + sessionId: 'session-1', + toolCallId: 'tool-1', + kind: 'read_file', + title: 'Read file', + status: 'in_progress', + }, + }); + + await vi.waitFor(() => expect(attachReaction).toHaveBeenCalledOnce()); + eye.resolve(false); + await vi.waitFor(() => expect(activeReactionKeys.size).toBe(0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(attachReaction).toHaveBeenCalledOnce(); + }); + it.each(['completed', 'cancelled', 'failed'] as const)( 'recalls the reaction on an isolated %s event', - (terminal) => { + async (terminal) => { const channel = createChannel(); const attachReaction = vi.fn().mockResolvedValue(undefined); const recallReaction = vi.fn().mockResolvedValue(undefined); @@ -1345,12 +2093,16 @@ describe('DingtalkChannel prompt reactions', () => { lifecycle({ ...base, type: terminal }); } - expect(recallReaction).toHaveBeenCalledOnce(); - expect(recallReaction).toHaveBeenCalledWith('message-1', 'cid-123'); + await vi.waitFor(() => { + expect(recallReaction).toHaveBeenCalledOnce(); + }); + expect(recallReaction.mock.calls.map(([, , tag]) => tag.name)).toEqual([ + '👀', + ]); }, ); - it('recalls reactions when the session dies without terminal events', () => { + it('recalls reactions when the session dies without terminal events', async () => { const channel = createChannel(); const attachReaction = vi.fn().mockResolvedValue(undefined); const recallReaction = vi.fn().mockResolvedValue(undefined); @@ -1381,11 +2133,16 @@ describe('DingtalkChannel prompt reactions', () => { memoryScope: { namespace: 'channel:dingtalk', mode: 'metadata-only' }, }); expect(activeReactionKeys.size).toBe(1); + await vi.waitFor(() => expect(attachReaction).toHaveBeenCalledTimes(2)); channel.onSessionDied('session-1'); - expect(recallReaction).toHaveBeenCalledWith('message-1', 'cid-123'); expect(activeReactionKeys.size).toBe(0); + await vi.waitFor(() => expect(recallReaction).toHaveBeenCalledTimes(2)); + expect(recallReaction.mock.calls.map(([, , tag]) => tag.name)).toEqual([ + '🤔 Thinking', + '👀', + ]); }); it('uses the app access token for emotion replies', async () => { @@ -1797,6 +2554,101 @@ describe('DingtalkChannel status cards', () => { expect(appendOutput).not.toHaveBeenCalled(); }); + it('projects granular lifecycle phases into matching reactions and status cards', async () => { + const channel = createChannel({}, { displayLanguage: 'zh-CN' }); + const registerRun = vi.fn(); + const startStatusCard = vi.fn(); + const updateStatusCardPhase = vi.fn(); + const attachReaction = vi.fn().mockResolvedValue(undefined); + const recallReaction = vi.fn().mockResolvedValue(undefined); + ( + channel as unknown as { + interactionPresenter: { + registerRun: typeof registerRun; + startStatusCard: typeof startStatusCard; + updateStatusCardPhase: typeof updateStatusCardPhase; + }; + inboundCardOwners: Map; + } + ).interactionPresenter = { + registerRun, + startStatusCard, + updateStatusCardPhase, + }; + ( + channel as unknown as { + attachReaction: typeof attachReaction; + recallReaction: typeof recallReaction; + } + ).attachReaction = attachReaction; + ( + channel as unknown as { + attachReaction: typeof attachReaction; + recallReaction: typeof recallReaction; + } + ).recallReaction = recallReaction; + ( + channel as unknown as { inboundCardOwners: Map } + ).inboundCardOwners.set('message-1', { + ownerId: 'owner-1', + target: { chatId: 'cid-1', isGroup: true }, + }); + const base = { + channelName: 'dingtalk', + chatId: 'cid-1', + sessionId: 'session-1', + messageId: 'message-1', + runId: 'run-1', + owner: { kind: 'channel_user', id: 'owner-1' }, + } satisfies LifecycleBase; + const lifecycle = getLifecycleHook(channel); + + seedSeenMessage(channel, 'message-1'); + lifecycle({ ...base, type: 'started' }); + await vi.waitFor(() => expect(attachReaction).toHaveBeenCalledTimes(2)); + for (const [kind, expectedCalls] of [ + ['fetch', 3], + ['delete', 4], + ['move', 5], + ['think', 6], + ['switch_mode', 7], + ]) { + lifecycle({ + ...base, + type: 'tool_call', + toolCall: { + sessionId: 'session-1', + toolCallId: `tool-${kind}`, + kind, + title: 'Run shell: echo $SECRET from /private/workspace', + status: 'in_progress', + }, + }); + await vi.waitFor(() => + expect(attachReaction).toHaveBeenCalledTimes(expectedCalls), + ); + } + lifecycle({ ...base, type: 'text_chunk', chunk: 'Answer' }); + await vi.waitFor(() => expect(attachReaction).toHaveBeenCalledTimes(8)); + + expect(updateStatusCardPhase.mock.calls).toEqual([ + ['run-1', 'fetching'], + ['run-1', 'deleting'], + ['run-1', 'moving'], + ['run-1', 'thinking'], + ['run-1', 'switching'], + ['run-1', 'replying'], + ]); + expect(attachReaction.mock.calls.slice(2).map(([, , tag]) => tag)).toEqual([ + { name: '🌐 获取中', emotionId: '34019', backgroundId: 'im_bg_6' }, + { name: '🗑️ 删除中', emotionId: '34019', backgroundId: 'im_bg_6' }, + { name: '📦 移动中', emotionId: '34019', backgroundId: 'im_bg_6' }, + { name: '🤔 思考中', emotionId: '34019', backgroundId: 'im_bg_6' }, + { name: '🔄 切换模式中', emotionId: '34019', backgroundId: 'im_bg_6' }, + { name: '✍️ 回复中', emotionId: '34019', backgroundId: 'im_bg_6' }, + ]); + }); + it('captures direct-card correlation by conversation instead of delivery user', async () => { const channel = createChannel(); const envelope: Envelope = { diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.ts b/packages/channels/dingtalk/src/DingtalkAdapter.ts index f9e9778e65b..303d5d7ed20 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.ts @@ -54,6 +54,12 @@ import { type DingtalkInteractiveCardConfig, } from './interactive-card-types.js'; import { StatusCardController } from './status-card-controller.js'; +import { + isChinesePresentationLanguage, + lifecyclePresentationPhase, + presentationPhaseLabel, + type DingtalkPresentationPhase, +} from './presentation-phase.js'; import { QuestionCardController } from './question-card-controller.js'; import { DingtalkInteractionPresenter } from './interaction-presenter.js'; import type { @@ -588,9 +594,14 @@ const DEDUP_TTL_MS = 5 * 60 * 1000; // 5 minutes const ACK_REACTION_NAME = '👀'; const ACK_EMOTION_ID = '2659900'; const ACK_EMOTION_BG_ID = 'im_bg_1'; +const STATUS_EMOTION_ID = '34019'; +const STATUS_EMOTION_BG_ID = 'im_bg_6'; +const DONE_EMOTION_ID = '54054'; +const DONE_EMOTION_BG_ID = 'im_bg_5'; const EMOTION_API = 'https://api.dingtalk.com/v1.0/robot/emotion'; const EMOTION_MAX_ATTEMPTS = 3; const EMOTION_RETRY_BASE_DELAY_MS = 250; +const EMOTION_FETCH_TIMEOUT_MS = 15_000; const GROUP_MSG_API = 'https://api.dingtalk.com/v1.0/robot/groupMessages/send'; const DIRECT_MSG_API = 'https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend'; @@ -739,7 +750,6 @@ const IMAGE_INSTRUCTIONS = [ '', 'Only use a real image file inside the workspace or system temporary directory.', ].join('\n'); - type MentionTargetEnvelope = Envelope & { [mentionTarget]?: string; }; @@ -750,6 +760,48 @@ interface CardRunCorrelation { sender?: { senderName: string }; } +interface DingtalkEmotionTag { + name: string; + emotionId: string; + backgroundId: string; +} + +interface DingtalkReactionState { + key: string; + messageId: string; + chatId: string; + sessionId?: string; + desiredStatusTag?: DingtalkEmotionTag; + statusTag?: DingtalkEmotionTag; + terminalTag?: DingtalkEmotionTag; + finishing: boolean; + drainScheduled: boolean; + eyeAttached: boolean; + revision: number; + tail: Promise; +} + +function statusEmotionTag(name: string): DingtalkEmotionTag { + return { + name, + emotionId: STATUS_EMOTION_ID, + backgroundId: STATUS_EMOTION_BG_ID, + }; +} + +const EYE_TAG: DingtalkEmotionTag = { + name: ACK_REACTION_NAME, + emotionId: ACK_EMOTION_ID, + backgroundId: ACK_EMOTION_BG_ID, +}; +const DONE_TAG: DingtalkEmotionTag = { + name: '✅ Done', + emotionId: DONE_EMOTION_ID, + backgroundId: DONE_EMOTION_BG_ID, +}; +const FAILED_TAG = statusEmotionTag('❌ Failed'); +const STOPPED_TAG = statusEmotionTag('⏹️ Stopped'); + function collectNonBotMentionIds(data: DingTalkMessageData): string[] { if (!Array.isArray(data.atUsers) || typeof data.chatbotUserId !== 'string') { return []; @@ -799,6 +851,7 @@ type DingtalkChannelConfig = ChannelConfig & { export class DingtalkChannel extends ChannelBase { private client: DWClient; private readonly atSender: boolean; + private readonly displayLanguage: string | undefined; private connectionManager?: DingtalkConnectionManager; private seenMessages: Map = new Map(); private mentionTargets = new Map(); @@ -809,6 +862,7 @@ export class DingtalkChannel extends ChannelBase { /** Map conversationId → latest sessionWebhook URL for sending replies. */ private webhooks: Map = new Map(); private activeReactionKeys = new Set(); + private reactionStates = new Map(); /** sessionId → reaction keys, so a dead session's reactions can be recalled. */ private sessionReactionKeys = new Map< string, @@ -862,6 +916,7 @@ export class DingtalkChannel extends ChannelBase { this.atSender = (config as unknown as Record)['atSender'] === true; + this.displayLanguage = options?.displayLanguage; if (!this.config.instructions) { this.config.instructions = [ '## DingTalk Channel', @@ -914,6 +969,9 @@ export class DingtalkChannel extends ChannelBase { cancelRun: (sessionId, runId) => this.requestPromptRunCancellation(sessionId, runId), ...(config.model ? { model: config.model } : {}), + ...(options?.displayLanguage + ? { language: options.displayLanguage } + : {}), onError: (operation, error) => { process.stderr.write( `[DingTalk:${this.name}] ${operation} failed: ${sanitizeLogText(String(error), 300)}\n`, @@ -1620,14 +1678,15 @@ export class DingtalkChannel extends ChannelBase { endpoint: 'reply' | 'recall', msgId: string, conversationId: string, - ): Promise { + tag: DingtalkEmotionTag, + ): Promise { const robotCode = this.config.clientId; - if (!robotCode || !msgId || !conversationId) return; + if (!robotCode || !msgId || !conversationId) return false; try { const token = this.config.clientSecret ? await this.getProactiveToken() : this.getAccessToken(); - if (!token) return; + if (!token) return false; for (let attempt = 0; attempt < EMOTION_MAX_ATTEMPTS; attempt++) { const resp = await fetch(`${EMOTION_API}/${endpoint}`, { method: 'POST', @@ -1640,16 +1699,17 @@ export class DingtalkChannel extends ChannelBase { openMsgId: msgId, openConversationId: conversationId, emotionType: 2, - emotionName: ACK_REACTION_NAME, + emotionName: tag.name, textEmotion: { - emotionId: ACK_EMOTION_ID, - emotionName: ACK_REACTION_NAME, - text: ACK_REACTION_NAME, - backgroundId: ACK_EMOTION_BG_ID, + emotionId: tag.emotionId, + emotionName: tag.name, + text: tag.name, + backgroundId: tag.backgroundId, }, }), + signal: AbortSignal.timeout(EMOTION_FETCH_TIMEOUT_MS), }); - if (resp.ok) return; + if (resp.ok) return true; const isTransient = resp.status === 429 || resp.status >= 500; if (isTransient && attempt < EMOTION_MAX_ATTEMPTS - 1) { @@ -1664,40 +1724,49 @@ export class DingtalkChannel extends ChannelBase { process.stderr.write( `[DingTalk:${this.name}] emotion/${endpoint} failed after ${attempt + 1}/${EMOTION_MAX_ATTEMPTS} attempts: ${resp.status} ${detail}\n`, ); - return; + return false; } } catch { // best-effort, don't break message flow } + return false; } private async attachReaction( msgId: string, conversationId: string, - ): Promise { - await this.emotionApi('reply', msgId, conversationId); + tag: DingtalkEmotionTag = EYE_TAG, + ): Promise { + return this.emotionApi('reply', msgId, conversationId, tag); } private async recallReaction( msgId: string, conversationId: string, - ): Promise { - await this.emotionApi('recall', msgId, conversationId); + tag: DingtalkEmotionTag = EYE_TAG, + ): Promise { + return this.emotionApi('recall', msgId, conversationId, tag); } - disconnect(): void { + async disconnect(): Promise { if (this.dedupTimer) { clearInterval(this.dedupTimer); } + const reactionStates = [...this.reactionStates.values()]; + for (const state of reactionStates) { + this.finishReaction(state.chatId, state.messageId, state.sessionId); + } this.statusCardController?.dispose(); this.activeReactionKeys.clear(); this.sessionReactionKeys.clear(); + this.reactionStates.clear(); if (this.connectionManager) { this.connectionManager.stop(); } else { this.client.disconnect(); } process.stderr.write(`[DingTalk:${this.name}] Disconnected.\n`); + await Promise.allSettled(reactionStates.map((state) => state.tail)); } /** Stable API targets are conversation or user IDs, never webhook URLs. */ @@ -1709,6 +1778,121 @@ export class DingtalkChannel extends ChannelBase { return `${conversationId}:${messageId}`; } + private forgetReactionState(state: DingtalkReactionState): void { + this.activeReactionKeys.delete(state.key); + if (this.reactionStates.get(state.key) === state) { + this.reactionStates.delete(state.key); + } + if (!state.sessionId) return; + const keys = this.sessionReactionKeys.get(state.sessionId); + keys?.delete(state.key); + if (keys?.size === 0) this.sessionReactionKeys.delete(state.sessionId); + } + + private enqueueReaction( + state: DingtalkReactionState, + operation: () => Promise, + ): void { + state.tail = state.tail.then(operation).catch((err) => { + this.logReactionFailure('lifecycle tag update', err); + }); + } + + private scheduleReactionDrain(state: DingtalkReactionState): void { + if (state.drainScheduled) return; + state.drainScheduled = true; + this.enqueueReaction(state, async () => { + try { + await this.drainReactionState(state); + } finally { + state.drainScheduled = false; + if ( + this.reactionStates.get(state.key) === state && + (state.finishing || + (this.activeReactionKeys.has(state.key) && + state.desiredStatusTag && + state.desiredStatusTag.name !== state.statusTag?.name)) + ) { + this.scheduleReactionDrain(state); + } + } + }); + } + + private async drainReactionState( + state: DingtalkReactionState, + ): Promise { + while (true) { + if (state.finishing) { + await this.finishReactionState(state); + return; + } + if ( + this.reactionStates.get(state.key) !== state || + !this.activeReactionKeys.has(state.key) + ) { + return; + } + + const desired = state.desiredStatusTag; + if (!desired || desired.name === state.statusTag?.name) return; + if (state.statusTag) { + const revision = state.revision; + if ( + (await this.recallReaction( + state.messageId, + state.chatId, + state.statusTag, + )) === false + ) { + if (state.revision !== revision) continue; + state.desiredStatusTag = state.statusTag; + return; + } + state.statusTag = undefined; + continue; + } + + const revision = state.revision; + if ( + (await this.attachReaction(state.messageId, state.chatId, desired)) === + false + ) { + if (state.revision !== revision) continue; + state.desiredStatusTag = undefined; + return; + } + state.statusTag = desired; + } + } + + private async finishReactionState( + state: DingtalkReactionState, + ): Promise { + let statusCleared = true; + if (state.statusTag) { + statusCleared = + (await this.recallReaction( + state.messageId, + state.chatId, + state.statusTag, + )) !== false; + if (statusCleared) state.statusTag = undefined; + } + const eyeCleared = + !state.eyeAttached || + (await this.recallReaction(state.messageId, state.chatId, EYE_TAG)) !== + false; + if (state.terminalTag && statusCleared && eyeCleared) { + await this.attachReaction( + state.messageId, + state.chatId, + state.terminalTag, + ); + } + this.forgetReactionState(state); + } + private rememberInboundMessageId(msgId: string): void { this.inboundMessageIds.delete(msgId); this.inboundMessageIds.add(msgId); @@ -1737,6 +1921,19 @@ export class DingtalkChannel extends ChannelBase { const key = this.reactionKey(messageId, chatId); if (this.activeReactionKeys.has(key)) return; this.activeReactionKeys.add(key); + const state: DingtalkReactionState = { + key, + messageId, + chatId, + ...(sessionId ? { sessionId } : {}), + desiredStatusTag: this.phaseReactionTag('thinking'), + finishing: false, + drainScheduled: false, + eyeAttached: false, + revision: 0, + tail: Promise.resolve(), + }; + this.reactionStates.set(key, state); if (sessionId) { let keys = this.sessionReactionKeys.get(sessionId); if (!keys) { @@ -1745,38 +1942,86 @@ export class DingtalkChannel extends ChannelBase { } 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); - }); + this.enqueueReaction(state, async () => { + try { + if ((await this.attachReaction(messageId, chatId, EYE_TAG)) === false) { + this.forgetReactionState(state); + return; } - }) - .catch((err) => { - this.activeReactionKeys.delete(key); + state.eyeAttached = true; + this.scheduleReactionDrain(state); + } catch (err) { + this.forgetReactionState(state); this.logReactionFailure('reaction attach', err); - }); + } + }); } - private stopReaction( + private replaceStatusReaction( chatId: string, - messageId?: string, - sessionId?: string, + messageId: string | undefined, + tag: DingtalkEmotionTag, + ): void { + if (!messageId) return; + const state = this.reactionStates.get(this.reactionKey(messageId, chatId)); + if (!state || !this.activeReactionKeys.has(state.key)) return; + state.desiredStatusTag = tag; + state.revision++; + this.scheduleReactionDrain(state); + } + + private phaseReactionTag( + phase: DingtalkPresentationPhase, + ): DingtalkEmotionTag { + return statusEmotionTag( + presentationPhaseLabel(phase, this.displayLanguage), + ); + } + + private terminalReactionTag( + type: 'completed' | 'failed' | 'cancelled', + ): DingtalkEmotionTag { + if (!isChinesePresentationLanguage(this.displayLanguage)) { + return type === 'completed' + ? DONE_TAG + : type === 'failed' + ? FAILED_TAG + : STOPPED_TAG; + } + if (type === 'completed') { + return { ...DONE_TAG, name: '✅ 已完成' }; + } + return statusEmotionTag(type === 'failed' ? '❌ 失败' : '⏹️ 已停止'); + } + + private finishReaction( + chatId: string, + messageId: string | undefined, + sessionId: string | undefined, + terminalTag?: DingtalkEmotionTag, ): void { if (!messageId || !this.isStableTargetId(chatId)) return; const key = this.reactionKey(messageId, chatId); + const state = this.reactionStates.get(key); + if (!state || !this.activeReactionKeys.delete(key)) return; if (sessionId) { const keys = this.sessionReactionKeys.get(sessionId); - if (keys) { - keys.delete(key); - if (keys.size === 0) this.sessionReactionKeys.delete(sessionId); - } + 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); - }); + state.finishing = true; + state.desiredStatusTag = undefined; + state.terminalTag = terminalTag; + state.revision++; + this.scheduleReactionDrain(state); + } + + private stopReaction( + chatId: string, + messageId?: string, + sessionId?: string, + ): void { + this.finishReaction(chatId, messageId, sessionId); } /** Recall reactions left behind when a session dies without terminal lifecycle events. */ @@ -1804,12 +2049,8 @@ export class DingtalkChannel extends ChannelBase { 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); - }); - } + for (const { messageId, chatId } of keys.values()) { + this.finishReaction(chatId, messageId, sessionId); } } super.onSessionDied(sessionId); @@ -1842,9 +2083,29 @@ export class DingtalkChannel extends ChannelBase { } return; } + const presentationPhase = lifecyclePresentationPhase(event); + if (event.runId && presentationPhase) { + this.interactionPresenter?.updateStatusCardPhase( + event.runId, + presentationPhase, + ); + } + if (presentationPhase) { + this.replaceStatusReaction( + event.chatId, + event.messageId, + this.phaseReactionTag(presentationPhase), + ); + return; + } if (isTerminalTaskLifecycleType(event.type)) { if (event.messageId) this.mentionTargets.delete(event.messageId); - this.stopReaction(event.chatId, event.messageId, event.sessionId); + this.finishReaction( + event.chatId, + event.messageId, + event.sessionId, + this.terminalReactionTag(event.type), + ); if (event.runId) { this.deleteFileProjectorsForRun(event.runId); if (event.type === 'failed') { diff --git a/packages/channels/dingtalk/src/interaction-presenter.test.ts b/packages/channels/dingtalk/src/interaction-presenter.test.ts index e0642a5fb16..3fdef5e9c69 100644 --- a/packages/channels/dingtalk/src/interaction-presenter.test.ts +++ b/packages/channels/dingtalk/src/interaction-presenter.test.ts @@ -170,7 +170,7 @@ describe('DingtalkInteractionPresenter', () => { expect.objectContaining({ templateId: STATUS_CARD_TEMPLATE_ID, cardParamMap: expect.objectContaining({ - content: '', + content: '🤔 Thinking', flowStatus: 2, }), }), @@ -178,6 +178,23 @@ describe('DingtalkInteractionPresenter', () => { }); }); + it('serializes lifecycle phases into the status card before output', async () => { + const { client, presenter } = createHarness(); + + presenter.startStatusCard('run-1'); + presenter.updateStatusCardPhase('run-1', 'searching'); + + await vi.waitFor(() => { + expect(client.openOrUpdateStream).toHaveBeenCalledWith( + expect.objectContaining({ + content: '🔎 Searching', + finalize: false, + }), + ); + }); + expect(client.openOrUpdateStream).toHaveBeenCalledTimes(2); + }); + it('renders one escaped source label through running, streaming, and terminal cards', async () => { const { client, presenter } = createHarness(); presenter.registerRun( @@ -196,13 +213,13 @@ describe('DingtalkInteractionPresenter', () => { expect(client.createAndDeliver).toHaveBeenCalledWith( expect.objectContaining({ cardParamMap: expect.objectContaining({ - content: '\\[IMAGE\\: x · review\\_\\*\\]', + content: '🤔 Thinking\n\n\\[IMAGE\\: x · review\\_\\*\\]', }), }), ); expect(client.openOrUpdateStream).toHaveBeenCalledWith( expect.objectContaining({ - content: '\\[IMAGE\\: x · review\\_\\*\\]\n\nanalysis', + content: '🤔 Thinking\n\n\\[IMAGE\\: x · review\\_\\*\\]\n\nanalysis', }), ); }); @@ -254,7 +271,7 @@ describe('DingtalkInteractionPresenter', () => { expect(client.createAndDeliver).toHaveBeenCalledWith( expect.objectContaining({ cardParamMap: expect.objectContaining({ - content: '', + content: '🤔 Thinking', }), }), ); @@ -263,7 +280,7 @@ describe('DingtalkInteractionPresenter', () => { .mock.calls.map(([request]) => request.content) .filter(Boolean) .at(-1); - expect(streamed).toBe('正在分析'); + expect(streamed).toBe('🤔 Thinking\n\n正在分析'); }); await presenter.closeOutput( @@ -640,7 +657,7 @@ describe('DingtalkInteractionPresenter', () => { vi .mocked(client.openOrUpdateStream) .mock.calls.map(([request]) => request.content), - ).toContain('segment one'); + ).toContain('🤔 Thinking\n\nsegment one'); presenter.appendOutput(segment('segment-2'), 'segment two'); await presenter.closeOutput('segment-2', '', 'completed'); @@ -662,7 +679,9 @@ describe('DingtalkInteractionPresenter', () => { expect(client.openOrUpdateStream).toHaveBeenCalledOnce(), ); expect(client.openOrUpdateStream).toHaveBeenCalledWith( - expect.objectContaining({ content: 'intermediate result' }), + expect.objectContaining({ + content: '🤔 Thinking\n\nintermediate result', + }), ); vi.mocked(client.openOrUpdateStream).mockRejectedValueOnce( new Error('stream blip'), @@ -679,7 +698,7 @@ describe('DingtalkInteractionPresenter', () => { expect(sendFallback).not.toHaveBeenCalled(); expect(client.openOrUpdateStream).toHaveBeenLastCalledWith( expect.objectContaining({ - content: 'intermediate result updated', + content: '🤔 Thinking\n\nintermediate result updated', finalize: false, }), ); @@ -959,7 +978,7 @@ describe('DingtalkInteractionPresenter', () => { vi .mocked(client.openOrUpdateStream) .mock.calls.map(([request]) => request.content), - ).toContain('segment one'); + ).toContain('🤔 Thinking\n\nsegment one'); }); it('falls back at a boundary when the in-flight card creation fails', async () => { diff --git a/packages/channels/dingtalk/src/interaction-presenter.ts b/packages/channels/dingtalk/src/interaction-presenter.ts index dc17d2208fc..d3ca75d514a 100644 --- a/packages/channels/dingtalk/src/interaction-presenter.ts +++ b/packages/channels/dingtalk/src/interaction-presenter.ts @@ -10,6 +10,7 @@ import type { import { escapeDingTalkMarkdown } from './markdown.js'; import { stripPartialImageMarker } from './outbound-image.js'; import type { QuestionCardController } from './question-card-controller.js'; +import type { DingtalkPresentationPhase } from './presentation-phase.js'; import { CONTENT_LIMIT, TRUNCATION_MARKER, @@ -122,6 +123,14 @@ export class DingtalkInteractionPresenter { }); } + updateStatusCardPhase(runId: string, phase: DingtalkPresentationPhase): void { + const run = this.runs.get(runId); + if (!run || run.terminal) return; + void this.enqueue(run, () => + this.options.statusCards?.updateRunPhase(runId, phase), + ); + } + appendOutput(segment: ChannelOutputSegmentContext, chunk: string): void { const run = this.runs.get(segment.runId); if ( diff --git a/packages/channels/dingtalk/src/presentation-phase.test.ts b/packages/channels/dingtalk/src/presentation-phase.test.ts new file mode 100644 index 00000000000..5f93650fd7b --- /dev/null +++ b/packages/channels/dingtalk/src/presentation-phase.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import type { ChannelTaskLifecycleEvent } from '@qwen-code/channel-base'; +import { + lifecyclePresentationPhase, + presentationPhaseLabel, +} from './presentation-phase.js'; + +function toolEvent( + kind: string, + status = 'in_progress', +): ChannelTaskLifecycleEvent { + return { + channelName: 'dingtalk', + chatId: 'chat-1', + sessionId: 'session-1', + identity: { id: 'channel:dingtalk', displayName: 'dingtalk' }, + memoryScope: { + namespace: 'channel:dingtalk', + mode: 'metadata-only', + }, + type: 'tool_call', + toolCall: { + sessionId: 'session-1', + toolCallId: `tool-${kind}`, + kind, + title: kind, + status, + }, + }; +} + +describe('lifecyclePresentationPhase', () => { + it.each([ + ['read', 'reading'], + ['edit', 'editing'], + ['delete', 'deleting'], + ['move', 'moving'], + ['search', 'searching'], + ['execute', 'running'], + ['think', 'thinking'], + ['fetch', 'fetching'], + ['switch_mode', 'switching'], + ['other', 'working'], + ] as const)('maps ACP kind %s to %s', (kind, phase) => { + expect(lifecyclePresentationPhase(toolEvent(kind))).toBe(phase); + }); + + it('prioritizes terminal statuses over ACP kinds', () => { + expect(lifecyclePresentationPhase(toolEvent('fetch', 'failed'))).toBe( + 'retrying', + ); + expect(lifecyclePresentationPhase(toolEvent('delete', 'completed'))).toBe( + 'thinking', + ); + }); + + it('localizes the new ACP phases', () => { + expect(presentationPhaseLabel('fetching', 'zh-CN')).toBe('🌐 获取中'); + expect(presentationPhaseLabel('deleting', 'en-US')).toBe('🗑️ Deleting'); + expect(presentationPhaseLabel('moving', 'zh')).toBe('📦 移动中'); + expect(presentationPhaseLabel('switching', 'en')).toBe('🔄 Switching mode'); + }); + + it('keeps legacy Bridge kind matching', () => { + expect(lifecyclePresentationPhase(toolEvent('run_shell_command'))).toBe( + 'running', + ); + expect(lifecyclePresentationPhase(toolEvent('web_search'))).toBe( + 'searching', + ); + expect(lifecyclePresentationPhase(toolEvent('write_file'))).toBe('editing'); + expect(lifecyclePresentationPhase(toolEvent('read_file'))).toBe('reading'); + expect(lifecyclePresentationPhase(toolEvent('custom_tool'))).toBe( + 'working', + ); + }); +}); diff --git a/packages/channels/dingtalk/src/presentation-phase.ts b/packages/channels/dingtalk/src/presentation-phase.ts new file mode 100644 index 00000000000..b6db90764e4 --- /dev/null +++ b/packages/channels/dingtalk/src/presentation-phase.ts @@ -0,0 +1,108 @@ +import type { ChannelTaskLifecycleEvent } from '@qwen-code/channel-base'; + +export type DingtalkPresentationPhase = + | 'thinking' + | 'reading' + | 'searching' + | 'running' + | 'editing' + | 'deleting' + | 'moving' + | 'fetching' + | 'switching' + | 'working' + | 'retrying' + | 'replying'; + +export const DINGTALK_PRESENTATION_PHASE_LABELS: Record< + DingtalkPresentationPhase, + string +> = { + thinking: '🤔 Thinking', + reading: '📖 Reading', + searching: '🔎 Searching', + running: '🖥️ Running', + editing: '🛠️ Editing', + deleting: '🗑️ Deleting', + moving: '📦 Moving', + fetching: '🌐 Fetching', + switching: '🔄 Switching mode', + working: '🛠️ Working', + retrying: '⚠️ Retrying', + replying: '✍️ Replying', +}; + +const DINGTALK_ZH_PRESENTATION_PHASE_LABELS: Record< + DingtalkPresentationPhase, + string +> = { + thinking: '🤔 思考中', + reading: '📖 读取中', + searching: '🔎 搜索中', + running: '🖥️ 执行中', + editing: '🛠️ 编辑中', + deleting: '🗑️ 删除中', + moving: '📦 移动中', + fetching: '🌐 获取中', + switching: '🔄 切换模式中', + working: '🛠️ 处理中', + retrying: '⚠️ 重试中', + replying: '✍️ 回复中', +}; + +export function isChinesePresentationLanguage(language?: string): boolean { + const normalized = language?.trim().toLowerCase().replaceAll('_', '-'); + return normalized?.startsWith('zh') === true; +} + +export function presentationPhaseLabel( + phase: DingtalkPresentationPhase, + language?: string, +): string { + return ( + isChinesePresentationLanguage(language) + ? DINGTALK_ZH_PRESENTATION_PHASE_LABELS + : DINGTALK_PRESENTATION_PHASE_LABELS + )[phase]; +} + +function toolPresentationPhase(kind: string): DingtalkPresentationPhase { + switch (kind.trim().toLowerCase()) { + case 'read': + return 'reading'; + case 'edit': + return 'editing'; + case 'delete': + return 'deleting'; + case 'move': + return 'moving'; + case 'search': + return 'searching'; + case 'execute': + return 'running'; + case 'think': + return 'thinking'; + case 'fetch': + return 'fetching'; + case 'switch_mode': + return 'switching'; + case 'other': + return 'working'; + default: + if (/read/iu.test(kind)) return 'reading'; + if (/search|browser|web/iu.test(kind)) return 'searching'; + if (/shell|exec|command|run/iu.test(kind)) return 'running'; + if (/edit|write|patch/iu.test(kind)) return 'editing'; + return 'working'; + } +} + +export function lifecyclePresentationPhase( + event: ChannelTaskLifecycleEvent, +): DingtalkPresentationPhase | undefined { + if (event.type === 'text_chunk') return 'replying'; + if (event.type !== 'tool_call') return undefined; + if (/fail|error/iu.test(event.toolCall.status)) return 'retrying'; + if (/complete|success/iu.test(event.toolCall.status)) return 'thinking'; + return toolPresentationPhase(event.toolCall.kind); +} diff --git a/packages/channels/dingtalk/src/status-card-controller.test.ts b/packages/channels/dingtalk/src/status-card-controller.test.ts index a0d044ddb63..72c9f8c816f 100644 --- a/packages/channels/dingtalk/src/status-card-controller.test.ts +++ b/packages/channels/dingtalk/src/status-card-controller.test.ts @@ -67,6 +67,7 @@ function deferred() { function createHarness( options: { model?: string; + language?: string; onError?(operation: string, error: unknown): void; } = {}, ) { @@ -103,6 +104,8 @@ describe('StatusCardController', () => { outTrackId: expect.stringMatching(/^qwen-status-/), target: { chatId: 'cid-1', isGroup: true }, cardParamMap: expect.objectContaining({ + content: '🤔 Thinking\n\nfirst', + statusLine: '0s', hasAction: 'true', stop_action: 'true', }), @@ -111,13 +114,32 @@ describe('StatusCardController', () => { await vi.waitFor(() => expect(client.openOrUpdateStream).toHaveBeenCalledWith( expect.objectContaining({ - content: 'first', + content: '🤔 Thinking\n\nfirst', finalize: false, }), ), ); }); + it('projects localized granular phases without tool details', async () => { + vi.useFakeTimers(); + const { client, controller } = createHarness({ language: 'zh-CN' }); + + controller.replace(segment(), target, 'answer'); + await vi.advanceTimersByTimeAsync(0); + vi.mocked(client.openOrUpdateStream).mockClear(); + + await controller.updateRunPhase('run-1', 'fetching'); + await vi.advanceTimersByTimeAsync(500); + + expect(client.openOrUpdateStream).toHaveBeenLastCalledWith( + expect.objectContaining({ + content: '🌐 获取中\n\nanswer', + finalize: false, + }), + ); + }); + it('includes replacement content in the initial card delivery', async () => { const { client, controller } = createHarness(); @@ -127,14 +149,14 @@ describe('StatusCardController', () => { expect(client.createAndDeliver).toHaveBeenCalledWith( expect.objectContaining({ cardParamMap: expect.objectContaining({ - content: '@Alice', + content: '🤔 Thinking\n\n@Alice', }), }), ), ); expect(client.openOrUpdateStream).toHaveBeenCalledWith( expect.objectContaining({ - content: '@Alice', + content: '🤔 Thinking\n\n@Alice', finalize: false, }), ); @@ -184,7 +206,7 @@ describe('StatusCardController', () => { vi .mocked(client.openOrUpdateStream) .mock.calls.map(([request]) => request.content), - ).toEqual(['latest', 'latest']); + ).toEqual(['🤔 Thinking\n\nlatest', '🤔 Thinking\n\nlatest']); await vi.advanceTimersByTimeAsync(1_000); await expect(controller.flushPending('segment-1')).resolves.toBe(true); @@ -212,7 +234,7 @@ describe('StatusCardController', () => { vi .mocked(client.openOrUpdateStream) .mock.calls.map(([request]) => request.content), - ).toEqual(['second', 'third']); + ).toEqual(['🤔 Thinking\n\nsecond', '🤔 Thinking\n\nthird']); }); it('does not re-arm a flush after writing the latest content', async () => { @@ -247,7 +269,9 @@ describe('StatusCardController', () => { .mocked(client.openOrUpdateStream) .mock.calls.map(([request]) => request.content); expect(streamContents.join('\n')).not.toContain('/Users/ben/private'); - expect(streamContents.at(-1)).toBe('before [Image pending] after'); + expect(streamContents.at(-1)).toBe( + '🤔 Thinking\n\nbefore [Image pending] after', + ); }); it('hides image paths when a streaming card is cancelled', async () => { @@ -293,7 +317,7 @@ describe('StatusCardController', () => { expect(client.createAndDeliver).toHaveBeenCalledWith( expect.objectContaining({ cardParamMap: expect.objectContaining({ - statusLine: 'Running · qwen3.7-max · 0s', + statusLine: 'qwen3.7-max · 0s', }), }), ); @@ -305,7 +329,7 @@ describe('StatusCardController', () => { expect(client.updateInstance).toHaveBeenCalledWith( expect.objectContaining({ cardParamMap: { - statusLine: 'Running · qwen3.7-max · 2s', + statusLine: 'qwen3.7-max · 2s', }, }), ); @@ -315,7 +339,7 @@ describe('StatusCardController', () => { expect(client.updateInstance).toHaveBeenCalledWith( expect.objectContaining({ cardParamMap: { - statusLine: 'Running · qwen3.7-max · 3s', + statusLine: 'qwen3.7-max · 3s', }, }), ); @@ -346,8 +370,8 @@ describe('StatusCardController', () => { expect(client.updateInstance).toHaveBeenLastCalledWith( expect.objectContaining({ cardParamMap: { - content: 'latest [Image pending]', - statusLine: 'Running · 5s', + content: '🤔 Thinking\n\nlatest [Image pending]', + statusLine: '5s', }, }), ); @@ -357,7 +381,7 @@ describe('StatusCardController', () => { expect(client.updateInstance).toHaveBeenLastCalledWith( expect.objectContaining({ cardParamMap: { - statusLine: 'Running · 6s', + statusLine: '6s', }, }), ); @@ -367,8 +391,8 @@ describe('StatusCardController', () => { expect(client.updateInstance).toHaveBeenLastCalledWith( expect.objectContaining({ cardParamMap: { - content: 'latest [Image pending]', - statusLine: 'Running · 10s', + content: '🤔 Thinking\n\nlatest [Image pending]', + statusLine: '10s', }, }), ); @@ -396,8 +420,8 @@ describe('StatusCardController', () => { expect(client.updateInstance).toHaveBeenLastCalledWith( expect.objectContaining({ cardParamMap: { - content: 'latest', - statusLine: 'Running · 5s', + content: '🤔 Thinking\n\nlatest', + statusLine: '5s', }, }), ); @@ -411,8 +435,8 @@ describe('StatusCardController', () => { expect(client.updateInstance).toHaveBeenLastCalledWith( expect.objectContaining({ cardParamMap: { - content: 'latest', - statusLine: 'Running · 6s', + content: '🤔 Thinking\n\nlatest', + statusLine: '6s', }, }), ); @@ -477,7 +501,7 @@ describe('StatusCardController', () => { expect(client.createAndDeliver).toHaveBeenCalledWith( expect.objectContaining({ cardParamMap: expect.objectContaining({ - statusLine: 'Running · 0s', + statusLine: '0s', }), }), ); @@ -487,7 +511,7 @@ describe('StatusCardController', () => { expect(client.updateInstance).toHaveBeenCalledWith( expect.objectContaining({ cardParamMap: { - statusLine: 'Running · 2s', + statusLine: '2s', }, }), ); @@ -520,7 +544,7 @@ describe('StatusCardController', () => { await vi.advanceTimersByTimeAsync(500); expect(client.openOrUpdateStream).toHaveBeenCalledWith( expect.objectContaining({ - content: 'firstsecond', + content: '🤔 Thinking\n\nfirstsecond', finalize: false, }), ); @@ -798,7 +822,7 @@ describe('StatusCardController', () => { ); const gate = deferred(); vi.mocked(client.openOrUpdateStream).mockImplementation(async (request) => { - if (request.content === 'first more') await gate.promise; + if (request.content.endsWith('first more')) await gate.promise; }); controller.replace(segment(), target, 'first more'); @@ -814,7 +838,7 @@ describe('StatusCardController', () => { vi .mocked(client.openOrUpdateStream) .mock.calls.map(([request]) => request.content), - ).toContain('first more second'); + ).toContain('🤔 Thinking\n\nfirst more second'); }); it('awaits in-flight creation before reporting liveness', async () => { @@ -912,7 +936,9 @@ describe('StatusCardController', () => { expect(secondCreate).toEqual( expect.objectContaining({ outTrackId: firstCreate.outTrackId, - cardParamMap: expect.objectContaining({ content: 'first more' }), + cardParamMap: expect.objectContaining({ + content: '🤔 Thinking\n\nfirst more', + }), }), ); await vi.advanceTimersByTimeAsync(1_999); @@ -927,7 +953,7 @@ describe('StatusCardController', () => { expect(client.openOrUpdateStream).toHaveBeenLastCalledWith( expect.objectContaining({ outTrackId: firstCreate.outTrackId, - content: 'first more', + content: '🤔 Thinking\n\nfirst more', finalize: false, }), ); @@ -1104,7 +1130,7 @@ describe('StatusCardController', () => { expect(client.openOrUpdateStream).toHaveBeenCalledTimes(3); expect(client.openOrUpdateStream).toHaveBeenLastCalledWith( expect.objectContaining({ - content: 'latest after recovery', + content: '🤔 Thinking\n\nlatest after recovery', finalize: false, }), ); @@ -1112,6 +1138,34 @@ describe('StatusCardController', () => { expect(onError).toHaveBeenCalledTimes(2); }); + it('does not bypass stream retry backoff for a phase update', async () => { + vi.useFakeTimers(); + const { client, controller } = createHarness(); + controller.replace(segment(), target, 'first'); + await vi.advanceTimersByTimeAsync(0); + vi.mocked(client.openOrUpdateStream).mockClear(); + vi.mocked(client.openOrUpdateStream).mockRejectedValueOnce( + new Error('stream died'), + ); + + controller.replace(segment(), target, 'first more'); + await vi.advanceTimersByTimeAsync(500); + expect(client.openOrUpdateStream).toHaveBeenCalledOnce(); + + await controller.updateRunPhase('run-1', 'fetching'); + await vi.advanceTimersByTimeAsync(999); + expect(client.openOrUpdateStream).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(1); + + expect(client.openOrUpdateStream).toHaveBeenCalledTimes(2); + expect(client.openOrUpdateStream).toHaveBeenLastCalledWith( + expect.objectContaining({ + content: '🌐 Fetching\n\nfirst more', + finalize: false, + }), + ); + }); + it('recovers terminal state after content and finalization failures', async () => { vi.useFakeTimers(); vi.setSystemTime(0); @@ -1286,7 +1340,9 @@ describe('StatusCardController', () => { await vi.advanceTimersByTimeAsync(1); expect(client.openOrUpdateStream).toHaveBeenCalledTimes(4); expect(client.openOrUpdateStream).toHaveBeenLastCalledWith( - expect.objectContaining({ content: 'first more second' }), + expect.objectContaining({ + content: '🤔 Thinking\n\nfirst more second', + }), ); }); diff --git a/packages/channels/dingtalk/src/status-card-controller.ts b/packages/channels/dingtalk/src/status-card-controller.ts index 200fe88cb16..7b691a8dffd 100644 --- a/packages/channels/dingtalk/src/status-card-controller.ts +++ b/packages/channels/dingtalk/src/status-card-controller.ts @@ -9,7 +9,13 @@ import { type DingtalkInteractiveCardClient, } from './interactive-card-client.js'; import type { DingtalkCardCallbackResult } from './interactive-card-types.js'; +import { escapeDingTalkMarkdown } from './markdown.js'; import { sanitizeStreamingImageMarkers } from './outbound-image.js'; +import { + isChinesePresentationLanguage, + presentationPhaseLabel, + type DingtalkPresentationPhase, +} from './presentation-phase.js'; const FLUSH_INTERVAL_MS = 500; const STATUS_REFRESH_INTERVAL_MS = 1_000; @@ -39,6 +45,8 @@ interface StatusRecord { target: { chatId: string; isGroup: boolean }; outTrackId: string; content: string; + sourcePrefix?: string; + phase: DingtalkPresentationPhase; startedAt: number; lastStatusSecond: number; lastContentSyncSecond: number; @@ -73,6 +81,7 @@ export interface StatusCardControllerOptions { client: DingtalkInteractiveCardClient; cancelRun(sessionId: string, runId: string): Promise; model?: string; + language?: string; onError?(operation: string, error: unknown): void; } @@ -83,6 +92,36 @@ function boundContent(content: string): string { )}`; } +function activeContent( + phase: DingtalkPresentationPhase, + content: string, + language?: string, + sourcePrefix?: string, +): string { + const label = presentationPhaseLabel(phase, language); + const sanitized = sanitizeStreamingImageMarkers(content); + const renderedSourcePrefix = + sourcePrefix && + (sanitized === sourcePrefix || sanitized.startsWith(`${sourcePrefix}\n\n`)) + ? sourcePrefix + : undefined; + const body = renderedSourcePrefix + ? sanitized.slice( + renderedSourcePrefix.length + + (sanitized.length === renderedSourcePrefix.length ? 0 : 2), + ) + : sanitized; + const prefix = [label, renderedSourcePrefix].filter(Boolean).join('\n\n'); + if (!body) return prefix; + + const separator = '\n\n'; + const available = CONTENT_LIMIT - prefix.length - separator.length; + if (body.length <= available) return `${prefix}${separator}${body}`; + return `${prefix}${separator}${TRUNCATION_MARKER}${body.slice( + body.length - (available - TRUNCATION_MARKER.length), + )}`; +} + export class StatusCardController { private readonly recordsBySegment = new Map(); private readonly recordsByOutTrack = new Map(); @@ -213,6 +252,10 @@ export class StatusCardController { target, outTrackId, content: boundContent(initialContent), + ...(segment.sourceLabel + ? { sourcePrefix: escapeDingTalkMarkdown(segment.sourceLabel) } + : {}), + phase: 'thinking', startedAt: Date.now(), lastStatusSecond: 0, lastContentSyncSecond: 0, @@ -259,6 +302,25 @@ export class StatusCardController { ); } + updateRunPhase(runId: string, phase: DingtalkPresentationPhase): void { + if (this.disposed) return; + for (const segmentId of this.segmentIdsByRun.get(runId) ?? []) { + const record = this.recordsBySegment.get(segmentId); + if ( + !record || + record.terminal || + record.streamFailed || + record.phase === phase + ) { + continue; + } + record.phase = phase; + record.contentVersion++; + record.hasPendingWrite = true; + this.scheduleFlush(record); + } + } + fail(segmentId: string, error: string): void { void this.finalize(segmentId, boundContent(error), 'Failed', true); } @@ -328,9 +390,14 @@ export class StatusCardController { outTrackId: record.outTrackId, target, cardParamMap: { - content: sanitizeStreamingImageMarkers(record.content), + content: activeContent( + record.phase, + record.content, + this.options.language, + record.sourcePrefix, + ), flowStatus: 2, - statusLine: this.statusLine(record, 'Running').text, + statusLine: this.statusLine(record).text, hasAction: 'true', stop_action: 'true', }, @@ -357,7 +424,12 @@ export class StatusCardController { await this.options.client.openOrUpdateStream({ outTrackId: record.outTrackId, key: 'content', - content: sanitizeStreamingImageMarkers(record.content), + content: activeContent( + record.phase, + record.content, + this.options.language, + record.sourcePrefix, + ), finalize: false, }); } catch (error) { @@ -422,7 +494,12 @@ export class StatusCardController { await this.options.client.openOrUpdateStream({ outTrackId: record.outTrackId, key: 'content', - content: sanitizeStreamingImageMarkers(record.content), + content: activeContent( + record.phase, + record.content, + this.options.language, + record.sourcePrefix, + ), finalize: false, }); contentWritten = true; @@ -638,7 +715,7 @@ export class StatusCardController { private statusLine( record: StatusRecord, - state: StatusState, + state?: Exclude, ): { text: string; second: number } { const second = Math.max( 0, @@ -646,14 +723,32 @@ export class StatusCardController { ); const model = this.options.model?.trim(); return { - text: [state, model, `${second}s`].filter(Boolean).join(' · '), + text: [ + state ? this.statusStateLabel(state) : undefined, + model, + `${second}s`, + ] + .filter(Boolean) + .join(' · '), second, }; } + private statusStateLabel(state: Exclude): string { + if (!isChinesePresentationLanguage(this.options.language)) return state; + return ( + { + Completed: '已完成', + Failed: '已失败', + Stopped: '已终止', + Cancelled: '已取消', + }[state] ?? state + ); + } + private async updateRunningStatus(record: StatusRecord): Promise { if (this.disposed || record.terminal || record.streamFailed) return; - const status = this.statusLine(record, 'Running'); + const status = this.statusLine(record); if (status.second === record.lastStatusSecond) return; const syncContent = status.second - record.lastContentSyncSecond >= @@ -663,7 +758,14 @@ export class StatusCardController { outTrackId: record.outTrackId, cardParamMap: { ...(syncContent - ? { content: sanitizeStreamingImageMarkers(record.content) } + ? { + content: activeContent( + record.phase, + record.content, + this.options.language, + record.sourcePrefix, + ), + } : {}), statusLine: status.text, }, diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 040f9925e0e..af6618f4275 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -4290,7 +4290,6 @@ describe('QwenAgent MCP SSE/HTTP support', () => { setWorkflowsEnabled: vi.fn(), getBareMode: vi.fn().mockReturnValue(false), getFolderTrustFeature: vi.fn().mockReturnValue(false), - getFolderTrust: vi.fn().mockReturnValue(true), isTrustedFolder: vi.fn().mockReturnValue(true), }; } diff --git a/packages/cli/src/commands/channel/daemon-worker.test.ts b/packages/cli/src/commands/channel/daemon-worker.test.ts index 211e5d89d0a..603be251e05 100644 --- a/packages/cli/src/commands/channel/daemon-worker.test.ts +++ b/packages/cli/src/commands/channel/daemon-worker.test.ts @@ -946,8 +946,14 @@ describe('runChannelDaemonWorker', () => { it('starts selected channels through a daemon-backed bridge facade', async () => { const sdk = createSdk(); const ready = vi.fn(); - const settings = { merged: { proxy: 'http://settings-proxy:8080' } }; + const settings = { + merged: { + proxy: 'http://settings-proxy:8080', + general: { language: 'en' }, + }, + }; mockLoadSettings.mockReturnValueOnce(settings); + vi.stubEnv('QWEN_CODE_LANG', 'zh'); const handle = await runChannelDaemonWorker({ daemonUrl: 'http://127.0.0.1:4170', @@ -1006,6 +1012,7 @@ describe('runChannelDaemonWorker', () => { }, stateDir: '/tmp/qwen/channels/daemon/workspace-hash/instances/telegram-hash', + displayLanguage: 'zh', }), ); expect(mockDaemonChannelStateDir).toHaveBeenCalledWith( @@ -2418,6 +2425,41 @@ describe('runChannelDaemonWorker', () => { expect(mockRouterClearAll).not.toHaveBeenCalled(); }); + it('waits for asynchronous channel cleanup before closing', async () => { + const sdk = createSdk(); + let finishDisconnect!: () => void; + const disconnect = vi.fn( + () => + new Promise((resolve) => { + finishDisconnect = resolve; + }), + ); + mockCreateChannel.mockResolvedValueOnce({ + connect: vi.fn().mockResolvedValue(undefined), + disconnect, + name: 'telegram', + }); + + const handle = await runChannelDaemonWorker({ + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + loadDaemonSdk: async () => sdk, + }); + let closed = false; + const closing = handle.close().then(() => { + closed = true; + }); + + await vi.waitFor(() => expect(disconnect).toHaveBeenCalledOnce()); + expect(closed).toBe(false); + + finishDisconnect(); + await closing; + + expect(closed).toBe(true); + }); + it('runs webhook tasks on the matching channel handle', async () => { const sdk = createSdk(); const runWebhookTask = vi.fn().mockResolvedValue(undefined); diff --git a/packages/cli/src/commands/channel/daemon-worker.ts b/packages/cli/src/commands/channel/daemon-worker.ts index 21ce367ba6f..6a9cc278c16 100644 --- a/packages/cli/src/commands/channel/daemon-worker.ts +++ b/packages/cli/src/commands/channel/daemon-worker.ts @@ -12,6 +12,7 @@ import { updateChannelMemoryEntry, } from '@qwen-code/qwen-code-core'; import { loadSettings } from '../../config/settings.js'; +import { resolveLanguage, resolveLanguageSetting } from '../../i18n/index.js'; import { scrubAndReportInheritedLoaderEnv } from '../../config/shared-env-keys.js'; import { ChannelLoopScheduler, @@ -486,6 +487,11 @@ export async function runChannelDaemonWorker( undefined, settings.merged.proxy as string | undefined, ); + const displayLanguage = resolveLanguage( + resolveLanguageSetting( + settings.merged.general?.language as string | undefined, + ), + ); const channelsConfig = loadChannelsConfig(daemonWorkspace, settings); const names = selectedChannelNames(channelsConfig, opts.selection); const parsed = await abortableStartup( @@ -572,14 +578,16 @@ export async function runChannelDaemonWorker( ...(opts.daemonToken ? { daemonToken: opts.daemonToken } : {}), workerEnv: process.env, }; - const disconnectAll = () => { + const disconnectAll = async (): Promise => { + const disconnections: Array> = []; for (const channel of channels.values()) { try { - channel.disconnect(); + disconnections.push(Promise.resolve(channel.disconnect())); } catch { // best-effort } } + await Promise.allSettled(disconnections); }; let router: SessionRouter | undefined; @@ -621,6 +629,7 @@ export async function runChannelDaemonWorker( await abortableStartup( createChannel(name, config, bridgeFacade, { ...(proxy ? { proxy } : {}), + ...(displayLanguage ? { displayLanguage } : {}), router: createdRouter, stateDir: daemonChannelStateDir(daemonWorkspace, name), channelMemory: { @@ -678,7 +687,7 @@ export async function runChannelDaemonWorker( `[Channel] Failed to connect "${safeName}": ${safeMessage}`, ); try { - channel.disconnect(); + await channel.disconnect(); } catch { // best-effort } @@ -806,23 +815,25 @@ export async function runChannelDaemonWorker( }, async close() { scheduler?.stop(); - disconnectAll(); + const disconnecting = disconnectAll(); try { bridge.stop(); } finally { createdRouter.dispose(); + await disconnecting; } }, }; } catch (err) { scheduler?.stop(); - disconnectAll(); + const disconnecting = disconnectAll(); try { bridge.stop(); } catch { // best-effort during startup rollback } finally { router?.dispose(); + await disconnecting; } throw err; } diff --git a/packages/cli/src/commands/channel/start.test.ts b/packages/cli/src/commands/channel/start.test.ts index f8ff5bb1711..b64f47f2106 100644 --- a/packages/cli/src/commands/channel/start.test.ts +++ b/packages/cli/src/commands/channel/start.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import type { ChannelBaseOptions } from '@qwen-code/channel-base'; @@ -230,6 +230,10 @@ beforeEach(() => { delete process.env['QWEN_CODE_DISABLE_CRON']; }); +afterEach(() => { + vi.unstubAllEnvs(); +}); + describe('resolveProxy', () => { it('prefers the CLI proxy over settings and environment proxies', async () => { process.env['HTTPS_PROXY'] = 'http://env.example.com:8080'; @@ -371,8 +375,14 @@ describe('startCommand.handler', () => { const envProxy = 'http://env.example.com:8080'; const channels = { telegram: { type: 'telegram' } }; mockLoadSettings.mockReturnValue({ - merged: { channels, proxy: settingsProxy }, + merged: { + channels, + proxy: settingsProxy, + general: { language: 'auto' }, + }, }); + vi.stubEnv('QWEN_CODE_LANG', ''); + vi.stubEnv('LANG', 'zh_CN.UTF-8'); process.env['HTTPS_PROXY'] = envProxy; const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`process.exit: ${String(code)}`); @@ -401,6 +411,7 @@ describe('startCommand.handler', () => { expect.any(Object), expect.objectContaining({ proxy: settingsProxy, + displayLanguage: 'zh', loopController: expect.objectContaining({ create: expect.any(Function), createForTarget: expect.any(Function), @@ -620,6 +631,46 @@ describe('startCommand.handler', () => { ); }); + it('waits for asynchronous channel cleanup before standalone exit', async () => { + const channels = { telegram: { type: 'telegram' } }; + let finishDisconnect!: () => void; + mockLoadSettings.mockReturnValue({ merged: { channels } }); + mockChannelConnect.mockResolvedValue(undefined); + mockChannelDisconnect.mockImplementationOnce( + () => + new Promise((resolve) => { + finishDisconnect = resolve; + }), + ); + const processOnSpy = vi + .spyOn(process, 'on') + .mockImplementation(() => process); + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + + try { + void invokeStartHandler({ name: 'telegram' }); + await vi.waitFor(() => expect(mockWriteServiceInfo).toHaveBeenCalled()); + const shutdown = processOnSpy.mock.calls.find( + ([eventName]) => eventName === 'SIGTERM', + )?.[1] as (() => void | Promise) | undefined; + expect(shutdown).toBeDefined(); + + const shuttingDown = Promise.resolve(shutdown!()); + await vi.waitFor(() => expect(mockChannelDisconnect).toHaveBeenCalled()); + expect(exitSpy).not.toHaveBeenCalled(); + + finishDisconnect(); + await shuttingDown; + + expect(exitSpy).toHaveBeenCalledWith(0); + } finally { + processOnSpy.mockRestore(); + exitSpy.mockRestore(); + } + }); + it('cleans up all connected channels when pidfile creation races', async () => { const channels = { telegram: { type: 'telegram' }, diff --git a/packages/cli/src/commands/channel/start.ts b/packages/cli/src/commands/channel/start.ts index 6767512695d..f12cc461b63 100644 --- a/packages/cli/src/commands/channel/start.ts +++ b/packages/cli/src/commands/channel/start.ts @@ -11,6 +11,7 @@ import { updateChannelMemoryEntry, } from '@qwen-code/qwen-code-core'; import { loadSettings } from '../../config/settings.js'; +import { resolveLanguage, resolveLanguageSetting } from '../../i18n/index.js'; import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js'; import { AcpBridge, @@ -90,11 +91,14 @@ function channelMemoryOptions( }; } -function writeServiceInfoOrExit(channels: string[], cleanup: () => void): void { +async function writeServiceInfoOrExit( + channels: string[], + cleanup: () => Promise, +): Promise { try { writeServiceInfo(channels); } catch (err) { - cleanup(); + await cleanup(); if (isFileExistsError(err)) { writeStderrLine( 'Error: Channel service was started concurrently. Use "qwen channel status" to inspect it.', @@ -109,10 +113,11 @@ function cleanupStartedChannels( channels: Iterable, bridge: AcpBridge, router: SessionRouter, -): void { +): Promise { + const disconnections: Array> = []; for (const channel of channels) { try { - channel.disconnect(); + disconnections.push(Promise.resolve(channel.disconnect())); } catch { // best-effort } @@ -127,6 +132,7 @@ function cleanupStartedChannels( } catch { // best-effort } + return Promise.allSettled(disconnections).then(() => undefined); } function createBridgeReadinessGate(): { @@ -240,7 +246,7 @@ function createBridgeRecovery(options: BridgeRecoveryOptions): { `[Channel] Bridge crashed ${recentCrashCount} times in ${CRASH_WINDOW_MS / 1000}s. Giving up.`, ); scheduler?.stop(); - cleanupStartedChannels(channels.values(), getBridge(), router); + await cleanupStartedChannels(channels.values(), getBridge(), router); removeServiceInfo(); process.exit(1); } @@ -269,12 +275,12 @@ function createBridgeRecovery(options: BridgeRecoveryOptions): { ); } while (recoveryRequested && !isShuttingDown()); })() - .catch((err) => { + .catch(async (err) => { writeStderrLine( `[Channel] Failed to restart bridge: ${err instanceof Error ? err.message : String(err)}`, ); scheduler?.stop(); - cleanupStartedChannels(channels.values(), getBridge(), router); + await cleanupStartedChannels(channels.values(), getBridge(), router); removeServiceInfo(); process.exit(1); }) @@ -315,6 +321,7 @@ async function startSingle( name: string, proxy: string | undefined, cronEnabled: boolean, + displayLanguage?: string, ): Promise { checkDuplicateInstance(); const channelsConfig = loadChannelsConfig(); @@ -375,6 +382,7 @@ async function startSingle( const channel = await createChannel(name, config, bridge, { router, proxy, + ...(displayLanguage ? { displayLanguage } : {}), ...channelMemoryOptions(() => bridge, config.cwd), ...(loopController ? { loopController } : {}), bridgeRecovery: bridgeReadiness.current, @@ -401,7 +409,7 @@ async function startSingle( bridge.stop(); process.exit(1); } - writeServiceInfoOrExit([name], () => + await writeServiceInfoOrExit([name], () => cleanupStartedChannels([channel], bridge, router), ); // Keep scheduled loops active; their prompt paths wait on bridgeReadiness. @@ -422,13 +430,12 @@ async function startSingle( }); attachDisconnectHandler(bridge); - const shutdown = () => { + const shutdown = async () => { + if (shuttingDown) return; shuttingDown = true; writeStdoutLine('\n[Channel] Shutting down...'); scheduler?.stop(); - channel.disconnect(); - bridge.stop(); - router.clearAll(); + await cleanupStartedChannels([channel], bridge, router); removeServiceInfo(); process.exit(0); }; @@ -442,6 +449,7 @@ async function startSingle( async function startAll( proxy: string | undefined, cronEnabled: boolean, + displayLanguage?: string, ): Promise { checkDuplicateInstance(); const channelsConfig = loadChannelsConfig(); @@ -510,6 +518,7 @@ async function startAll( await createChannel(name, config, bridge, { router, proxy, + ...(displayLanguage ? { displayLanguage } : {}), ...channelMemoryOptions(() => bridge, config.cwd), ...(loopController ? { loopController } : {}), bridgeRecovery: bridgeReadiness.current, @@ -549,7 +558,7 @@ async function startAll( nextFireTime, }) : undefined; - writeServiceInfoOrExit( + await writeServiceInfoOrExit( parsed.map((p) => p.name), () => cleanupStartedChannels(channels.values(), bridge, router), ); @@ -573,14 +582,19 @@ async function startAll( }); attachDisconnectHandler(bridge); - const shutdown = () => { + const shutdown = async () => { + if (shuttingDown) return; shuttingDown = true; writeStdoutLine('\n[Channel] Shutting down...'); scheduler?.stop(); + const disconnections: Array> = []; for (const [name, channel] of channels) { try { - channel.disconnect(); - writeStdoutLine(`[Channel] "${name}" disconnected.`); + disconnections.push( + Promise.resolve(channel.disconnect()).then(() => { + writeStdoutLine(`[Channel] "${name}" disconnected.`); + }), + ); } catch { // best-effort } @@ -588,6 +602,7 @@ async function startAll( bridge.stop(); router.clearAll(); removeServiceInfo(); + await Promise.allSettled(disconnections); process.exit(0); }; process.on('SIGINT', shutdown); @@ -611,10 +626,15 @@ export const startCommand: CommandModule = { settings.merged.proxy as string | undefined, ); const cronEnabled = isChannelCronEnabled(settings); + const displayLanguage = resolveLanguage( + resolveLanguageSetting( + settings.merged.general?.language as string | undefined, + ), + ); if (argv.name) { - await startSingle(argv.name, proxy, cronEnabled); + await startSingle(argv.name, proxy, cronEnabled, displayLanguage); } else { - await startAll(proxy, cronEnabled); + await startAll(proxy, cronEnabled, displayLanguage); } }, }; diff --git a/packages/cli/src/i18n/index.ts b/packages/cli/src/i18n/index.ts index 3332e484aa0..1b853a6fd74 100644 --- a/packages/cli/src/i18n/index.ts +++ b/packages/cli/src/i18n/index.ts @@ -223,7 +223,9 @@ function interpolate( } // Language setting helpers -function resolveLanguage(lang: SupportedLanguage | 'auto'): SupportedLanguage { +export function resolveLanguage( + lang: SupportedLanguage | 'auto', +): SupportedLanguage { if (lang === 'auto') { return detectSystemLanguage(); } @@ -311,7 +313,7 @@ export async function initializeI18n( export function resolveLanguageSetting( settingsLanguage?: string, ): SupportedLanguage | 'auto' { - return ( - process.env['QWEN_CODE_LANG'] || settingsLanguage || 'auto' - ) as SupportedLanguage | 'auto'; + return (process.env['QWEN_CODE_LANG'] || settingsLanguage || 'auto') as + | SupportedLanguage + | 'auto'; }