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