feat(gateway): 语音回合进编排+落库、G5 队列满带回身份、voice_output 归属(安卓 G5/C1/C3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- G5:pending_full 增 clientMsgId/messageId(仅在可给出时出现、绝不新铸 id —— 队列满的拒绝发生在幂等键认领之前,代铸会让客户端重试被判重复而永远进不来);另定向回发送者 {type:error,data:{code:chat_busy,...}}(与 REST 409 chat_busy 同码),广播帧照旧带同样的键供其它端对齐。

- C1:语音回合改走与文本同一条路径(编排器 Submit → 落库 → 流式 → 跨端同步),deviceId 走既有 withDeviceID、platform 走存储默认 web;未接编排器时保留旧直连路径。音频在回合结算处(persistAssistantReply → persistDocumentStatus → dispatchVoiceReply)合成并定向回原连接,回调各自 goroutine(TTS 延迟不拖住 pending_drain/session_idle);续跑 FIFO 与转向注入两条路径都带走回调 ⇒ 忙时排队/被注入的语音也不丢音频。

- C3:voice_output 增 sessionId/deviceId;三处主动加码(按最优方向):① voice_input 增可选 clientMsgId(语音也有幂等)② 不再拿 stopReason 顶替空正文当语音内容(空正文走 provider 降级信号)③ TTS 失败照旧回帧 + provider:silent(不静默)。

- 拆 chat_documents_test.go(510 行越界)→ 373 + 160,只搬不改(拆前后同 -run 过滤 PASS 集合逐条一致)。

- 主会话独立验收:隔离 worktree(HEAD + 本批次 11 文件)build/vet 绿、gateway -race 绿、全树 59 包(唯一红是既有负载 flake orchestration/TestSpawnTimeoutFromToolDefault)、linux+darwin 交叉编译绿;变异独立复现(pending_full 摘掉 clientMsgId ⇒ 3 条红)。

- 待办(已登记):Docs/api-client.md 与 api-reference.md 的帧/字段登记随 v0.59 一并落笔(本批次刻意未动 Docs,避免与契约批次打架)。
This commit is contained in:
AskaEth committed 2026-10-04 17:59:28 +08:00
1 parent 72b95df22b
commit 3a01b4e236
11 files changed
+1123 -170

No files matched your search

@@ -0,0 +1,352 @@
package gateway
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
)
// 本文件是协同项 C1(语音回合接进编排器与持久化)与 C3(voice_output 带
// sessionId/deviceId)的回归。
//
// 修前:runVoiceTurn 直调 runner.Run ⇒ 语音问过的事不进历史、不跨端同步、不参与
// busy/pending,音频却只能同步等在那个回合上。
//
// 修后:语音回合与文本回合走同一条路(进编排器 → 落库 → 流式 → 跨端同步),音频
// 由回合**结算**时的收尾回调合成并定向回原连接;TTS 失败/降级照旧回帧并带既有
// provider 降级信号,空正文不打无谓的 TTS。客户端既有绕行(transcribe → WS chat
// → speech)不受影响,因为它们走的是另一条入口。
//
// 断言一律按帧字段与已落库记录判定;日志/错误不按文本子串断言。
// waitFrame 排空连接上的帧直到出现给定类型(语音回合的收尾帧会排在若干状态
// 信号广播之后),超时即失败。
func waitFrame(t *testing.T, c *wsConn, typ string) wsEvent {
t.Helper()
deadline := time.After(3 * time.Second)
for {
select {
case b := <-c.send:
var ev wsEvent
if err := json.Unmarshal(b, &ev); err != nil {
t.Fatalf("解码帧 %s: %v", b, err)
}
if ev.Type == typ {
return ev
}
case <-deadline:
t.Fatalf("等待 %q 帧超时", typ)
return wsEvent{}
}
}
}
// frameData 返回一帧的载荷 map(形状不对直接判失败)。
func frameData(t *testing.T, ev wsEvent) map[string]any {
t.Helper()
data, ok := ev.Data.(map[string]any)
if !ok {
t.Fatalf("帧载荷 = %T, want map[string]any(frame=%+v)", ev.Data, ev)
}
return data
}
// voiceInputFrame 构造一条 voice_input 上行帧。text 非空时它覆盖 STT(测试因此
// 不依赖假 STT 的内容);为空时帧里带一段假音频,走真实的解码 + STT 分支。
func voiceInputFrame(text, sessionID, deviceID string) wsInbound {
data := map[string]any{
"text": text,
"format": "wav",
"sessionId": sessionID,
"deviceId": deviceID,
}
if text == "" {
data["audio"] = base64.StdEncoding.EncodeToString([]byte("fake-audio"))
}
raw, err := json.Marshal(data)
if err != nil {
panic(err)
}
return wsInbound{Type: "voice_input", Data: raw}
}
// TestVoiceTurnRepliesWithAudioAndIdentity 钉住 C1 的音频收尾与 C3 的归属字段:
// 一条语音回合(走编排器)结算后,定向回一条 voice_output,带助手正文合成的
// 音频、识别出的转写,以及 sessionId/deviceId;有真实音频时**不带** provider
// 降级信号。
func TestVoiceTurnRepliesWithAudioAndIdentity(t *testing.T) {
t.Parallel()
hb := newOrchestratedTestHub(t, fixedResult("我在听"), nil)
hb.voice = &signalVoice{text: "你好昔涟", audio: voice.Audio{Format: "wav", Data: []byte("audio-bytes")}, rung: "cloud"}
c := newOwnerConn(hb, 64)
hb.register(c)
c.route(voiceInputFrame("", "web", "android:pixel:7a"))
data := frameData(t, waitFrame(t, c, wsVoiceOutput))
if data["text"] != "你好昔涟" {
t.Fatalf("voice_output.text = %v, want 识别出的转写", data["text"])
}
if data["reply"] != "我在听" {
t.Fatalf("voice_output.reply = %v, want 助手正文", data["reply"])
}
if want := base64.StdEncoding.EncodeToString([]byte("audio-bytes")); data["audio"] != want {
t.Fatalf("voice_output.audio = %v, want 合成音频的 base64", data["audio"])
}
if data["sessionId"] != "web" || data["deviceId"] != "android:pixel:7a" {
t.Fatalf("voice_output 缺归属字段:%+v(协同 C3)", data)
}
if _, present := data["provider"]; present {
t.Fatalf("有真实音频时不许带降级信号:%+v", data)
}
}
// TestVoiceTurnRepliesFrameWhenTTSError 钉住「降级不静默」:TTS 不可用时**照旧
// 回帧**(而不是只回一条错误帧让客户端等不到终点),并带既有 provider 降级信号,
// 客户端据此提示「没生成声音」。
func TestVoiceTurnRepliesFrameWhenTTSError(t *testing.T) {
t.Parallel()
hb := newOrchestratedTestHub(t, fixedResult("我在听"), nil)
hb.voice = &signalVoice{text: "你好", ttsErr: errors.New("tts down"), rung: "cloud"}
c := newOwnerConn(hb, 64)
hb.register(c)
c.route(voiceInputFrame("", "web", "android:pixel:7a"))
data := frameData(t, waitFrame(t, c, wsVoiceOutput))
if data["provider"] != "silent" {
t.Fatalf("TTS 失败时必须带降级信号:%+v", data)
}
if data["reply"] != "我在听" || data["text"] != "你好" {
t.Fatalf("TTS 失败也要把转写与正文带回:%+v", data)
}
if data["audio"] != "" {
t.Fatalf("TTS 失败不该给出音频:%v", data["audio"])
}
if data["sessionId"] != "web" || data["deviceId"] != "android:pixel:7a" {
t.Fatalf("voice_output 缺归属字段:%+v(协同 C3)", data)
}
}
// TestVoiceOutputCarriesIdentityWithoutOrchestrator 钉住未接编排器时的旧直跑
// 路径(未接线的构建 / 裸 hub)同样带 C3 的两个归属字段:两条路径的
// voice_output 契约必须一致。
func TestVoiceOutputCarriesIdentityWithoutOrchestrator(t *testing.T) {
t.Parallel()
hb := newTestHub(&stubRunner{res: &loop.Result{Text: "你好", StopReason: "stop"}}, nil)
hb.voice = &signalVoice{text: "喂", audio: voice.Audio{Format: "wav", Data: []byte("a")}, rung: "cloud"}
c := newOwnerConn(hb, 16)
hb.register(c)
c.route(voiceInputFrame("喂", "s9", "android:x"))
data := frameData(t, waitFrame(t, c, wsVoiceOutput))
if data["sessionId"] != "s9" || data["deviceId"] != "android:x" {
t.Fatalf("直跑路径的 voice_output 缺归属字段:%+v(协同 C3)", data)
}
}
// TestVoiceTurnQueuesWhenSessionBusy 钉住 C1 的忙时语义:语音回合与文本回合共用
// 编排器,因此会话忙时它照常入有界待处理队列(不插队、不丢),前一条结算后按
// **严格 FIFO** 续跑;两条都落库。
func TestVoiceTurnQueuesWhenSessionBusy(t *testing.T) {
t.Parallel()
run := newGatedRunner(4)
hist := &fakeHistory{}
hb := newOrchestratedTestHub(t, run.Run, hist)
hb.voice = &signalVoice{audio: voice.Audio{Format: "wav", Data: []byte("a")}, rung: "cloud"}
c := newOwnerConn(hb, 64)
hb.register(c)
c.route(voiceInputFrame("第一次", "web", "dev"))
if !waitBusy(hb.orchestrator, "web") {
t.Fatal("会话一直没有忙碌起来")
}
// 第二条在会话忙时到达:按编排器规则排队,而不是新起一个回合或丢掉。
c.route(voiceInputFrame("第二次", "web", "dev"))
if !waitQueueDepth(hb.orchestrator, "web", 1) {
t.Fatalf("队列深度 = %d, want 1", hb.orchestrator.QueueDepth("web"))
}
// 放行第一条:只有它能结算(第二条还堵在围栏之后),因此这一帧确定属于
// 先到的那条 —— FIFO 不是靠时序运气,而是靠门控。
run.tickets <- struct{}{}
first := frameData(t, waitFrame(t, c, wsVoiceOutput))
if first["text"] != "第一次" {
t.Fatalf("先到的语音回合 = %v, want 第一次(不得插队)", first["text"])
}
run.tickets <- struct{}{}
second := frameData(t, waitFrame(t, c, wsVoiceOutput))
if second["text"] != "第二次" {
t.Fatalf("续跑的语音回合 = %v, want 第二次", second["text"])
}
if !waitIdle(hb.orchestrator, "web") {
t.Fatalf("会话一直没有回到空闲:busy=%v depth=%d", hb.orchestrator.Busy("web"), hb.orchestrator.QueueDepth("web"))
}
users := userContents(hist.records())
if len(users) != 2 || users[0] != "第一次" || users[1] != "第二次" {
t.Fatalf("落库的用户消息 = %v, want [第一次 第二次](语音问过的事必须进历史、且一条不丢)", users)
}
}
// TestVoiceSinkFollowsSteeredVoiceMessage 钉住转向注入这条路径(design/15 §2.4):
// 忙时入 pending 的语音消息若在回合内边界被注入**同一个回合**(注入=已消费),
// 它的音频必须由那个回合结算时一并送回 —— 那条请求的回合已被接手,若不跟着走,
// 它会永远等不到 voice_output(音频回复不能丢)。
//
// 本测试直接驱动认领那一步(consumePending),因为真正的触发点在 loop 的边界回调上,
// 而这里要钉的是网关侧的接线:注入时挂上收尾回调、结算时把它派发出去。
func TestVoiceSinkFollowsSteeredVoiceMessage(t *testing.T) {
t.Parallel()
run := newGatedRunner(4)
hb := newOrchestratedTestHub(t, run.Run, nil)
hb.voice = &signalVoice{audio: voice.Audio{Format: "wav", Data: []byte("a")}, rung: "cloud"}
c := newOwnerConn(hb, 64)
hb.register(c)
// 一个普通(非语音)回合在飞,语音请求在它忙时排队。
if sr := hb.orchestrator.Submit(context.Background(), "web", []model.Message{userMsg("文本问题")}, loop.OwnerEnv()); !sr.Immediate {
t.Fatalf("文本回合 = %+v, want Immediate", sr)
}
if !waitBusy(hb.orchestrator, "web") {
t.Fatal("会话一直没有忙碌起来")
}
c.route(voiceInputFrame("语音问题", "web", "dev"))
if !waitQueueDepth(hb.orchestrator, "web", 1) {
t.Fatalf("队列深度 = %d, want 1", hb.orchestrator.QueueDepth("web"))
}
// 模拟 loop 在回合内边界认领那条 pending 的语音消息(注入=已消费)。
o := hb.orchestrator
o.mu.Lock()
st := o.states["web"]
gen, seq := st.gen, st.pending[0].seq
o.mu.Unlock()
if !o.consumePending("web", gen, map[uint64]bool{seq: true}) {
t.Fatal("认领失败:pending 里没有那条语音消息")
}
// 放行那个回合:答案就在它的回复里,因此音频必须回给语音连接。
run.tickets <- struct{}{}
data := frameData(t, waitFrame(t, c, wsVoiceOutput))
if data["text"] != "语音问题" {
t.Fatalf("注入后回的音频属于 %v, want 语音问题", data["text"])
}
}
// userContents 返回已落库记录里的用户消息正文(按顺序)。
func userContents(msgs []history.Message) []string {
out := make([]string, 0, len(msgs))
for _, m := range msgs {
if m.Role == history.RoleUser {
out = append(out, m.Content)
}
}
return out
}
// fixedResult 返回一个总是产出同一助手正文的回合驱动器。
func fixedResult(text string) TurnRunner {
return func(context.Context, string, []model.Message, loop.SessionEnv) (*loop.Result, error) {
return &loop.Result{Text: text, Steps: 1, StopReason: "stop"}, nil
}
}
// TestVoiceTurnPersistsIntoHistoryEndToEnd 是 C1 的端到端回归(HTTP/WS 层 +
// 真实 history 存储):一条 voice_input 之后,用户消息(转写)与助手回复都真的
// 落库,用户消息带发送端的 deviceId 与平台标签,且历史接口查得到 —— 这正是
// 「语音问过的事在 Web 上必须看得到」。
func TestVoiceTurnPersistsIntoHistoryEndToEnd(t *testing.T) {
t.Parallel()
dataDir := t.TempDir()
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
cfg := &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}}
st := newGatewayHistoryStore(t)
srv, err := New(cfg, Deps{Auth: jwt, DataDir: dataDir, Loop: &stubRunner{res: &loop.Result{Text: "语音答复", StopReason: "stop"}}, History: st})
if err != nil {
t.Fatalf("构造网关失败: %v", err)
}
srv.SetVoice(&signalVoice{text: "语音提问", audio: voice.Audio{Format: "wav", Data: []byte("a")}, rung: "cloud"})
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
t.Cleanup(srv.Close)
tok, err := jwt.Sign("acct", auth.RoleOwner, time.Hour)
if err != nil {
t.Fatalf("签发 token 失败: %v", err)
}
conn := dialWS(t, ts.URL, tok)
t.Cleanup(func() { _ = conn.Close() })
if err := conn.WriteJSON(map[string]any{"type": "voice_input", "data": map[string]any{
"audio": base64.StdEncoding.EncodeToString([]byte("raw-audio")),
"format": "wav",
"sessionId": history.MainSessionID,
"deviceId": "android:pixel:7a",
}}); err != nil {
t.Fatalf("发送 voice_input 失败: %v", err)
}
if ev := readWSType(t, conn, wsVoiceOutput); ev.Type != wsVoiceOutput {
t.Fatalf("帧类型 = %q", ev.Type)
}
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("历史接口 status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var envelope struct {
Data []struct {
Role string `json:"role"`
Content string `json:"content"`
Platform string `json:"platform"`
DeviceID string `json:"deviceId"`
} `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
t.Fatalf("解析历史响应失败: %v (body=%s)", err, body)
}
var userOK, assistantOK bool
for _, m := range envelope.Data {
switch {
case m.Role == history.RoleUser && m.Content == "语音提问":
userOK = true
if m.DeviceID != "android:pixel:7a" {
t.Fatalf("用户消息 deviceId = %q, want android:pixel:7a(语音特有身份必须落库)", m.DeviceID)
}
if m.Platform != history.PlatformWeb {
t.Fatalf("用户消息 platform = %q, want %q", m.Platform, history.PlatformWeb)
}
case m.Role == history.RoleAssistant && m.Content == "语音答复":
assistantOK = true
}
}
if !userOK || !assistantOK {
t.Fatalf("历史里缺语音回合:user=%v assistant=%v (msgs=%+v)", userOK, assistantOK, envelope.Data)
}
}
// readWSType 从真连接上读帧直到出现给定类型。
func readWSType(t *testing.T, conn *websocket.Conn, typ string) wsEvent {
t.Helper()
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
for {
var ev wsEvent
if err := conn.ReadJSON(&ev); err != nil {
t.Fatalf("读 WS 帧失败(等 %q): %v", typ, err)
}
if ev.Type == typ {
return ev
}
}
}