fix(dsh)★★: 事件数据源字段名错 —— session.events 不存在,真实是 session.log

## 用户报「dsh 插件还是没有正确回复邮件」(第二版)

519bdf7 改了 `lastAssistantText` 的**解析逻辑**(从后往前找带 text 的那条),
生产仍复现。**解析逻辑本来就是对的**,错的是**数据源字段名**。

## 根因(运行时实测,不是推断)

在 idle 回调里打印 agent/session 的实际键表,得到:

    session 键:[log, surfaceManager, header, inheritedEventCount,
                 firstLiveSeq, firstLifecycleSeq, eventsSnapshot,
                 headerFold, headerFoldSeq, contextFold, contextFoldSeq,
                 toolHistoryProjection, toolHistorySeq, derived, …]

**没有 `events`。** ⇒ `agent.session?.events ?? []` **永远**兜成空数组
⇒ 每一次都判「空回复」⇒ 给人类发件人发「处理失败」通知。

真正的事件序列是 **`log`**:实测 `Array(26)`、末条 `type=turn/end`、
键 `[type,seq,time,data]` —— 与磁盘 `session.v4.jsonl.zstd` 逐字一致
(磁盘 27 条 / 内存 26 条,差 1 条是 idle 瞬间最后一条尚未写入)。
★ `eventsSnapshot` 名字最像,**实测是 null** —— 照名字写代码会第二次踩坑。

## 顺带修掉第二处同源缺陷

`modelTitle(a?.session?.events ?? [])`(会话标题同步)**也是同一个错字段**,
所以会话标题一直同步不上(静默失败)。
两处曾各写各的、修一处漏一处 ⇒ 收敛为单一函数 `sessionEvents()`,
判据禁止任何地方绕过它。

## 被观测否定的两个推断(都记下来)

1. **「idle 时最后一条事件还没落入内存」** ⇒ 我为此加了 3×250ms 重试
   (现已保留,成本极低且无害)。观测 `events=0`(重试 750ms 后**仍为 0**)
   直接否定:不是没到,是**压根没有那个字段**。
2. **「根因是 resume 路径」** ⇒ 新会话与 resume 两次都「正常」,因为 dsh
   都主动 `send_mail` 而在 `shouldSkipAutoRelay` 早退,压根没走到取文本那段。

## 判据(新增 4 格,含接线层)

纯函数测试抓不到这一类缺陷(`lastAssistantText` 的单元测试全绿)。
新增 `test/session-events-source.test.mjs` 钉住**调用点**:

* 辅助函数必须存在且 **log 排在 events 之前**(判**顺序**不是判存在 ——
  `events ?? log` 也含 `.session?.log`,用 `includes` 会免疫)
* 任何读会话事件的地方都必须走 `sessionEvents()`,豁免**按位置**判定
  (用 `helperBody.includes(frag)` 豁免会放过代码里**任何位置**的同名字段)
* 回退链保留 `events`(万一是更老的运行时,不比原来更差)
* 自检:合成坏源码必须判红

★ 这份判据自身也踩了两次「对变异免疫」:
`const code = readFileSync()` 模块级缓存、以及上面那个 includes 豁免。
两处都改成**每次现读 / 按位置判定**后才真正有区分力。

**变异验证**:辅助函数改回 events 优先 → 红;把调用点改回直接读字段 → 红。

## 验证状态

dsh 440 格全绿 · tsc 零错 · 部署成功(current → 20261004-080331)· 心跳正常。

⚠ **最终行为验证未完成**:需要一次真人发信(`from_human=true`)才会走到
自动转发那段。我的所有身份都是 Agent,会被「不自动转发」早退。
判空时的 `[diag]` 行已长期保留 —— 下次复现可直接看出是「没有文本」还是「没有事件」。
This commit is contained in:
2026-10-04 08:04:10 +08:00
parent 24f7ed99fc
commit bf4fa6e587
3 changed files with 259 additions and 7 deletions

View File

