## 用户报「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]` 行已长期保留 —— 下次复现可直接看出是「没有文本」还是「没有事件」。
288 lines
12 KiB
JavaScript
288 lines
12 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), '');
|
||
});
|
||
|
||
/**
|
||
* ★ 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')]), '带空白的标题');
|
||
});
|