Files
MailUI4Agents/plugins/dsh-mail-bridge/test/message.test.mjs
JianFeeeee bf4fa6e587 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]` 行已长期保留 —— 下次复现可直接看出是「没有文本」还是「没有事件」。
2026-10-04 08:04:10 +08:00

288 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* dsh-mail-bridge 纯函数测试。
*
* 重点不是覆盖率,而是钉住几条「错了不当场报错」的约定:
* 1. userMessage() 的形状 —— 传错会在 agent-loop 深处抛一个不指向调用点的
* `Cannot read properties of undefined (reading 'kind')`,实测卡了一下午
* 2. modelTitle() 必须拒绝 fallback 占位标题 —— 否则会把插件自己的提示词
* 「你收到一封新邮件(AgentMail)」当成会话标题回写给 AgentMail
* 3. lastAssistantText() 只取 text 块 —— reasoning 是思考过程,不该进邮件
*
* node --test test/
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
userMessage,
stripRe,
replySubject,
lastAssistantText,
modelTitle,
} from '../lib/message.js';
// ─── userMessage:DSH followup() 的唯一合法形状 ───
test('userMessage 产出 id + role + content + source', () => {
const m = userMessage('你好');
assert.deepEqual(Object.keys(m).sort(), ['content', 'id', 'role', 'source']);
assert.equal(typeof m.id, 'string');
assert.ok(m.id.length > 0, 'id 不能是空串');
assert.equal(m.role, 'user');
assert.deepEqual(m.content, [{ type: 'text', text: '你好' }]);
assert.deepEqual(m.source, { kind: 'user' });
});
test('不变量:两条消息的 id 必须不同 —— inbox 按 id 去重', () => {
// 这条是本文件存在的理由之二。DSH 0.1.5 的 inbox 投影与 mutate() 各自维护
// 一个 `Set`,遇到重复 id 就抛 `message "${id}" is already pending`。
// 早期实现不产出 id ⇒ 每条都是 undefined ⇒ **第二条消息必挂**
// ("message \"undefined\" is already pending",日志里累计 50+ 次)。
const a = userMessage('第一封');
const b = userMessage('第二封');
assert.notEqual(a.id, b.id, '同一会话连投两封邮件必须拿到不同 id');
assert.equal(new Set([a.id, b.id]).size, 2);
});
test('不变量:userMessage 必须带 role —— assertMessageEventShape 会校验', () => {
// dsh-session 的 assertMessageEventShape() 对 user/message 要求
// `message.role === 'user'`,缺了会报 `message must have role "user"`。
const m = userMessage('x');
assert.equal(m.role, 'user');
});
test('不变量:userMessage 必须带 source.kind —— agent-loop 的 preStep 直接读它', () => {
// 这一条是本文件存在的理由。少了 source.kind,DSH 抛的错落在 agent-loop 内部
// (`Cannot read properties of undefined (reading 'kind')`),既不指向调用点,
// 也不说是哪个字段,turn 会一 start 就 end、模型请求根本不发出去。
for (const text of ['x', '', '多行\n文本', '🙂']) {
const m = userMessage(text);
assert.equal(typeof m.source?.kind, 'string', 'source.kind 必须是字符串');
assert.equal(m.source.kind, 'user');
assert.ok(Array.isArray(m.content), 'content 必须是数组');
}
});
test('不变量:userMessage 返回的不是裸数组(opencode 的 parts 形状)', () => {
// opencode 的 promptAsync 收 parts 数组,DSH 收完整 UserMessage。
// 把 opencode 的写法照抄过来正是那次故障的起因。
const m = userMessage('x');
assert.ok(!Array.isArray(m), 'followup() 不接受裸数组');
});
test('userMessage 把非字符串转成字符串', () => {
assert.equal(userMessage(42).content[0].text, '42');
});
// ─── stripRe / replySubject ───
test('stripRe 去掉单个与叠加的 Re: 前缀', () => {
assert.equal(stripRe('Re: 主题'), '主题');
assert.equal(stripRe('Re: Re: Re: 主题'), '主题');
assert.equal(stripRe('RE: 主题'), '主题');
assert.equal(stripRe('主题'), '主题');
});
test('stripRe 不动正文里的 Re:', () => {
assert.equal(stripRe('关于 Re: 这个写法'), '关于 Re: 这个写法');
});
test('replySubject 只加一层 Re:', () => {
assert.equal(replySubject('缓存选型'), 'Re: 缓存选型');
assert.equal(replySubject('Re: 缓存选型'), 'Re: 缓存选型');
assert.equal(replySubject('Re: Re: 缓存选型'), 'Re: 缓存选型');
});
test('replySubject 空主题走兜底而不是产出裸 "Re: "', () => {
assert.equal(replySubject(''), 'DSH 回复');
assert.equal(replySubject(' '), 'DSH 回复');
assert.equal(replySubject(undefined), 'DSH 回复');
assert.equal(replySubject('', '自定义'), '自定义');
});
// ─── lastAssistantText ───
const assistantMsg = (blocks) => ({
type: 'assistant/message',
data: { message: { role: 'assistant', content: blocks } },
});
test('lastAssistantText 取最后一条 assistant 消息', () => {
const events = [
assistantMsg([{ type: 'text', text: '第一轮' }]),
{ type: 'tool/call', data: {} },
assistantMsg([{ type: 'text', text: '第二轮' }]),
];
assert.equal(lastAssistantText(events), '第二轮');
});
test('lastAssistantText 丢掉 reasoning 块', () => {
const events = [assistantMsg([
{ type: 'reasoning', text: '让我想想……用户要的是' },
{ type: 'text', text: '结论:可以。' },
])];
assert.equal(lastAssistantText(events), '结论:可以。');
});
test('lastAssistantText 拼接多个 text 块', () => {
const events = [assistantMsg([
{ type: 'text', text: '第一段' },
{ type: 'text', text: '第二段' },
])];
assert.equal(lastAssistantText(events), '第一段\n第二段');
});
test('lastAssistantText 只有 tool-call 时返回空串(没有可回信的内容)', () => {
const events = [assistantMsg([
{ type: 'reasoning', text: '先读收件箱' },
{ type: 'tool-call', id: 'c1', name: 'read_inbox' },
])];
assert.equal(lastAssistantText(events), '');
});
/**
* ★ 2026-10-03 实测回归:一条 assistant 消息里**只有 tool-call、没有 text**。
*
* 用户可见的现象是「模型回复得好好的,插件却发了『处理失败:空回复』通知」。
* 取自那次真实会话的事件日志(zstd 解压后统计 assistant/message = 2 条):
*
* 最后一条 content = [{"type":"tool-call","name":"read_mail",...}] ← 无 text
* 往前一条 = [{"type":"text","text":"邮件已读取 —— …"}] ← 用户看到的那段
*
* 原实现「取到最后一条 assistant/message 就 return,不管它有没有 text」
* ⇒ 过滤后是空串 ⇒ 判定空回复 ⇒ **静默丢弃模型已写好的回信**。
*
* 上面那几格测的都是**单条** assistant/message,所以全绿 —— 与本缺陷无关。
*/
test('★ 最后一条 assistant 只有 tool-call 时,必须往前找有文本的那条', () => {
const events = [
assistantMsg([{ type: 'text', text: '邮件已读取 —— 简单回个招呼' }]),
{ type: 'tool/call', data: { name: 'read_mail' } },
assistantMsg([{ type: 'tool-call', id: 'c1', name: 'read_mail', arguments: '{}' }]),
];
assert.equal(
lastAssistantText(events),
'邮件已读取 —— 简单回个招呼',
'★ 模型已经写好回信了,却因最后一条是工具调用而当成空回复丢弃',
);
});
// 反向对照:真的没有任何文本时**仍**要返回空串。
// 若为了修上面那格而把判定放宽成「几乎总有回复」,这两行会红。
test('★ 全部 assistant 消息都无文本时,仍返回空串(空回复判定不被放宽)', () => {
assert.equal(
lastAssistantText([
assistantMsg([{ type: 'tool-call', id: 'c1', name: 'x' }]),
assistantMsg([{ type: 'reasoning', text: '思考' }]),
]),
'',
'★ 真的没有文本时必须仍是空串(否则「空回复」判定形同虚设)',
);
// 结构异常的那条也不能中断整个搜索
assert.equal(
lastAssistantText([
assistantMsg([{ type: 'text', text: '早先那条有文本' }]),
{ type: 'assistant/message', data: { message: { content: '不是数组' } } },
]),
'早先那条有文本',
'★ content 不是数组时应继续往前找,而不是当成空回复',
);
});
/**
* ★★ 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), '');
assert.equal(lastAssistantText(null), '');
assert.equal(lastAssistantText([{ type: 'assistant/message' }]), '');
assert.equal(lastAssistantText([{ type: 'assistant/message', data: {} }]), '');
});
test('lastAssistantText 跳过非 assistant/message 事件', () => {
const events = [
assistantMsg([{ type: 'text', text: '正文' }]),
{ type: 'step/end', data: {} },
{ type: 'turn/end', data: {} },
];
assert.equal(lastAssistantText(events), '正文');
});
// ─── modelTitle ───
const titleEvent = (title, kind) => ({
type: 'session/title',
data: { title, source: { kind } },
});
test('modelTitle 取模型生成的标题', () => {
const events = [titleEvent('处理新邮件任务并回复', 'provider')];
assert.equal(modelTitle(events), '处理新邮件任务并回复');
});
test('不变量:modelTitle 拒绝 fallback 占位标题', () => {
// DSH 在模型生成真标题之前会先落一个 fallback 标题,内容是用户第一句话的截断。
// 而「用户第一句话」是插件自己拼的提示词,回写过去等于把
// 「你收到一封新邮件(AgentMail)」当成会话标题。
const events = [titleEvent('你收到一封新邮件(AgentMail)', 'fallback')];
assert.equal(modelTitle(events), '');
});
test('modelTitle 取最后一次 session/title —— fallback 之后的 provider 标题算', () => {
const events = [
titleEvent('你收到一封新邮件(AgentMail)', 'fallback'),
{ type: 'assistant/chunk', data: {} },
titleEvent('缓存层选型评估邮件回复', 'provider'),
];
assert.equal(modelTitle(events), '缓存层选型评估邮件回复');
});
test('modelTitle 容错:无标题事件、结构缺失、非字符串', () => {
assert.equal(modelTitle([]), '');
assert.equal(modelTitle(undefined), '');
assert.equal(modelTitle([{ type: 'session/title' }]), '');
assert.equal(modelTitle([{ type: 'session/title', data: {} }]), '');
assert.equal(modelTitle([{ type: 'session/title', data: { title: 42 } }]), '');
});
test('modelTitle 修掉标题两端空白', () => {
assert.equal(modelTitle([titleEvent(' 带空白的标题 ', 'provider')]), '带空白的标题');
});