fix(loop): keep cross-batch voice success and gate speak without dispatcher (M57)

两处 review 必修(review-m57 r1):

P2-1 跨批覆盖:applyBatchVoice 原先无条件用后一批的语音结论覆盖
前一批,于是「先成功合成 abc.wav、下一批换个参数再试却失败」会把
Result.Voice 退化成 tts_failed —— 音频已落盘,退回文本既无收益也
违背那次表达。改为守 captureVoice 的同一条优先级:已落盘的音频不被
后续批次的失败抹掉;先降级后成功仍然覆盖(后到的成功即最终语音)。

P2-2 nil-dispatcher 安全门:speak 由循环自己承载、无 dispatcher 也
能执行(invokeOne 短路),但 applyToolAdmissions 在 dispatcher 缺席
时整段跳过安全门,使「没配 dispatcher」成为绕过 design/11 §3 的
旁路。新增 applyDispatcherlessVoiceGate:与策略引擎对未登记工具的
口径一致(默认关闭、只有 owner 放行),拒绝走同一条 not_allowed
软结果与 tool.reject.not_allowed 审计通道;并改正该处过时注释。

两条回归测试都做过改前红/改后绿:
- TestApplyBatchVoice_KeepsCrossBatchSuccess:注释掉守卫即报
  「voice = ... FallbackReason:tts_failed, want abc.wav」
- TestApplyToolAdmissions_DispatcherlessSpeakGate:去掉 else 分支即报
  「env group_member run = true, want false」

验收:go build ./... / go vet ./... / go test ./... 全绿。
This commit is contained in:
AskaEth committed 2026-10-27 22:00:18 +08:00
1 parent c9da488749
commit a0b4c941e4
5 files changed
+159 -4

No files matched your search

@@ -238,6 +238,39 @@ func TestInvokeTool_SpeakWithoutDispatcher(t *testing.T) {
}
}
// TestApplyBatchVoice_KeepsCrossBatchSuccess 锁住跨批的语音优先级:批次之间
// 是两次独立结论,后一批换个参数失败时**绝不能**把前一批已落盘的音频抹成
// tts_failed —— 交付物已经存在,退回文本既没有收益也违背她那次表达的意愿。
// 反过来(先降级后成功)后到的成功仍然覆盖;后一批没有 speak 时保留结论。
func TestApplyBatchVoice_KeepsCrossBatchSuccess(t *testing.T) {
t.Parallel()
delivered := &OutboundVoice{Requested: true, Name: "abc.wav", Mime: "audio/wav", Size: 3}
degraded := &OutboundVoice{Requested: true, FallbackReason: speakReasonFailed}
res := &Result{}
applyBatchVoice(res, &toolRunResult{voice: delivered})
applyBatchVoice(res, &toolRunResult{voice: degraded})
if res.Voice == nil || res.Voice.Name != "abc.wav" || res.Voice.FallbackReason != "" {
t.Fatalf("success then cross-batch failure voice = %+v, want the delivered abc.wav", res.Voice)
}
// 先降级后成功:后到的成功就是这条消息最终的语音。
res = &Result{}
applyBatchVoice(res, &toolRunResult{voice: degraded})
applyBatchVoice(res, &toolRunResult{voice: delivered})
if res.Voice == nil || res.Voice.Name != "abc.wav" || res.Voice.FallbackReason != "" {
t.Fatalf("degraded then success voice = %+v, want abc.wav", res.Voice)
}
// 后一批没有 speak:前面那批的结论照旧算数。
res = &Result{}
applyBatchVoice(res, &toolRunResult{voice: delivered})
applyBatchVoice(res, &toolRunResult{})
if res.Voice == nil || res.Voice.Name != "abc.wav" {
t.Fatalf("batch without voice = %+v, want the earlier abc.wav", res.Voice)
}
}
// TestVoiceStoreFunc 锁住接线方用的函数适配器:它满足 VoiceStore,且
// 原样转发原始名与字节(接线方正是靠它把 documents.Store 接到接缝上,
// 而不让循环反向 import documents)。