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.
This commit is contained in:
1 parent
1347a77d33
commit
39ae883a4e
2 files changed
+597
No files matched your search
@@ -0,0 +1,334 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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 工具的叶子测试(M57):预算公式、扩展名白名单、
|
||||
// 参数校验、成功/超时/无音频/落盘失败四条执行路径,以及模型面的
|
||||
// 广告与追加。依赖全部用桩,绝不真连 TTS、绝不真写盘。
|
||||
|
||||
// stubSynth 是可配置的合成桩:delay 制造超时,err 制造失败,
|
||||
// audio 决定成功时的产物,provider 供日志断言。
|
||||
type stubSynth struct {
|
||||
audio mvoice.Audio
|
||||
provider string
|
||||
err error
|
||||
delay time.Duration
|
||||
gotText string
|
||||
}
|
||||
|
||||
func (s *stubSynth) TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error) {
|
||||
s.gotText = text
|
||||
if s.delay > 0 {
|
||||
select {
|
||||
case <-time.After(s.delay):
|
||||
case <-ctx.Done():
|
||||
return mvoice.Audio{}, "", ctx.Err()
|
||||
}
|
||||
}
|
||||
if s.err != nil {
|
||||
return mvoice.Audio{}, "", s.err
|
||||
}
|
||||
return s.audio, s.provider, nil
|
||||
}
|
||||
|
||||
// stubStore 是可配置的落盘桩,同时记录收到的原始名与字节。
|
||||
type stubStore struct {
|
||||
ref VoiceRef
|
||||
err error
|
||||
gotName string
|
||||
gotData []byte
|
||||
}
|
||||
|
||||
func (s *stubStore) SaveAudio(_ context.Context, origName string, data []byte) (VoiceRef, error) {
|
||||
s.gotName = origName
|
||||
s.gotData = data
|
||||
if s.err != nil {
|
||||
return VoiceRef{}, s.err
|
||||
}
|
||||
return s.ref, nil
|
||||
}
|
||||
|
||||
// newSpeakTool 装配一个注入了桩的 speak 工具。
|
||||
func newSpeakTool(synth VoiceSynth, store VoiceStore) speakTool {
|
||||
return speakTool{synth: synth, store: store}
|
||||
}
|
||||
|
||||
// speakCodeOf 取出 speak 失败的结构化原因码。
|
||||
func speakCodeOf(t *testing.T, err error) *speakError {
|
||||
t.Helper()
|
||||
var se *speakError
|
||||
if !errors.As(err, &se) {
|
||||
t.Fatalf("error %v is not *speakError", err)
|
||||
}
|
||||
return se
|
||||
}
|
||||
|
||||
// TestSpeakBudgetMs 锁住预算公式的边界:短句拿最低预算、线性增长、
|
||||
// 长文封顶,负数按 0 处理(绝不返回负预算)。
|
||||
func TestSpeakBudgetMs(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
runes int
|
||||
want int
|
||||
}{
|
||||
{-5, speakBudgetBaseMs},
|
||||
{0, speakBudgetBaseMs},
|
||||
{1, speakBudgetBaseMs + speakBudgetPerRuneMs},
|
||||
{100, speakBudgetBaseMs + 100*speakBudgetPerRuneMs},
|
||||
{680, speakBudgetMaxMs},
|
||||
{speakMaxRunes, speakBudgetMaxMs},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := speakBudgetMs(c.runes); got != c.want {
|
||||
t.Errorf("speakBudgetMs(%d) = %d, want %d", c.runes, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakExt 锁住落盘扩展名:空格式按 wav(TTS 产物恒为 WAV)、
|
||||
// 白名单内小写化、白名单外判不可用(绝不拿读不出 Content-Type 的
|
||||
// 名字落盘)。
|
||||
func TestSpeakExt(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
format string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"", "wav", true},
|
||||
{" ", "wav", true},
|
||||
{"WAV", "wav", true},
|
||||
{"mp3", "mp3", true},
|
||||
{" Opus ", "", false},
|
||||
{"exe", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := speakExt(c.format)
|
||||
if got != c.want || ok != c.ok {
|
||||
t.Errorf("speakExt(%q) = (%q,%v), want (%q,%v)", c.format, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseSpeakArgs_Validation 锁住参数校验:缺 text、非对象、超长
|
||||
// 都归 argument-validation(errors.Is(tool.ErrInvalidArgs)),并且
|
||||
// 长度按 **rune** 算而不是字节(多字节文本不会因字节数被误判超长)。
|
||||
func TestParseSpeakArgs_Validation(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := parseSpeakArgs(nil); !errors.Is(err, tool.ErrInvalidArgs) {
|
||||
t.Fatalf("nil args err = %v, want ErrInvalidArgs", err)
|
||||
}
|
||||
if _, err := parseSpeakArgs(map[string]any{"text": " "}); !errors.Is(err, tool.ErrInvalidArgs) {
|
||||
t.Fatalf("blank text err = %v, want ErrInvalidArgs", err)
|
||||
}
|
||||
if _, err := parseSpeakArgs([]string{"a"}); !errors.Is(err, tool.ErrInvalidArgs) {
|
||||
t.Fatalf("non-object args err = %v, want ErrInvalidArgs", err)
|
||||
}
|
||||
if _, err := parseSpeakArgs(map[string]any{"text": strings.Repeat("あ", speakMaxRunes+1)}); !errors.Is(err, tool.ErrInvalidArgs) {
|
||||
t.Fatalf("too-long text err = %v, want ErrInvalidArgs", err)
|
||||
} else if code := speakCodeOf(t, err).Code; code != speakReasonTooLong {
|
||||
t.Fatalf("too-long code = %q, want %q", code, speakReasonTooLong)
|
||||
}
|
||||
|
||||
// 恰好上限、且是多字节:按 rune 计数,应当放行。
|
||||
in, err := parseSpeakArgs(map[string]any{
|
||||
"text": strings.Repeat("あ", speakMaxRunes),
|
||||
"emotion": " 温柔 ",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("at-limit runes rejected: %v", err)
|
||||
}
|
||||
if in.runes != speakMaxRunes {
|
||||
t.Fatalf("runes = %d, want %d(必须按 rune 而非字节计数)", in.runes, speakMaxRunes)
|
||||
}
|
||||
if in.emotion != "温柔" {
|
||||
t.Fatalf("emotion = %q, want trimmed 温柔", in.emotion)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakTool_ExecuteSuccess 锁住成功路径的交付物:音频落盘用
|
||||
// 上游实际格式的扩展名,返回值带齐 name/mime/size/budgetMs/costMs,
|
||||
// 且 durationMs 恒为 0(未解析,不是零长度)。
|
||||
func TestSpeakTool_ExecuteSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
synth := &stubSynth{
|
||||
audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2, 3}},
|
||||
provider: "emotiontts",
|
||||
}
|
||||
store := &stubStore{ref: VoiceRef{Name: "abc123.wav", MIME: "audio/wav", Size: 3}}
|
||||
tk := newSpeakTool(synth, store)
|
||||
tk.budget = func(int) int { return 777 }
|
||||
|
||||
out, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if len(store.gotData) != 3 {
|
||||
t.Fatalf("stored bytes = %d, want 3", len(store.gotData))
|
||||
}
|
||||
m, ok := out.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("result type = %T, want map[string]any", out)
|
||||
}
|
||||
if m["name"] != "abc123.wav" || m["mime"] != "audio/wav" || m["size"] != int64(3) {
|
||||
t.Fatalf("result = %v, want the stored ref's name/mime/size", m)
|
||||
}
|
||||
if m["durationMs"] != int64(0) {
|
||||
t.Fatalf("durationMs = %v, want int64(0)(未解析)", m["durationMs"])
|
||||
}
|
||||
if m["budgetMs"] != 777 {
|
||||
t.Fatalf("budgetMs = %v, want the injected 777", m["budgetMs"])
|
||||
}
|
||||
if _, ok := m["costMs"].(int); !ok {
|
||||
t.Fatalf("costMs = %v, want int", m["costMs"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakTool_ExecuteTimeout 锁住超时路径:预算到点即放弃合成,
|
||||
// 原因码是 tts_timeout,且预算/实际耗时都回传给调用方(她下一轮
|
||||
// 据此判断是「说短点」还是「上游坏了」)。这里用 1ms 预算 +
|
||||
// 5ms 延迟制造真超时,不靠断言硬编码的时间差。
|
||||
func TestSpeakTool_ExecuteTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
tk := newSpeakTool(
|
||||
&stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, delay: 5 * time.Millisecond},
|
||||
&stubStore{},
|
||||
)
|
||||
tk.budget = func(int) int { return 1 }
|
||||
|
||||
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
|
||||
se := speakCodeOf(t, err)
|
||||
if se.Code != speakReasonTimeout {
|
||||
t.Fatalf("code = %q, want %q", se.Code, speakReasonTimeout)
|
||||
}
|
||||
if se.BudgetMs != 1 {
|
||||
t.Fatalf("BudgetMs = %d, want 1", se.BudgetMs)
|
||||
}
|
||||
if se.CostMs < 0 {
|
||||
t.Fatalf("CostMs = %d, want >= 0", se.CostMs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakTool_ExecuteNoAudio 锁住「上游说成功但没给可用音频」:
|
||||
// 空字节与白名单外格式都归 tts_no_audio,绝不落一个空文件。
|
||||
func TestSpeakTool_ExecuteNoAudio(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
audio mvoice.Audio
|
||||
}{
|
||||
{"empty bytes", mvoice.Audio{Format: "wav"}},
|
||||
{"unknown format", mvoice.Audio{Format: "exe", Data: []byte{1}}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := &stubStore{ref: VoiceRef{Name: "x.wav"}}
|
||||
tk := newSpeakTool(&stubSynth{audio: c.audio, provider: "p"}, store)
|
||||
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
|
||||
if code := speakCodeOf(t, err).Code; code != speakReasonNoAudio {
|
||||
t.Fatalf("code = %q, want %q", code, speakReasonNoAudio)
|
||||
}
|
||||
if store.gotName != "" {
|
||||
t.Fatalf("store was called with %q, want no write at all", store.gotName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakTool_ExecuteStoreFailed 锁住落盘失败:合成成功但副本存不下
|
||||
// 时不谎报成功 —— 原因是 tts_store_failed,音频字节不会被当作交付物。
|
||||
func TestSpeakTool_ExecuteStoreFailed(t *testing.T) {
|
||||
t.Parallel()
|
||||
tk := newSpeakTool(
|
||||
&stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2}}, provider: "p"},
|
||||
&stubStore{err: errors.New("disk full")},
|
||||
)
|
||||
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
|
||||
se := speakCodeOf(t, err)
|
||||
if se.Code != speakReasonStoreFailed {
|
||||
t.Fatalf("code = %q, want %q", se.Code, speakReasonStoreFailed)
|
||||
}
|
||||
if !errors.Is(err, se.Err) {
|
||||
t.Fatalf("error chain lost the store error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakTool_NotConfigured 锁住「合成或落盘缺一个」:不 panic,
|
||||
// 以结构化失败返回,使回合仍能继续。
|
||||
func TestSpeakTool_NotConfigured(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tk := range []speakTool{
|
||||
newSpeakTool(nil, &stubStore{}),
|
||||
newSpeakTool(&stubSynth{}, nil),
|
||||
} {
|
||||
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
|
||||
if code := speakCodeOf(t, err).Code; code != speakReasonFailed {
|
||||
t.Fatalf("code = %q, want %q", code, speakReasonFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakSpec 锁住模型面的接线判定:两个依赖缺任一都不广告
|
||||
// (模型面与接线前逐字节一致),都在时声明带参数 schema 的 speak。
|
||||
func TestSpeakSpec(t *testing.T) {
|
||||
t.Parallel()
|
||||
if spec := New(Options{}).speakSpec(); spec != nil {
|
||||
t.Fatalf("unwired speakSpec = %+v, want nil", spec)
|
||||
}
|
||||
if spec := New(Options{VoiceSynth: &stubSynth{}}).speakSpec(); spec != nil {
|
||||
t.Fatalf("half-wired speakSpec = %+v, want nil", spec)
|
||||
}
|
||||
spec := New(Options{VoiceSynth: &stubSynth{}, VoiceStore: &stubStore{}}).speakSpec()
|
||||
if spec == nil {
|
||||
t.Fatal("wired speakSpec = nil, want a spec")
|
||||
}
|
||||
if spec.Name != speakToolName {
|
||||
t.Fatalf("spec name = %q, want %q", spec.Name, speakToolName)
|
||||
}
|
||||
if spec.Parameters == nil {
|
||||
t.Fatal("spec parameters = nil, want the JSON schema")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithSpeakSpec 锁住追加语义:未接线原样返回、接线后追加到末尾
|
||||
// (因此不受前面任何收缩影响)、已有同名不重复追加。
|
||||
func TestWithSpeakSpec(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := []model.ToolSpec{{Name: "terminal"}, {Name: "web-search"}}
|
||||
|
||||
unwired := New(Options{})
|
||||
if got := unwired.withSpeakSpec(base); len(got) != len(base) {
|
||||
t.Fatalf("unwired appended: %d tools, want %d", len(got), len(base))
|
||||
}
|
||||
|
||||
wired := New(Options{VoiceSynth: &stubSynth{}, VoiceStore: &stubStore{}})
|
||||
got := wired.withSpeakSpec(base)
|
||||
if len(got) != len(base)+1 {
|
||||
t.Fatalf("wired tools = %d, want %d", len(got), len(base)+1)
|
||||
}
|
||||
if last := got[len(got)-1].Name; last != speakToolName {
|
||||
t.Fatalf("last tool = %q, want %q(speak 必须在末尾)", last, speakToolName)
|
||||
}
|
||||
|
||||
again := wired.withSpeakSpec(got)
|
||||
if len(again) != len(got) {
|
||||
t.Fatalf("duplicate speak appended: %d tools, want %d", len(again), len(got))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user