## 现象(用户实测) 用户看到 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 零错。
131 lines
5.7 KiB
JavaScript
131 lines
5.7 KiB
JavaScript
/**
|
||
* 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 '';
|
||
}
|