Files
Cyrene_v2/internal/runtime/loop/voice_result_test.go
T
AskaEth 39ae883a4e test(loop): cover the speak tool and Result.Voice (M57)
speak_tool_test.go pins the leaf behaviour: budget formula, extension
whitelist, argument validation, and the success/timeout/no-audio/store-failed
paths, plus the model-surface advertisement and append-once rule.

voice_result_test.go pins the outbound chain end to end: captureVoice's three
states and its last-success-wins rule, a full turn where speak is advertised,
executed through the injected synth/store and lands on Result.Voice, the
degraded turn that still answers in text, the unwired turn that keeps the
surface byte-identical, and the store adapter the wiring will use.
2026-10-27 21:22:20 +08:00

264 lines
9.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}