Files
MailUI4Agents/plugins/dsh-mail-bridge/test/message.test.mjs
JianFeeeee 519bdf7093 fix(dsh)★: 模型写好回信却被当成「空回复」丢弃 —— lastAssistantText 取错那条事件
## 现象(用户实测)

用户看到 dsh 的回复**内容完全正常**(一段完整的「你好 jianf!收到你的问候了 👋…」),
但 AgentMail 里收到的是 dsh 发来的 `处理失败: 打招呼`,正文写着:

    这封邮件的处理轮次已结束,但没有产出任何回复文本。

## 根因(从真实会话日志取证,不是推断)

取那次会话的 `session.v4.jsonl.zstd`(zstd 解压 84KB),事件统计:

    assistant/message: 2 条        ← 两条!

    最后一条 content = [{"type":"tool-call","name":"read_mail",...}]   ← 无 text
    往前一条        = [{"type":"text","text":"邮件已读取 —— …你好的问候了 👋…"}]

dsh 的一次 step 里,模型先出文本、再发工具调用,会落成**两条**
`assistant/message`;最后那条往往只有 tool-call 块。

而 `lastAssistantText` 是「从后往前找,取到**第一条** assistant/message
就 return —— 不管那条里有没有 text 块」:

    if (!Array.isArray(blocks)) return '';      ← 直接判空
    return blocks.filter(b => b?.type === 'text')…   ← 无条件 return

⇒ 过滤后是空串 ⇒ 判定空回复 ⇒ **静默丢弃模型已经写好的回信**,
改发一封「处理失败」通知给发件人。

## 修法

`return ''` 改 `continue`;只在**真的取到文本**时才 `return`。

## 为什么既有测试全绿

原有 5 格测的**全是单条** `assistant/message`(或只有 reasoning/tool-call 的
单条),与本缺陷正交。新增 2 格用的是**从真实日志取的事件形状**:

* 最后一条只有 tool-call ⇒ 必须往前找到有文本的那条
* **反向对照**:全部无文本时**仍**返回空串,且 `content` 不是数组时应
  `continue` 而非当成空回复 —— 保证「空回复」判定没有被放宽成
  「几乎总有回复」,否则那封失败通知就没有存在意义了

**变异验证**:把实现还原成原写法 → 21 pass / **2 fail**(正是新增那两格)。

## 影响面

仅 dsh:pi / opencode 走各自宿主的 API 取回复,不共用这个函数
(实测两边的 `lib/` 里没有 `assistant/message` 字面量)。
实测那次只有 34 秒就走到失败通知,是个高频路径而非边缘情况。

dsh 435 格全绿、tsc 零错。
2026-10-03 22:24:32 +08:00

249 lines
10 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 不是数组时应继续往前找,而不是当成空回复',
);
});
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')]), '带空白的标题');
});