diff --git a/plugins/dsh-mail-bridge/lib/message.js b/plugins/dsh-mail-bridge/lib/message.js index 5c8371d..84abf8b 100644 --- a/plugins/dsh-mail-bridge/lib/message.js +++ b/plugins/dsh-mail-bridge/lib/message.js @@ -66,10 +66,26 @@ export function replySubject(subject, fallback = 'DSH 回复') { } /** - * 从会话事件日志里取最后一条 assistant 消息的可见文本。 + * 从会话事件日志里取**最后一条有可见文本的** assistant 消息。 * * 只取 `type === 'text'` 的块:reasoning 块是模型的思考过程,不该出现在邮件里。 * + * ★ 2026-10-03 修(dsh 独有缺陷,实测):原实现是「从后往前找,取到**第一条** + * `assistant/message` 就 return」——**不管那条里有没有 text 块**。 + * 而 dsh 的一次 step 里,模型先出文本、再发工具调用,会落成**两条** + * `assistant/message`;最后那条往往**只有 tool-call 块**。 + * + * 实测那次(用户可见现象是「模型回复得好好的,插件却发了空回复失败通知」): + * events 里 assistant/message 有 **2** 条 + * 最后一条 content = [{"type":"tool-call","name":"read_mail",...}] ← 无 text + * 往前一条 = [{"type":"text","text":"邮件已读取 —— …你���的问候了 👋…"}] ← 用户看到的那段 + * 原实现取最后一条 ⇒ 过滤后是空串 ⇒ 判定「空回复」⇒ 静默丢弃模型已经写好的 + * 回信,改发一封「处理失败」通知给发件人。 + * + * ⇒ `return ''` 改`continue`;只在**真的取到文本**时才 return。 + * pi / opencode 不受影响:它们走各自宿主的 API 取回复,不共用这个函数 + * (实测两边的 lib/ 里没有 assistant/message 字面量)。 + * * @param {readonly any[]} events session.events * @returns {string} 文本,找不到时为空串 */ @@ -79,12 +95,13 @@ export function lastAssistantText(events) { const ev = list[i]; if (ev?.type !== 'assistant/message') continue; const blocks = ev.data?.message?.content; - if (!Array.isArray(blocks)) return ''; - return blocks + if (!Array.isArray(blocks)) continue; + const text = blocks .filter((b) => b?.type === 'text' && typeof b.text === 'string') .map((b) => b.text) .join('\n') .trim(); + if (text) return text; } return ''; } diff --git a/plugins/dsh-mail-bridge/test/message.test.mjs b/plugins/dsh-mail-bridge/test/message.test.mjs index a17bb8d..08a3803 100644 --- a/plugins/dsh-mail-bridge/test/message.test.mjs +++ b/plugins/dsh-mail-bridge/test/message.test.mjs @@ -140,6 +140,55 @@ test('lastAssistantText 只有 tool-call 时返回空串(没有可回信的内 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), '');