Files
MailUI4Agents/plugins/dsh-mail-bridge/test/message.test.mjs
JianFeeeee b4a8f74ae5 修复: dsh 邮件通道全断的**两侧**根因(桥侧不产 message id 是真正在写的那一处)
现象:dsh 的邮件通道全断。老会话读不出来 ⇒ 桥报 SessionQueryError ⇒ 按"不在磁盘"
处理 ⇒ 再 create 撞 `already exists`。修好读路径之后又立刻暴露下一层
`message "undefined" is already pending`。

根因一(历史数据,dsh 侧):v0 会话的 `agent/inbox/spliced.inserted[]` 缺 `id`/`role`,
v0→v1 迁移第一步就拒绝。40 个真 mail-* 会话全部命中。

根因二(**仍在写**,本仓侧):`plugins/dsh-mail-bridge/lib/message.js` 的
`userMessage()` 只产出 `{content, source}`。DSH 0.1.5 的 inbox 按 `message.id` 去重
(`dsh-agent-loop` 的投影 apply() 与 mutate() 各维护一个 Set),id 全是 undefined
⇒ **第二条消息必挂**。日志里最早的同类记录在 2026-09-07,累计 50+ 次。
官方形状在 `@deepseek-ai/dsh-llm` 的 `createMessage()`({id, role, content, source}),
同一份 dsh 里其它插件都用官方的 createUserMessage(),只有这个桥手搓。
以前没炸是因为读路径先坏,根本走不到 followup。

本次改动
- message.js/.d.ts: userMessage() 补 id: randomUUID() 与 role:'user'
- test/message.test.mjs: 钉住「id 非空」「两条消息 id 必须不同」,用官方 inbox
  去重逻辑逐字复刻验证(修复前 message "undefined" is already pending,修复后 20 封全唯一)
- scripts/: repair-legacy-spliced-ids.mjs(v0,默认 dry-run)、
  repair-v3-usermessage-ids.mjs(v3)、verify-mail-sessions-readable.mjs
  (走生产真读路径 JsonlSessionPersistence.open,而非解码器口径)、两个 apply driver
- docs/DSH-0.1.5-MAIL-CHANNEL-ROOTCAUSE.md: 补执行结果与两处新事实

执行与验收(详见文档 §9-§15)
- v0 修 40 个、v3 修 2 个;逐文件解压后与备份 `cmp` **逐字节相等**,事件数 40/40 一致,
  零丢失(25.2MB→12.5MB 是单帧改 500 行/帧的重压缩,不是丢数据)
- 真 mail-* 会话最终 **41/41 可读**
- journal 里同一会话从 `already exists` 变为 `resume 续谈`,且持续增长
  (22647→22685 事件),最新 user/message 带真实 UUID;修复上线后 already pending 计数为 0
- 已在生产部署(deploy/redeploy-plugin.sh dsh,快照+原子软链+重启+后置验证全绿)

两个必须记住的坑
1. **校验与落盘不能共用同一批对象**:createRestore().decodeRow() 会原地改写入参
   (补全 dt 数组),污染后写出去会报 `released Session row N has seq gap`。
   这曾让 dry-run 说"40 个可修"、apply 只说"3 个"。
2. **判定磁盘健康只认 open()**:readSession() 走 SessionCorpus.load,命中有 live 会话时
   直接返回内存快照、不校验磁盘;open() 才走 validateStoredEvents。两条路径结论相反
   是设计使然,不是矛盾。
2026-09-19 12:03:34 +08:00

200 lines
7.9 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), '');
});
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')]), '带空白的标题');
});