@ -499,7 +499,7 @@ export function apply(ctx: any, config: PluginConfig): void {
return live.map((a: any) => ({
id: String(a?.id ?? ''),
cwd: a?.session?.header?.cwd ?? '',
title: modelTitle(a?.session?.events ?? []),
title: modelTitle(sessionEvents(a)),
updatedAt: a?.session?.header?.createdAt,
origin: a?.session?.header?.origin,
delegationDepth: a?.session?.header?.delegationDepth,
@ -1030,6 +1030,29 @@ export function apply(ctx: any, config: PluginConfig): void {
return `${path}${sep}session_id=${encodeURIComponent(sid)}`;
}
/**
* 取 DSH 会话的事件序列。
*
* ★★ 2026-10-04 实测定位(两处同类缺陷的共同根因):
* dsh 0.2.0-rc.2 的 session 对象**没有 `events` 字段**。实测键表:
* [log, surfaceManager, header, inheritedEventCount, firstLiveSeq,
* firstLifecycleSeq, eventsSnapshot, headerFold, headerFoldSeq,
* contextFold, contextFoldSeq, toolHistoryProjection, toolHistorySeq, …]
* 真正的事件序列是 **`log`**(实测 Array(26),末条 type=turn/end、键
* [type,seq,time,data],与磁盘 session.v4.jsonl.zstd 逐字一致)。
* `eventsSnapshot` 名字像但实测是 **null**。
*
* ⇒ `session?.events ?? []` **永远**兜成空数组。生产后果:
* ① 自动转发取不到 assistant 文本 ⇒ 每封人类来信都被发「处理失败:空回复」
* ② 会话标题同步取不到 ⇒ title 永远空
*
* 为什么要有这个函数而不是就地写:这两处曾经**各写各的**(一处已修、另一处还错着),
* 而纯函数测试抓不到「调用点用了哪个字段」。集中一处 + 下方判据钉住调用点。
*/
function sessionEvents(agentLike: any): any[] {
return agentLike?.session?.log ?? agentLike?.session?.events ?? [];
}
function findLiveDshSession(mailSessionID: string): { id: string; agent: any } | undefined {
const bound = sessionMap.peek(mailSessionID);
if (bound) {
@ -1935,8 +1958,31 @@ export function apply(ctx: any, config: PluginConfig): void {
// ⇒ 修法:**idle 时取不到文本就稍等再取**,而不是当场判失败。
// events 是会补上的(磁盘已有 ⇒ 内存最终也会有),重试即可命中。
// 下面的诊断日志会打出重试前后的 events 长度,验证这个推断。
// events 这个绑定后面还要用(relay_key 与 modelTitle),故保留。
const events = (): any[] => agent.session?.events ?? [];
// ★★ 2026-10-04 实测定位(用户报「dsh 还是不回信」,生产复现两次):
//
// 根因:**`agent.session.events` 这个字段不存在**。dsh 0.2.0-rc.2 的 session
// 实测键表(由本插件在 idle 时打印):
// [log, surfaceManager, header, inheritedEventCount, firstLiveSeq,
// firstLifecycleSeq, eventsSnapshot, headerFold, headerFoldSeq,
// contextFold, contextFoldSeq, toolHistoryProjection, toolHistorySeq,
// derived, derivedNodes, derivedGeneration]
// 没有 `events`。⇒ `agent.session?.events ?? []` **永远**兜成空数组
// ⇒ 每一次都判「空回复」⇒ 给人类发件人发「处理失败」通知。
//
// 真正的事件序列在 **`session.log`**(实测 Array(26),末条 type=turn/end、
// 键 [type,seq,time,data] —— 与磁盘 session.v4.jsonl.zstd 的结构逐字一致,
// 磁盘 27 条 / 内存 26 条,差 1 条是 idle 瞬间最后一条尚未写入)。
// `eventsSnapshot` 名字像但实测是 **null**,别被它骗。
//
// ⇒ 本行改为读 `session.log`。同时保留 events 作为回退:万一是更老的
// 运行时(字段名不同),至少不会比原来更差。
//
// ★ 为什么前面的 519bdf7 没能修好:那一版改的是 `lastAssistantText` 的
// **解析逻辑**(从后往前找带 text 的那条),而这里错的是**数据源字段名** ——
// 解析逻辑本来就是对的。中间我还误判成「idle 时事件还没到」并加了重试,
// 那是错的:不是没到,是压根没有那个字段。观测 events=0(重试 750ms 后仍为 0)
// 直接否定了时间假设。
const events = (): any[] => sessionEvents(agent);
const readAssistantText = (): string => lastAssistantText(events());
let lastText = readAssistantText();
let retryNote = '';
@ -1949,16 +1995,19 @@ export function apply(ctx: any, config: PluginConfig): void {
if (lastText) retryNote = ` (第 ${attempt} 次重试才拿到文本)`;
}
}
{
const evs = agent.session?.events ?? [];
if (!lastText) {
// 长期观测(保留):判空时打出事件序列的实况,便于下次直接看出是「没有文本」
// 还是「没有事件」。2026-10-04 那次根因就是靠它才定案的 —— 之前只有
// 「已发空回复失败通知」这一行,看不出到底是取不到还是取到空。
const evs = events();
const tail = evs.slice(-4).map((e: any) => {
const c = (e as any)?.data?.message?.content;
const kinds = Array.isArray(c) ? c.map((b: any) => b?.type).join('+') : typeof c;
return `${(e as any)?.type ?? '?'}(${kinds})`;
});
console.error(
`[dsh-mail-bridge] [diag] events=${evs.length} 末尾=[${tail.join(' ')}] ` +
`hasText=${lastText.length > 0}${retryNote}`,
`[dsh-mail-bridge] [diag] 无文本:events=${evs.length} ` +
`末尾=[${tail.join(' ')}]${retryNote}`,
);
}
if (!lastText) {