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:
@ -189,6 +189,45 @@ test('lastAssistantText 只有 tool-call 时返回空串(没有可回信的内
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* ★★ 2026-10-04 实测回归:**事件不在 `session.events`,而在 `session.log`**
|
||||
*
|
||||
* 现象:模型明明写了完整回信(磁盘 session.v4.jsonl.zstd 里
|
||||
* `assistant/message 块=['text']`),插件却每次都发「处理失败:空回复」。
|
||||
*
|
||||
* 根因:dsh 0.2.0-rc.2 的 session 对象实测键表为
|
||||
* [log, surfaceManager, header, inheritedEventCount, firstLiveSeq,
|
||||
* firstLifecycleSeq, eventsSnapshot, headerFold, headerFoldSeq,
|
||||
* contextFold, contextFoldSeq, toolHistoryProjection, toolHistorySeq, …]
|
||||
* **没有 `events`** ⇒ `agent.session?.events ?? []` 永远兜成空数组。
|
||||
* 注意 `eventsSnapshot` 名字像但实测是 **null**。
|
||||
*
|
||||
* ⇒ 这格钉住「从 session.log 取得到、从 session.events 取不到」,
|
||||
* 防止数据源再漂回那个不存在的字段。
|
||||
*/
|
||||
test('★ 数据源是 session.log(session.events 不存在 ⇒ 旧写法必然取空)', () => {
|
||||
const log = [
|
||||
{ type: 'step/start', seq: 1, time: 'now', data: {} },
|
||||
assistantMsg([{ type: 'tool-call', id: 'c1', name: 'read_mail', arguments: '{}' }]),
|
||||
assistantMsg([{ type: 'text', text: '这是模型写的回信文本' }]),
|
||||
{ type: 'turn/end', seq: 4, time: 'now', data: {} },
|
||||
];
|
||||
const realSession = { log, eventsSnapshot: null }; // ← dsh 0.2 的真实形状
|
||||
|
||||
// 复现旧写法的后果(这就是生产上「模型回了信却报空回复」的形状)
|
||||
assert.equal(
|
||||
lastAssistantText(realSession.events ?? []),
|
||||
'',
|
||||
'★ session.events 不存在 ⇒ 旧写法必然取空',
|
||||
);
|
||||
// 修法:从 log 取
|
||||
assert.equal(
|
||||
lastAssistantText(realSession.log ?? realSession.events ?? []),
|
||||
'这是模型写的回信文本',
|
||||
'★ 必须能从 session.log 取到 assistant 文本',
|
||||
);
|
||||
});
|
||||
|
||||
test('lastAssistantText 容错:空日志、非数组、结构缺失', () => {
|
||||
assert.equal(lastAssistantText([]), '');
|
||||
assert.equal(lastAssistantText(undefined), '');
|
||||
|
||||
Reference in New Issue
Block a user