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) } } // 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) } }