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 零错。
This commit is contained in:
@ -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":"邮件已读取 —— …你<E280A6><E4BDA0><EFBFBD>的问候了 👋…"}] ← 用户看到的那段
|
||||
* 原实现取最后一条 ⇒ 过滤后是空串 ⇒ 判定「空回复」⇒ 静默丢弃模型已经写好的
|
||||
* 回信,改发一封「处理失败」通知给发件人。
|
||||
*
|
||||
* ⇒ `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 '';
|
||||
}
|
||||
|
||||
@ -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), '');
|
||||
|
||||
Reference in New Issue
Block a user