Files
MailUI4Agents/plugins/dsh-mail-bridge/lib/message.js
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

131 lines
5.7 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 消息与会话日志的纯函数工具。
*
* 单独放一个模块是为了能被单测覆盖 —— 其中 userMessage() 的形状曾让整个插件
* 卡了一下午(见该函数注释),这种「错了不报错、只在深处炸一个无关的类型错误」
* 的约定必须被测试钉住。
*/
import { randomUUID } from 'node:crypto';
/**
* 构造 DSH 的 UserMessage。
*
* **这是 followup()/steer() 唯一接受的形状。** DSH 的 `agent.followup(message)`
* 要的是完整的 UserMessage(`content` + `source`),不是 opencode 那种 parts 数组。
* 传数组进去不会当场报错:agent-loop 会一路走到 preStep 里读 `message.source.kind`,
* 然后抛 `Cannot read properties of undefined (reading 'kind')` —— 错误信息落在
* agent-loop 内部,完全不指向调用点。
*
* ## 为什么必须带 `id` 和 `role`(2026-09-19 补)
*
* DSH 0.1.5 的 inbox 把「待处理消息」按 `message.id` 去重:
* `dsh-agent-loop/lib/index.js` 的投影(splice apply)与 `mutate()` 各维护一个
* `Set`,一旦 `ids.has(message.id)` 就抛 `message "${message.id}" is already pending`。
* 而这里原先**不产出 id**,于是每条消息的 `message.id` 都是 `undefined`:
*
* - 第二条消息进 inbox 时,`Set` 里已经有 `undefined` ⇒ 抛
* `message "undefined" is already pending`;
* - 该错误由投影抛出,会话日志的 replay 也随之失败。
*
* 症状因此是「第一条能处理、第二条起全挂」,且错误信息里的 `undefined` 不指向
* 调用点。日志里最早的同类记录在 2026-09-07,累计 50+ 次。
*
* 官方形状由 `@deepseek-ai/dsh-llm` 的 `createMessage()` 给出:
* `{ id: brandString(randomUUID()), role, content, source }`。这里不能直接 import
* 它(plugins 不解析 dsh 内部包),所以按同一形状本地实现。
*
* `role` 同样是必需的:`assertMessageEventShape()`(dsh-session)会校验
* `user/message` 的 `role === 'user'`,缺了就报 `message must have role "user"`。
*
* @param {string} text 正文
* @returns {{id: string, role: 'user', content: {type: 'text', text: string}[], source: {kind: 'user'}}}
*/
export function userMessage(text) {
return {
id: randomUUID(),
role: 'user',
content: [{ type: 'text', text: String(text) }],
source: { kind: 'user' },
};
}
/** 去掉已有的 Re: 前缀,避免 Re: Re: Re: 无限叠加。 */
export function stripRe(subject) {
return String(subject ?? '').replace(/^(\s*Re:\s*)+/i, '');
}
/**
* 回信主题:原主题前加一个 Re:,空主题给一个兜底。
* @param {string} subject 来信主题
* @param {string} fallback 主题为空时用的标题
*/
export function replySubject(subject, fallback = 'DSH 回复') {
const base = stripRe(subject).trim();
return base ? `Re: ${base}` : fallback;
}
/**
* 从会话事件日志里取**最后一条有可见文本的** 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":"邮件已读取 —— …你<E280A6><E4BDA0><EFBFBD>的问候了 👋…"}] ← 用户看到的那段
* 原实现取最后一条 ⇒ 过滤后是空串 ⇒ 判定「空回复」⇒ 静默丢弃模型已经写好的
* 回信,改发一封「处理失败」通知给发件人。
*
* ⇒ `return ''` 改`continue`;只在**真的取到文本**时才 return。
* pi / opencode 不受影响:它们走各自宿主的 API 取回复,不共用这个函数
* (实测两边的 lib/ 里没有 assistant/message 字面量)。
*
* @param {readonly any[]} events session.events
* @returns {string} 文本,找不到时为空串
*/
export function lastAssistantText(events) {
const list = Array.isArray(events) ? events : [];
for (let i = list.length - 1; i >= 0; i--) {
const ev = list[i];
if (ev?.type !== 'assistant/message') continue;
const blocks = ev.data?.message?.content;
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 '';
}
/**
* 从会话事件日志里取最后一次 session/title 的标题。
*
* DSH 首轮结束后由模型生成摘要标题,之前是 `source.kind === 'fallback'` 的占位
* (内容就是用户第一句话的截断)。占位标题不值得回写给 AgentMail,会把
* 「你收到一封新邮件(AgentMail)」这种插件自己的提示词当成会话标题。
*
* @param {readonly any[]} events session.events
* @returns {string} 模型生成的标题,没有则空串
*/
export function modelTitle(events) {
const list = Array.isArray(events) ? events : [];
for (let i = list.length - 1; i >= 0; i--) {
const ev = list[i];
if (ev?.type !== 'session/title') continue;
const kind = ev.data?.source?.kind;
if (kind === 'fallback') return '';
const title = ev.data?.title;
return typeof title === 'string' ? title.trim() : '';
}
return '';
}