现象: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。两条路径结论相反
是设计使然,不是矛盾。
200 lines
7.9 KiB
JavaScript
200 lines
7.9 KiB
JavaScript
/**
|
||
* 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')]), '带空白的标题');
|
||
});
|