package gateway // 本文件是复审 r2 P2-1 的**回归锁**:主会话(`web`)的 `persona_id` 恒为空且没有 // 重绑路径,而"空"的真实语义是「**继承全局默认人格**」(`loop/persona.go` 回退到 // `Options.PersonaID`;前端 `lib/persona.ts` 的 `bound ?? defaultPersona(personas)`)。 // // 若把空当成"没有生效人格",主会话聊天是 furina、声音却是 `voice-tts.json` 的 // `default` 那个人 —— 主人逐字抱怨的那件事,而且发生在唯一能给普通聊天出声的路径上。 // // 两条用例分别钉住 REST 与 WS 两条既有出站口;变异(把 effectivePersona 退回 // 「空 = 无」)必须打红。 import ( "context" "encoding/base64" "encoding/json" "net/http" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts" ) // defaultPersonaRegistry 是一个"全局默认人格 = furina"的人格库。 func defaultPersonaRegistry() PersonaStore { return &stubPersonas{list: []personareg.Character{ {ID: "Cyrene", Name: "Cyrene", PersonaID: "cyrene"}, {ID: "芙宁娜", Name: "芙宁娜", PersonaID: "furina", Default: true}, }} } // unboundHistory 模拟**主会话**:SessionPersona 恒报空串(继承全局默认)。 type unboundHistory struct{ *fakeHistory } func (h unboundHistory) SessionPersona(context.Context, string) (string, error) { return "", nil } // TestSpeechEndpointUnboundSessionInheritsGlobalPersona 是复审那条用例的复现: // 主会话(未绑定)+ 全局默认人格 furina + `default` 配成 cyrene 的角色 ⇒ // 必须用 **furina 的** 声音(不是 default)。 func TestSpeechEndpointUnboundSessionInheritsGlobalPersona(t *testing.T) { env := newEnv(t, nil, false) rec := &vocalRecorder{} // default 故意指向 cyrene 的角色:修好之前这里会被选中(复审报的红)。 env.srv.SetVoice(emotionTTSService(t, rec, voicetts.Config{ Personas: personaCharacters, Default: personaCharacters["cyrene"], }, &silentBase{})) env.srv.envResolver = turnEnvResolver{ history: unboundHistory{fakeHistory: &fakeHistory{}}, personas: defaultPersonaRegistry(), log: env.srv.log, } token := env.token(t, auth.RoleOwner) // 主会话就是 "web"(与 history.MainSessionID 同值;这里只声明"未绑定"这一事实)。 resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{ "text": "你好,我是苏梦。", "sessionId": "web", }, token) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, body = %s", resp.StatusCode, body) } got := rec.Voices() want := personaCharacters["furina"] if len(got) != 1 || got[0] != want { t.Fatalf("主会话(继承全局人格 furina)用了 %v 的声音,want [%s] —— 这正是主人抱怨的那件事", got, want) } if string(body) != string(fakeWAV(want)) { t.Fatalf("响应体不是 furina 的声音: %q", string(body)) } } // TestVoiceOutputUnboundSessionInheritsGlobalPersona 是同一条口径在 WS 口上的复现: // 帧里的音频必须来自**全局默认人格**的角色。 func TestVoiceOutputUnboundSessionInheritsGlobalPersona(t *testing.T) { hb := newTestHub(&stubRunner{res: &loop.Result{Text: "你好,我是苏梦。", Steps: 1, StopReason: "stop"}}, nil) hb.envResolver = turnEnvResolver{ history: unboundHistory{fakeHistory: &fakeHistory{}}, personas: defaultPersonaRegistry(), log: sourcedLog(nil), } rec := &vocalRecorder{} hb.voice = emotionTTSService(t, rec, voicetts.Config{ Personas: personaCharacters, Default: personaCharacters["cyrene"], }, &silentBase{}) c := newOwnerConn(hb, 32) hb.register(c) c.route(wsInbound{Type: "voice_input", Data: json.RawMessage(`{"text":"你好","format":"wav"}`)}) ev := readFakeFrame(t, c) if ev.Type != "voice_output" { t.Fatalf("frame = %q, want voice_output (%+v)", ev.Type, ev) } data, ok := ev.Data.(map[string]any) if !ok { t.Fatalf("frame data = %T", ev.Data) } raw, err := base64.StdEncoding.DecodeString(asString(data["audio"])) if err != nil || len(raw) == 0 { t.Fatalf("帧里没有可解码的音频(err=%v)", err) } want := personaCharacters["furina"] if string(raw) != string(fakeWAV(want)) { t.Fatalf("主会话的帧里不是 furina 的声音: %q", string(raw)) } if got := rec.Voices(); len(got) != 1 || got[0] != want { t.Fatalf("请求体里的 voice = %v, want [%s]", got, want) } } // TestSpeechEndpointPersonaUnmappedErrorCode 钉住复审 r2 建议 3:状态码仍是 500 // (服务端配置缺失,不是客户端的错),但**错误码可区分** —— 不许跟一般合成失败混成一个码。 func TestSpeechEndpointPersonaUnmappedErrorCode(t *testing.T) { env := newEnv(t, nil, false) env.srv.SetVoice(emotionTTSService(t, &vocalRecorder{}, voicetts.Config{ Personas: personaCharacters, }, &silentBase{})) env.srv.envResolver = turnEnvResolver{ history: defaultPersonaRegistryHistory(), personas: defaultPersonaRegistry(), log: env.srv.log, } token := env.token(t, auth.RoleOwner) resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{ "text": "你好", "sessionId": "web:sub-7", }, token) if resp.StatusCode != http.StatusInternalServerError { t.Fatalf("status = %d, want 500(服务端配置缺失)", resp.StatusCode) } code := errorCodeOf(t, body) if code != "tts_persona_unmapped" { t.Fatalf("error.code = %q, want tts_persona_unmapped(body=%s)", code, body) } } // defaultPersonaRegistryHistory 是一个"会话已绑定到没有 TTS 映射的人格"的存储。 func defaultPersonaRegistryHistory() HistoryStore { return personaHistory{fakeHistory: &fakeHistory{}, persona: "stranger"} }