两处 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 ./... 全绿。
297 lines
12 KiB
Go
297 lines
12 KiB
Go
package loop
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||
)
|
||
|
||
// 本文件锁住「speak 调用 → Result.Voice」这条出站链(M57):
|
||
// 成功、降级、没调三态在叶子层,加上一次端到端回合,证明模型面真的
|
||
// 被广告了 speak、调用真的被执行、结论真的落在 Result 上。
|
||
|
||
// TestCaptureVoice_States 锁住三态:成功带交付物、失败带原因码与
|
||
// 预算/耗时、没调(或只调了别的工具)返回 nil。
|
||
func TestCaptureVoice_States(t *testing.T) {
|
||
t.Parallel()
|
||
l := New(Options{})
|
||
|
||
if v := l.captureVoice(
|
||
[]agent.ToolCall{{Name: "terminal"}},
|
||
[]ToolOutcome{{Result: "ok"}},
|
||
); v != nil {
|
||
t.Fatalf("non-speak batch voice = %+v, want nil", v)
|
||
}
|
||
|
||
ok := l.captureVoice(
|
||
[]agent.ToolCall{{Name: speakToolName}},
|
||
[]ToolOutcome{{Result: map[string]any{
|
||
"name": "abc.wav", "mime": "audio/wav", "size": int64(9),
|
||
"durationMs": int64(0), "budgetMs": 3025, "costMs": 120,
|
||
}}},
|
||
)
|
||
if ok == nil || !ok.Requested {
|
||
t.Fatalf("success voice = %+v, want Requested", ok)
|
||
}
|
||
if ok.Name != "abc.wav" || ok.Mime != "audio/wav" || ok.Size != 9 {
|
||
t.Fatalf("success voice = %+v, want the stored ref", ok)
|
||
}
|
||
if ok.BudgetMs != 3025 || ok.CostMs != 120 || ok.DurationMs != 0 {
|
||
t.Fatalf("success voice = %+v, want budget/cost and unparsed duration", ok)
|
||
}
|
||
if ok.FallbackReason != "" {
|
||
t.Fatalf("success FallbackReason = %q, want empty", ok.FallbackReason)
|
||
}
|
||
|
||
bad := l.captureVoice(
|
||
[]agent.ToolCall{{Name: speakToolName}},
|
||
[]ToolOutcome{{Err: &speakError{Code: speakReasonTimeout, BudgetMs: 3000, CostMs: 3100}}},
|
||
)
|
||
if bad == nil || !bad.Requested {
|
||
t.Fatalf("failure voice = %+v, want Requested", bad)
|
||
}
|
||
if bad.FallbackReason != speakReasonTimeout || bad.BudgetMs != 3000 || bad.CostMs != 3100 {
|
||
t.Fatalf("failure voice = %+v, want the timeout code and budget/cost", bad)
|
||
}
|
||
if bad.Name != "" {
|
||
t.Fatalf("failure name = %q, want empty(没有交付物)", bad.Name)
|
||
}
|
||
|
||
// 非结构化失败也要有原因码,绝不把空串交给客户端。
|
||
raw := l.captureVoice(
|
||
[]agent.ToolCall{{Name: speakToolName}},
|
||
[]ToolOutcome{{Err: errors.New("boom")}},
|
||
)
|
||
if raw == nil || raw.FallbackReason != speakReasonFailed {
|
||
t.Fatalf("raw failure voice = %+v, want %q", raw, speakReasonFailed)
|
||
}
|
||
}
|
||
|
||
// TestCaptureVoice_LastSuccessWins 锁住多次 speak 的取用规则:成功优先
|
||
// 于失败(音频已经落盘就交付),同类取最后一个(这条消息最后的意愿)。
|
||
func TestCaptureVoice_LastSuccessWins(t *testing.T) {
|
||
t.Parallel()
|
||
l := New(Options{})
|
||
calls := []agent.ToolCall{{Name: speakToolName}, {Name: speakToolName}, {Name: speakToolName}}
|
||
|
||
// 先成功、后失败:交付已落盘的那份。
|
||
got := l.captureVoice(calls[:2], []ToolOutcome{
|
||
{Result: map[string]any{"name": "first.wav", "mime": "audio/wav", "size": int64(1)}},
|
||
{Err: &speakError{Code: speakReasonFailed}},
|
||
})
|
||
if got == nil || got.Name != "first.wav" {
|
||
t.Fatalf("voice = %+v, want first.wav(成功优先于后续失败)", got)
|
||
}
|
||
|
||
// 两次都成功:取后者。
|
||
got = l.captureVoice(calls[:2], []ToolOutcome{
|
||
{Result: map[string]any{"name": "first.wav"}},
|
||
{Result: map[string]any{"name": "second.wav"}},
|
||
})
|
||
if got == nil || got.Name != "second.wav" {
|
||
t.Fatalf("voice = %+v, want second.wav(后者覆盖前者)", got)
|
||
}
|
||
|
||
// 被跳过的调用不算数。
|
||
if v := l.captureVoice(calls[:1], []ToolOutcome{{Skipped: true, Reason: "duplicate"}}); v != nil {
|
||
t.Fatalf("skipped voice = %+v, want nil", v)
|
||
}
|
||
}
|
||
|
||
// TestRun_VoiceResult 是一次端到端回合:speak 出现在模型面里、被调用
|
||
// 后真的走了注入的合成与落盘,结论落在 Result.Voice 上。
|
||
func TestRun_VoiceResult(t *testing.T) {
|
||
t.Parallel()
|
||
call := model.Response{Model: "m", ToolCalls: []model.ToolCall{{
|
||
ID: "call_speak_1",
|
||
Name: speakToolName,
|
||
Args: map[string]any{"text": "晚安", "emotion": "温柔"},
|
||
}}}
|
||
ad := &mockAdapter{stream: []mgResp{{resp: call}, {resp: model.Response{Model: "m", Content: "晚安"}}}}
|
||
td := dispatcherWith(t, &mockTool{name: "terminal", result: "ok"})
|
||
synth := &stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2, 3}}, provider: "emotiontts"}
|
||
store := &stubStore{ref: VoiceRef{Name: "abc.wav", MIME: "audio/wav", Size: 3}}
|
||
l := New(Options{
|
||
Agent: newTestAgent(t, ad, td),
|
||
MaxRounds: 8,
|
||
OnStream: func(model.StreamEvent) {},
|
||
VoiceSynth: synth,
|
||
VoiceStore: store,
|
||
})
|
||
|
||
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "说晚安")})
|
||
if err != nil {
|
||
t.Fatalf("Run: %v", err)
|
||
}
|
||
if res.ToolCallsExecuted != 1 {
|
||
t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted)
|
||
}
|
||
// ① 模型面确实被广告了 speak(否则模型不可能调它)。
|
||
if len(ad.streamReqs) == 0 {
|
||
t.Fatal("no streaming request captured")
|
||
}
|
||
if set := toolSet(ad.streamReqs[0].Tools); !set[speakToolName] {
|
||
t.Fatalf("advertised tools = %v, want %q", toolNames(ad.streamReqs[0].Tools), speakToolName)
|
||
}
|
||
// ② 调用真的走了注入的合成与落盘。
|
||
if synth.gotText != "晚安" {
|
||
t.Fatalf("synth text = %q, want 晚安", synth.gotText)
|
||
}
|
||
if store.gotName != "speak.wav" {
|
||
t.Fatalf("origName = %q, want speak.wav", store.gotName)
|
||
}
|
||
// ③ 结论落在 Result.Voice 上。
|
||
if res.Voice == nil {
|
||
t.Fatal("Result.Voice = nil, want the synthesized audio")
|
||
}
|
||
if !res.Voice.Requested || res.Voice.Name != "abc.wav" || res.Voice.Mime != "audio/wav" || res.Voice.Size != 3 {
|
||
t.Fatalf("Result.Voice = %+v, want the stored ref", res.Voice)
|
||
}
|
||
if res.Voice.FallbackReason != "" {
|
||
t.Fatalf("FallbackReason = %q, want empty", res.Voice.FallbackReason)
|
||
}
|
||
}
|
||
|
||
// TestRun_VoiceDegraded 锁住端到端的降级:合成失败时回合照常结束
|
||
// (绝不因语音失败而失败),Result.Voice 带上原因码让客户端特殊显示。
|
||
func TestRun_VoiceDegraded(t *testing.T) {
|
||
t.Parallel()
|
||
call := model.Response{Model: "m", ToolCalls: []model.ToolCall{{
|
||
ID: "call_speak_2",
|
||
Name: speakToolName,
|
||
Args: map[string]any{"text": "晚安"},
|
||
}}}
|
||
ad := &mockAdapter{stream: []mgResp{{resp: call}, {resp: model.Response{Model: "m", Content: "晚安"}}}}
|
||
l := New(Options{
|
||
Agent: newTestAgent(t, ad, dispatcherWith(t)),
|
||
MaxRounds: 8,
|
||
OnStream: func(model.StreamEvent) {},
|
||
VoiceSynth: &stubSynth{err: errors.New("upstream down")},
|
||
VoiceStore: &stubStore{},
|
||
})
|
||
|
||
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "说晚安")})
|
||
if err != nil {
|
||
t.Fatalf("Run: %v", err)
|
||
}
|
||
if res.Text != "晚安" {
|
||
t.Fatalf("Text = %q, want the text answer(语音失败不得拖垮回合)", res.Text)
|
||
}
|
||
if res.Voice == nil || res.Voice.FallbackReason != speakReasonFailed {
|
||
t.Fatalf("Result.Voice = %+v, want %q", res.Voice, speakReasonFailed)
|
||
}
|
||
if res.Voice.Requested != true {
|
||
t.Fatalf("Result.Voice.Requested = %v, want true(确实想用语音)", res.Voice.Requested)
|
||
}
|
||
}
|
||
|
||
// TestRun_NoSpeakNoVoice 锁住「不接线就没有语音面」:未注入依赖时
|
||
// 模型面里没有 speak,Result.Voice 保持 nil —— 与接线前逐字节一致。
|
||
func TestRun_NoSpeakNoVoice(t *testing.T) {
|
||
t.Parallel()
|
||
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Model: "m", Content: "晚安"}}}}
|
||
l := New(Options{
|
||
Agent: newTestAgent(t, ad, dispatcherWith(t, &mockTool{name: "terminal", result: "ok"})),
|
||
MaxRounds: 8,
|
||
OnStream: func(model.StreamEvent) {},
|
||
})
|
||
|
||
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "说晚安")})
|
||
if err != nil {
|
||
t.Fatalf("Run: %v", err)
|
||
}
|
||
if res.Voice != nil {
|
||
t.Fatalf("Result.Voice = %+v, want nil", res.Voice)
|
||
}
|
||
if len(ad.streamReqs) == 0 {
|
||
t.Fatal("no streaming request captured")
|
||
}
|
||
if set := toolSet(ad.streamReqs[0].Tools); set[speakToolName] {
|
||
t.Fatalf("advertised tools = %v, must not contain %q", toolNames(ad.streamReqs[0].Tools), speakToolName)
|
||
}
|
||
}
|
||
|
||
// TestInvokeTool_SpeakWithoutDispatcher 锁住 speak 不依赖 dispatcher:
|
||
// 未接线时普通工具仍按原样报「没有 dispatcher」,接线后 speak 照常执行。
|
||
func TestInvokeTool_SpeakWithoutDispatcher(t *testing.T) {
|
||
t.Parallel()
|
||
wired := New(Options{
|
||
VoiceSynth: &stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, provider: "p"},
|
||
VoiceStore: &stubStore{ref: VoiceRef{Name: "a.wav", MIME: "audio/wav", Size: 1}},
|
||
})
|
||
out := wired.invokeOne(context.Background(), nil, agent.ToolCall{
|
||
Name: speakToolName, Args: map[string]any{"text": "晚安"},
|
||
})
|
||
if out.Err != nil {
|
||
t.Fatalf("speak without dispatcher err = %v, want nil", out.Err)
|
||
}
|
||
|
||
plain := New(Options{})
|
||
out = plain.invokeOne(context.Background(), nil, agent.ToolCall{Name: "terminal"})
|
||
if out.Err == nil || out.ErrKind != tool.KindExecution {
|
||
t.Fatalf("nil dispatcher outcome = %+v, want an execution error", out)
|
||
}
|
||
}
|
||
|
||
// 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)。
|
||
func TestVoiceStoreFunc(t *testing.T) {
|
||
t.Parallel()
|
||
var gotName string
|
||
var gotData []byte
|
||
var store VoiceStore = VoiceStoreFunc(func(_ context.Context, origName string, data []byte) (VoiceRef, error) {
|
||
gotName, gotData = origName, data
|
||
return VoiceRef{Name: "saved.wav", MIME: "audio/wav", Size: int64(len(data))}, nil
|
||
})
|
||
|
||
ref, err := store.SaveAudio(context.Background(), "speak.wav", []byte{7, 8})
|
||
if err != nil {
|
||
t.Fatalf("SaveAudio: %v", err)
|
||
}
|
||
if gotName != "speak.wav" || len(gotData) != 2 {
|
||
t.Fatalf("forwarded (%q, %d bytes), want (speak.wav, 2 bytes)", gotName, len(gotData))
|
||
}
|
||
if ref.Name != "saved.wav" || ref.Size != 2 {
|
||
t.Fatalf("ref = %+v, want the adapter's own result", ref)
|
||
}
|
||
}
|