diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index 20991e0..1dff4b9 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -1917,8 +1917,50 @@ export function apply(ctx: any, config: PluginConfig): void { if (!mctx) return; // 取最后一条 assistant 消息的文本 - const events = agent.session?.events ?? []; - const lastText = lastAssistantText(events); + // + // ★★ 2026-10-04 实测定位(用户报「dsh 还是不回信」,生产复现 22:56): + // + // 那次磁盘上的最后一轮事件是 + // seq=50 step/start + // seq=51 assistant/message 块=['text'] ← 模型确实写了完整文本 + // seq=52 step/end seq=53 turn/end + // 按 lastAssistantText 的逻辑(从后往前找第一条带 text 的 assistant) + // **必然会取到 seq=51**,可它仍被判成空回复。 + // + // ⇒ 唯一解释:`agent/status: idle` 触发的那一刻, + // `agent.session.events` 里**还没有** seq=51 —— 也就是宿主在 + // 最后一条 assistant 消息落入内存**之前**就发了 idle。 + // 投影缓存里也没有 events(只有 identity/rows),证实它不是从投影读的。 + // + // ⇒ 修法:**idle 时取不到文本就稍等再取**,而不是当场判失败。 + // events 是会补上的(磁盘已有 ⇒ 内存最终也会有),重试即可命中。 + // 下面的诊断日志会打出重试前后的 events 长度,验证这个推断。 + // events 这个绑定后面还要用(relay_key 与 modelTitle),故保留。 + const events = (): any[] => agent.session?.events ?? []; + const readAssistantText = (): string => lastAssistantText(events()); + let lastText = readAssistantText(); + let retryNote = ''; + if (!lastText) { + // 重试三次、每次 250ms(合计 750ms):远小于一轮模型思考的量级, + // 不会拖慢链路;而 seq=51 落盘只需几毫秒,三次足够覆盖。 + for (let attempt = 1; attempt <= 3 && !lastText; attempt++) { + await new Promise((r) => setTimeout(r, 250)); + lastText = readAssistantText(); + if (lastText) retryNote = ` (第 ${attempt} 次重试才拿到文本)`; + } + } + { + const evs = agent.session?.events ?? []; + const tail = evs.slice(-4).map((e: any) => { + const c = (e as any)?.data?.message?.content; + const kinds = Array.isArray(c) ? c.map((b: any) => b?.type).join('+') : typeof c; + return `${(e as any)?.type ?? '?'}(${kinds})`; + }); + console.error( + `[dsh-mail-bridge] [diag] events=${evs.length} 末尾=[${tail.join(' ')}] ` + + `hasText=${lastText.length > 0}${retryNote}`, + ); + } if (!lastText) { // 模型 idle 但没有任何 assistant 文本:空回复/被中断在文本前。 // 此前静默返回 —— 发件人等不到任何回复(缺的是 B-6 的「模型全部失败」 @@ -1961,7 +2003,7 @@ export function apply(ctx: any, config: PluginConfig): void { reply_to: mctx.mailID || '', // relay + relay_key:走免配额通道(harness 的搬运不该收费) relay: 'summary', - relay_key: clampRelayKey(`${agent.id}:${events.length}`), + relay_key: clampRelayKey(`${agent.id}:${events().length}`), }); relayedSummaries.set(String(agent.id), lastText); explicitSends.delete(String(agent.id)); @@ -1976,7 +2018,7 @@ export function apply(ctx: any, config: PluginConfig): void { // DSH 没有 opencode 那样的 slug,别名由标题派生(slugFromTitle 会去掉 // `.` `@` `/` 这些寻址分隔符 —— 留在别名里会让它自己被解析器切开)。 // 别名与标题一起发:服务端撞名时自动追 -2/-3 后缀,并尊重人工改过的别名。 - const title = modelTitle(events); + const title = modelTitle(events()); if (title && syncedTitles.get(String(agent.id)) !== title) { syncedTitles.set(String(agent.id), title); const alias = slugFromTitle(title);