Support current DSH Session.snapshotEvents while retaining the legacy session.events and apiProxy paths. Add regression coverage and record the nine-channel rollout and live verification for issue #70.
19 KiB
Issue #70:九渠道 Harness 问题与审批兼容修复方案
方案日期:2026-09-04。实施基线:dsh-im v4.9.0。状态:代码实施完成;4 个本机客户端实测通过,其余 5 个渠道待用户人工验收。
需求来源:Issue #70。本方案针对已在 dsh-v0.1.2-alpha.5 + dsh-im 4.9.0 和 dsh-v0.1.2-rc.1 + dsh-im 4.9.0 上确认的回归:Harness 结构化问题仅出现在 DSH Web,IM 侧停留在“正在使用 ask_user_question”。
实施结果(2026-09-04):已按本文的最小方案完成共用 Host 适配层、回归测试、发布 bundle 和变更日志。dsh-v0.1.2-rc.1 下,飞书、企业微信、钉钉、Telegram 各选一个机器人完成了真实客户端的提问与审批闭环;微信个人号、QQ、Slack、Discord、WhatsApp 按用户安排由用户手动验收。
1. 最终决定
在九个 IM 渠道共用的 modern Harness 适配层中,用一个小函数兼容读取新旧 DSH Session 事件:
- Session 提供
snapshotEvents()时,读取一次当前不可变快照。 - Session 没有
snapshotEvents()但仍提供events数组时,继续使用旧接口。 #claimableAgent()用统一的events判定交互归属,并把同一份快照交给后续逻辑。- 结构化问题继续复用现有
question/requested广播与回答回填机制。 - 审批请求不再直接读取
owner.session.events,改为复用owner.events。
这是 Host 级别的统一修复。企业微信、微信个人号、钉钉、QQ、飞书、Slack、Telegram、Discord 和 WhatsApp 不需要九份补丁。
本次不引入 DSH 版本判断、新配置项、新交互协议、新持久化数据或新依赖。
2. 范围与完成标准
2.1 必须修复
覆盖以下九个渠道:
- 微信个人号
- 飞书
- 钉钉
- 企业微信
- Slack
- Telegram
- Discord
每个渠道都必须保持以下完整闭环:
- dsh-im 所有的 Turn 中,
ask_user_question能进入原有渠道交互流程。 - IM 用户回答后,原 Harness Turn 继续,后续流式内容和最终答案送回同一会话。
- dsh-im 所有的 Turn 中,Harness 审批请求能进入原有渠道审批流程。
- IM 用户批准或拒绝后,结果只回填给原审批。
- 浏览器所有的 Turn、其他 Host 和其他 Session 的交互不能被 dsh-im 抢占。
- 旧 DSH 上已可用的问题、审批、流式回复、Session 绑定和取消行为不退化。
2.2 明确不做
- 不改写九个渠道已有的问题呈现、选项解析、审批文案和回复状态机。
- 不无条件拦截所有 Harness 交互;仍只接管有活跃 dsh-im interaction owner 的 Turn。
- 不为新旧 DSH 维护版本区间表,不解析
package.json或 CLI 版本号。 - 不优化 Session 事件扫描或新增增量游标;每次交互读取一份完整快照已足够。
- 不把 AI Office 纳入本次九渠道验收;它使用同一
harnessConnection()时会自然获得兼容修复,但不做 Office 专属改造。
3. 现象、证据与根因
3.1 已确认现象
dsh-v0.1.2-alpha.5 + dsh-im 4.9.0:企业微信仅显示正在思考,DSH Web 已显示待回答问题。dsh-v0.1.2-rc.1 + dsh-im 4.9.0:已再次复现同样现象。- dsh-im 已有渠道问题和审批的呈现与回填;失败发生在进入渠道处理器之前。
3.2 回归根因
dsh-im v4.9.0 的 plugin-src/host/modern-harness-api.mjs 在 #claimableAgent() 中要求 agent.session.events 必须是数组。
DSH 在 5660f44d 中将 Session 日志读取拆成 eventAt() 和 snapshotEvents(),并移除公开 events getter。dsh-v0.1.2-rc.1 的 Session 因此有 snapshotEvents(),但没有 events。
user-questions/request 或 approval/request
-> #claimableAgent(request.agent)
-> Array.isArray(agent.session.events) === false
-> 返回 null 并调用 next()
-> 交互被交给 DSH Web
-> dsh-im mux 没有 question/requested / approval/requested
-> 九渠道的 onInteraction 都不会执行
审批路径还有第二个相同假设:#requestApproval() 直接使用 owner.session.events 查找未决策的 approval/asked。因此不能只让 #claimableAgent() 通过,还必须让审批扫描复用兼容快照。
3.3 现有测试为什么没有发现
test/host-modern-harness-api.test.mjs 的问题和审批 fixture 都使用 const session = { id: 'session', events: [] }。这只模拟了旧 Session 形状,没有模拟 rc.1 的“只有 snapshotEvents()、没有 events”,因此用例会假通过。
3.4 与 Issue 早期报告的边界
Issue 最初的 dsh-im 3.0.4 + DSH 0.1.0-rc.5 报告发生在 modern Host 适配层引入之前,不能仅凭相同表象认定为本次 API 回归。
“在 Web 作答后,67 分钟长 Turn 的后续消息未送达 IM”也尚未证明是同一根因。本次会验证“用户在 IM 回答问题/审批后,同一 Turn 继续并完成投递”,但不在没有新复现证据时重写长 Turn 订阅生命周期。
4. 九渠道为什么能统一修复
九渠道的 production controller 都会调用 harnessConnection(ctx, config)。当 Host 没有旧 apiProxy 且未显式设置 harnessBaseUrl 时,它们共用 modernHarnessApi(ctx)。
| 渠道 | production 接入 | 原有交互呈现 | 是否改渠道代码 |
|---|---|---|---|
| 企业微信 | plugin-src/host/channels/wecom/production.mjs |
WecomBridge 自有 onInteraction |
否 |
| 微信个人号 | plugin-src/host/channels/weixin/production.mjs |
WeixinBridge 自有 onInteraction |
否 |
| 钉钉 | plugin-src/host/channels/dingtalk/production.mjs |
DingTalkBridge 自有 onInteraction |
否 |
plugin-src/host/channels/qq/production.mjs |
QqBridge 自有 onInteraction |
否 | |
| 飞书 | plugin-src/host/channels/feishu/production.mjs |
FeishuBridge 自有卡片/文字交互 |
否 |
| Slack | plugin-src/host/channels/slack/production.mjs |
复用 TextHarnessBridge |
否 |
| Telegram | plugin-src/host/channels/telegram/production.mjs -> channels/shared/production.mjs |
复用 TextHarnessBridge |
否 |
| Discord | plugin-src/host/channels/discord/production.mjs -> channels/shared/production.mjs |
复用 TextHarnessBridge |
否 |
plugin-src/host/channels/whatsapp/production.mjs |
复用 TextHarnessBridge |
否 |
九渠道在收到 mux 中的 question/requested 或 approval/requested 后,已有各自或共享的呈现流程。当前回归使这两类帧在 Host 适配层就被丢失,修复共用上游入口即可覆盖全部九渠道。
5. 最小实现方案
5.1 文件内兼容函数
在 plugin-src/host/modern-harness-api.mjs 内增加非导出函数,不新建模块:
function sessionEvents(session) {
if (typeof session?.snapshotEvents === 'function') {
const events = session.snapshotEvents();
if (Array.isArray(events)) return events;
}
const events = session?.events;
return Array.isArray(events) ? events : null;
}
- 优先新 DSH 的公开
snapshotEvents()。 - 新接口不存在或返回非数组时,再读取旧
events。 - 不吞掉
snapshotEvents()本身抛出的真实 Host 错误。 - 不修改、排序或另行缓存返回的数组。
5.2 统一交互归属快照
#claimableAgent() 收敛为:
#claimableAgent(agent) {
const session = agent?.session;
const sessionId = session?.id ?? agent?.id;
const events = sessionEvents(session);
if (typeof sessionId !== 'string' || !events) return null;
return hasActiveHarnessInteractionOwner(this.#scope, sessionId, events)
? { sessionId, events }
: null;
}
仍然必须由 hasActiveHarnessInteractionOwner() 确认当前 Session 存在未完成、可重连的 dsh-im Turn。不匹配时继续调用 next(),把处理权留给 Web 或其他 Host 处理器。
5.3 问题与审批
#requestQuestion() 不改协议或流程。owner 可被识别后,它会按现有机制建立 pending 记录、广播 question/requested、校验答案、广播 question/resolved 并恢复 Turn。
#requestApproval() 保留现有审批 ID 匹配算法,只替换事件来源:
for (let index = owner.events.length - 1; index >= 0; index -= 1) {
const event = owner.events[index];
// 原有 approval/decided、approval/asked、callId 逻辑不变
}
这同时修复 owner 判定和审批扫描两处旧 API 假设。使用同一份快照,还能保证“归属判定”与“审批 ID 选择”观测到同一时点的 Session 日志。
6. 旧 DSH 兼容策略
兼容性按运行时能力选择,不按版本号选择:
| Host / Session 形状 | harnessConnection() 路径 |
修复后行为 |
|---|---|---|
旧 Host 提供 ctx.apiProxy |
直接复用 apiProxy |
不进入 modern 适配层,行为不变 |
modern Host,Session 有 snapshotEvents() |
modernHarnessApi(ctx) |
读新接口;覆盖已复现的 alpha.5 和 rc.1 |
较早 modern Host,Session 只有 events |
modernHarnessApi(ctx) |
回退读旧数组,保持原行为 |
| 过渡 Session 同时提供两者 | modernHarnessApi(ctx) |
优先 snapshotEvents() |
| 两者都不提供 | modernHarnessApi(ctx) |
不声称交互,安全调用 next() |
显式配置 harnessBaseUrl |
旧远程 HTTP/WebSocket 路径 | 不进入 modern 适配层,行为不变 |
该策略可兼容旧 DSH,且不要求 dsh-im 增加 DSH npm 依赖。将来预发布标签或正式版号变化时,只要 Session 保持上述任一能力,就不需要改分支。
7. 修复后的统一链路
7.1 结构化问题
IM 入站消息
-> 渠道 Bridge 调用共用 HarnessClient.ask()
-> HarnessClient 在 interactionScope 登记当前 Turn owner
-> DSH 调用 user-questions/request
-> modernHarnessApi 用 snapshotEvents() / events 识别 owner
-> mux 广播 question/requested
-> 原渠道 onInteraction 发送问题
-> 用户在原会话作答
-> 原渠道状态机解析并 respondInteraction()
-> modernHarnessApi.respond() 验证并恢复 DSH Turn
-> 后续流式内容与最终答案继续返回原渠道
7.2 审批
Harness 写入 approval/asked
-> DSH 调用 approval/request
-> modernHarnessApi 用同一 Session 快照确认 owner
-> 在 owner.events 中按原 callId 规则选中 approvalId
-> mux 广播 approval/requested
-> 原渠道 onInteraction 发送审批
-> 用户批准/拒绝
-> modernHarnessApi.respond() 核对 sessionId + approvalId + outcome
-> Harness 写入 approval/decided 并继续 Turn
8. 代码改动清单
8.1 需要修改
| 文件 | 最小改动 |
|---|---|
plugin-src/host/modern-harness-api.mjs |
增加 sessionEvents();调整 #claimableAgent();审批扫描改用 owner.events |
test/host-modern-harness-api.test.mjs |
增加新旧 Session 形状的问题和审批回归用例 |
CHANGELOG.md |
在 Unreleased / Fixed 记录九渠道对新 DSH Session API 的问题/审批兼容修复 |
lib/index.js |
由 npm run build 重建,确保 npm 实际入口包含修复 |
8.2 不需要修改
plugin-src/host/harness-connection.mjs:已正确选择旧apiProxy或 modern 适配器。src/channels/shared/harness-client.mjs:owner 登记、mux 消费、回答校验和重连机制已存在。src/channels/shared/harness-question.mjs和harness-approval.mjs:问题/审批语义未变。- 九个渠道的 Bridge、Runtime 和 production controller:下游交互逻辑已有覆盖,不复制修复。
- 配置、状态文件和 UI:不需要迁移。
9. 测试与验证
9.1 Host 适配层回归测试
调整 test/host-modern-harness-api.test.mjs 的 Session fixture,让内部可变日志与暴露的 Session API 分离:
const events = [];
const currentSession = {
id: 'session',
snapshotEvents: () => Object.freeze([...events]),
};
const legacySession = { id: 'session', events };
currentSession 不能保留 events 属性,否则用例仍然无法防住真实回归。
最少覆盖以下矩阵:
| Session API | 结构化问题 | 审批 | 预期 |
|---|---|---|---|
只有 snapshotEvents() |
是 | 是 | dsh-im owner 接管、响应并完成 Turn |
只有 events |
是 | 是 | 旧 DSH 行为不变 |
两者都有,events 放入过期数据 |
是 | 是 | 使用 snapshotEvents() 的当前数据 |
| 两者都没有 | 是 | 是 | 不报错、不抢占,调用 next() |
| API 有效但 Session/Turn 不属于 dsh-im | 是 | 是 | 调用 next(),保持 Web owner |
成功用例还要断言:
- 问题答案或审批 outcome 确实回到 DSH waterfall 请求。
question/resolved或approval/resolved被广播。- 交互之后的
assistant/message和turn/end仍被HarnessClient.ask()消费。 ask()返回交互之后的最终文本,不停在“正在使用”。- abort/cancel 仍清理 pending interaction,不留过期请求。
优先复用现有两个 adapter 闭环用例,只抽取一个小型 Session fixture 切换 API 形状,不新建模拟 Harness 框架。
9.2 九渠道自动化覆盖
本次故障位于所有渠道之前,不增加九份重复的 Session API 测试。自动化分两层证明覆盖:
test/host-modern-harness-api.test.mjs证明共用 Host 适配层能在新旧 DSH 上产生并回收问题/审批。- 现有渠道 Bridge 测试证明 interaction 到达后可被原生呈现和回填。企业微信、微信个人号、钉钉、QQ、飞书使用自有 Bridge 用例;Slack、Telegram、Discord、WhatsApp 的共性由
test/channels/shared/text-harness-bridge.test.mjs覆盖,渠道差异由各自现有测试覆盖。
执行顺序:
node --test test/host-modern-harness-api.test.mjs
npm test
npm run check
npm run check 会重建 client/host bundle、运行全量测试并验证发布包。完成后再确认生成的 lib/index.js 已包含 snapshotEvents 兼容路径。
9.3 真实 DSH 兼容验收
| DSH 环境 | dsh-im | 问题 | 审批 | 目的 |
|---|---|---|---|---|
dsh-v0.1.2-rc.1 |
当前待发布代码 | 本机实测通过 | 本机实测通过 | 已关闭当前 API 回归 |
仅暴露 session.events 的旧 modern Session |
当前待发布代码 | 自动化通过 | 自动化通过 | 证明旧 Session API 向后兼容 |
提供旧 ctx.apiProxy 的 Host |
当前待发布代码 | 现有 adapter 用例通过 | 保持原旁路 | 不进入本次 modern 适配层 |
旧 Session 的兼容性采用能力 fixture 验证,没有把版本号写入生产逻辑;真实旧 DSH 客户端未在本轮重复启动。
9.4 九渠道真实客户端烟雾
发布记录必须如实区分“自动化已覆盖”和“真实客户端已验收”。
| 渠道 | ask_user_question |
审批 | 交互后最终回复 | 当前状态 |
|---|---|---|---|---|
| 企业微信 | 通过(回复 1) |
通过(回复“批准”) | 通过 | 本机客户端实测 |
| 微信个人号 | 待验收 | 待验收 | 待验收 | 用户手动测试 |
| 钉钉 | 通过(回复 1) |
通过(回复“批准”) | 通过 | 本机客户端实测 |
| 待验收 | 待验收 | 待验收 | 用户手动测试 | |
| 飞书 | 通过(选项按钮) | 通过(批准按钮) | 通过 | 本机客户端实测 |
| Slack | 待验收 | 待验收 | 待验收 | 用户手动测试 |
| Telegram | 通过(回复 1) |
通过(回复“批准”) | 通过 | 本机客户端实测 |
| Discord | 待验收 | 待验收 | 待验收 | 用户手动测试 |
| 待验收 | 待验收 | 待验收 | 用户手动测试 |
问题烟雾必须检查问题和选项可见、回答只被消费一次、最终回复可见。审批烟雾至少覆盖批准或拒绝中的一条,并确认不串到其他会话。
本机实测使用 dsh-v0.1.2-rc.1,时间为 2026-09-04 02:59–03:06(Asia/Taipei)。四个渠道都检查了问题/选项可见、回答后原 Turn 恢复、审批参数可见、批准后命令执行、最终回复送达;四个审批测试标记文件均在命令内删除并经本机再次确认不存在。
9.5 已执行的自动化
- 修复前:仅
snapshotEvents()和双接口优先级场景的问题、审批共 4 个用例稳定失败,复现旧实现的 API 形状缺口。 - 修复后定向测试:
node --test test/host-modern-harness-api.test.mjs,9/9 通过。 - 完整检查:
git diff --check && npm run check,2111/2111 通过,发布包校验通过。 lib/index.js已由构建脚本重建,并包含snapshotEvents()优先、session.events回退的兼容逻辑。
10. 验收标准
代码实施结果如下;其余 5 个渠道的真实客户端状态仍按上表等待用户补录:
- 已通过:rc.1 上,企业微信能收到并回答
ask_user_question。 - 已通过:rc.1 上,企业微信能收到并处理审批请求。
- 已通过:四个实测渠道在问题或审批完成后,同一 Turn 的最终回复均送达 IM。
- 已通过自动化:旧
session.events、新snapshotEvents()、双接口优先级三种形状的问题和审批均成功。 - 已通过:两种 Session API 形状的 Host adapter 回归测试通过。
- 已通过:非 dsh-im owner 以及没有可读事件 API 的 Session 均委托给
next()。 - 已通过:
npm run check完成 2111 个测试,生成的 npm 入口包含修复。 - 已记录:九渠道状态逐项列出,未实测的五个渠道明确标记为“用户手动测试”。
11. 风险与回滚
| 风险 | 控制 |
|---|---|
| 抢占本应在 Web 回答的交互 | 保留 hasActiveHarnessInteractionOwner() 为唯一归属判定,非 owner 继续 next() |
| 审批匹配错误 | 保留 approval/decided 排除、pending 排除和 callId 匹配,只替换事件来源 |
| 旧 DSH 回归 | 保留 events 回退和旧 apiProxy 旁路,用两种 fixture 和真实旧环境验证 |
| 长 Session 快照成本 | 每次交互只读一次;DSH 已缓存全量不可变快照,不再加第二套缓存 |
| 只修问题、遗漏审批 | 同一变更同时修改 owner 判定和审批扫描,两类交互都有新旧 API 用例 |
本次无配置、数据或状态迁移。若发布后发现问题,回滚 modern-harness-api.mjs 与重建的 lib/index.js 即可,不需要清理机器人配置或 Session 文件。
12. 实施顺序
- 先把现有问题和审批 adapter 用例切换为只暴露
snapshotEvents()的 fixture,确认未修复代码能稳定失败。 - 增加
sessionEvents(),修改#claimableAgent()和审批扫描。 - 增加
events旧接口、双接口优先级、无接口委托和非 owner 委托用例。 - 运行定向测试、全量测试和
npm run check,重建发布 bundle。 - 先在
dsh-v0.1.2-rc.1 + 企业微信验证问题、审批及交互后最终回复。 - 在代表性旧 DSH 上做同样验收。
- 完成其余渠道烟雾,如实更新本文档验收表。
- 更新
CHANGELOG.md,发布修复版。
13. 结论
当前回归不在九个渠道的呈现层,而在它们共用的 modern Host 交互归属入口。最小且完整的修复是:用一个文件内函数兼容 snapshotEvents() 和 events,让问题与审批共用同一份 Session 快照,其余协议、渠道桥接和状态机保持不变。