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,238 @@
package gateway
import (
"context"
"encoding/json"
"log/slog"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件是协同项 G5 的回归:pending_full 必须说清「是哪一条被丢了」。
//
// 修前:pending_full 只带 {sessionId, mode, max},客户端收到「队列满」时无法判定
// 该收掉哪一条本地乐观气泡(WS 路径也没有 REST 那 409 chat_busy 的等价物),
// 那条气泡会永远挂在「发送中…」。
//
// 修后:帧里带上被拒那条的 clientMsgId(未携带时省略、绝不伪造),该键此前已
// 落库/已认领时再带上 messageId;发送者还会收到一条定向拒绝帧(type=error、
// data.code=chat_busy,与 REST 同码)。既有三个字段一个不改。
//
// 断言一律按字段判定,不按错误文本子串。
// pendingFullData 返回记录型汇聚点里最近一次 pending_full 的载荷。
func pendingFullData(t *testing.T, sink *recordingSink) map[string]any {
t.Helper()
sink.mu.Lock()
defer sink.mu.Unlock()
for i := len(sink.events) - 1; i >= 0; i-- {
if sink.events[i].typ == EventPendingFull {
return sink.events[i].data
}
}
t.Fatal("没有收到 pending_full 事件")
return nil
}
// fillPendingQueue 把 "s1" 推到「有一个在飞回合 + 待处理队列已满」的状态。
func fillPendingQueue(t *testing.T, o *Orchestrator, maxPending int) {
t.Helper()
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv()); !sr.Immediate {
t.Fatalf("A = %+v, want Immediate", sr)
}
for i := 0; i < maxPending; i++ {
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("p")}, loop.OwnerEnv()); !sr.Pending {
t.Fatalf("第 %d 条排队 = %+v, want Pending", i, sr)
}
}
}
// TestPendingFullCarriesRejectedClientMsgId 钉住主路径:队列满且新消息带
// clientMsgId ⇒ pending_full 载荷含该键;未落库时**不得**凭空给出 messageId。
func TestPendingFullCarriesRejectedClientMsgId(t *testing.T) {
t.Parallel()
run := newGatedRunner(8)
o, sink := seedOrchestrator(run, 1)
fillPendingQueue(t, o, 1)
ctx := withClientMsgHint(context.Background(), clientMsgHint{ClientMsgID: "cm-42"})
if sr := o.Submit(ctx, "s1", []model.Message{userMsg("overflow")}, loop.OwnerEnv()); !sr.PendingFull {
t.Fatalf("overflow = %+v, want PendingFull", sr)
}
data := pendingFullData(t, sink)
if data["clientMsgId"] != "cm-42" {
t.Fatalf("pending_full.clientMsgId = %v, want cm-42(客户端必须知道是哪条被丢)", data["clientMsgId"])
}
if _, present := data["messageId"]; present {
t.Fatalf("未落库的拒绝不许伪造 messageId:%+v", data)
}
// 既有字段一个不改、不删。
for _, key := range []string{"sessionId", "mode", "max"} {
if _, ok := data[key]; !ok {
t.Fatalf("pending_full 丢了既有字段 %q:%+v", key, data)
}
}
if data["sessionId"] != "s1" || data["max"] != 1 {
t.Fatalf("既有字段的值被改动:%+v", data)
}
}
// TestPendingFullOmitsIdentityWhenAbsent 钉住「省略、不伪造」:老客户端不带
// clientMsgId 时,两个新增键都必须缺席(而不是回一个空串或编造的 id)。
func TestPendingFullOmitsIdentityWhenAbsent(t *testing.T) {
t.Parallel()
run := newGatedRunner(8)
o, sink := seedOrchestrator(run, 1)
fillPendingQueue(t, o, 1)
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("overflow")}, loop.OwnerEnv()); !sr.PendingFull {
t.Fatalf("overflow = %+v, want PendingFull", sr)
}
data := pendingFullData(t, sink)
if _, present := data["clientMsgId"]; present {
t.Fatalf("未携带 clientMsgId 时该键必须缺席:%+v", data)
}
if _, present := data["messageId"]; present {
t.Fatalf("未落库时 messageId 必须缺席:%+v", data)
}
if data["sessionId"] != "s1" {
t.Fatalf("既有字段必须照旧:%+v", data)
}
}
// TestPendingFullCarriesMessageIdWhenAlreadyClaimed 钉住「已落库则带身份」:同一个
// clientMsgId 此前已经认领并落库时,队列满的拒绝要把它已有(且终将存在)的
// messageId 一并给出,让客户端能把那条气泡直接对齐,而不是重试一次。
func TestPendingFullCarriesMessageIdWhenAlreadyClaimed(t *testing.T) {
t.Parallel()
run := newGatedRunner(8)
sink := &recordingSink{}
o := NewOrchestrator(OrchestratorOptions{
Run: run.Run,
Emit: sink.Emit,
Log: slog.Default(),
MaxPending: 1,
MaxTail: 20,
History: &fakeHistory{},
})
ctx := withClientMsgHint(context.Background(), clientMsgHint{ClientMsgID: "cm-7"})
first := o.Submit(ctx, "s1", []model.Message{userMsg("第一次")}, loop.OwnerEnv())
if !first.Immediate || first.MessageID == "" {
t.Fatalf("第一次提交 = %+v, want Immediate 且带预分配 messageId", first)
}
fillPendingQueueAfterFirst(t, o, 1)
retry := o.Submit(ctx, "s1", []model.Message{userMsg("重发同一条")}, loop.OwnerEnv())
if !retry.PendingFull {
t.Fatalf("重发 = %+v, want PendingFull(队列满是先于判重的第一道门)", retry)
}
data := pendingFullData(t, sink)
if data["clientMsgId"] != "cm-7" {
t.Fatalf("clientMsgId = %v, want cm-7", data["clientMsgId"])
}
if data["messageId"] != first.MessageID {
t.Fatalf("messageId = %v, want %q(该键此前已落库,必须给出已有的那条消息 id)", data["messageId"], first.MessageID)
}
}
// fillPendingQueueAfterFirst 在已有一个在飞回合的前提下填满待处理队列。
func fillPendingQueueAfterFirst(t *testing.T, o *Orchestrator, maxPending int) {
t.Helper()
for i := 0; i < maxPending; i++ {
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("排队")}, loop.OwnerEnv()); !sr.Pending {
t.Fatalf("第 %d 条排队 = %+v, want Pending", i, sr)
}
}
}
// newOrchestratedTestHub 构建一个接了编排器的 hub(回合驱动器由调用方给出),使
// 「必须在编排器之下才成立」的行为(语音回合、队列满拒绝)能被单元级回归。
func newOrchestratedTestHub(t *testing.T, run TurnRunner, hist HistoryStore) *hub {
t.Helper()
hb := newTestHub(&stubRunner{res: &loop.Result{Text: "ok", StopReason: "stop"}}, nil)
hb.orchestrator = NewOrchestrator(OrchestratorOptions{
Run: run,
Emit: func(sessionID, typ string, data map[string]any) {
hb.broadcast(wsEvent{Type: typ, SessionID: sessionID, Data: data})
},
Log: sourcedLog(nil),
MaxTail: 20,
History: hist,
MaxPending: defaultMaxPending,
})
return hb
}
// TestPendingFullNotifiesSenderDirectly 钉住定向拒绝帧:队列满时,发送这条消息的
// 连接必须收到一条 {type:"error", data:{code:"chat_busy"}}(与 REST 的 409 同码),
// 且带上被打回那条的 clientMsgId;不带 clientMsgId 时该键缺席。
func TestPendingFullNotifiesSenderDirectly(t *testing.T) {
t.Parallel()
run := newGatedRunner(8)
hb := newOrchestratedTestHub(t, run.Run, nil)
c := newOwnerConn(hb, 64)
hb.register(c)
// 用编排器直接填队列:三条 route 各自起 goroutine,提交顺序不确定,而这道
// 门本身依赖「先忙起来、再满」。
fillPendingQueue(t, hb.orchestrator, defaultMaxPending)
if got := hb.orchestrator.QueueDepth("s1"); got != defaultMaxPending {
t.Fatalf("队列深度 = %d, want %d", got, defaultMaxPending)
}
// 第三条经 WS 帧进来 ⇒ 它带着「定向回这条连接」的回调。
c.route(wsInbound{Type: "chat", Data: json.RawMessage(`{"sessionId":"s1","text":"overflow","clientMsgId":"cm-9"}`)})
full, rejected := waitFullAndRejection(t, c)
if full["clientMsgId"] != "cm-9" {
t.Fatalf("广播的 pending_full.clientMsgId = %v, want cm-9", full["clientMsgId"])
}
if rejected["clientMsgId"] != "cm-9" || rejected["code"] != wsChatBusyCode {
t.Fatalf("定向拒绝帧 = %+v, want clientMsgId=cm-9 且 code=%q", rejected, wsChatBusyCode)
}
if rejected["sessionId"] != "s1" {
t.Fatalf("定向拒绝帧必须标明会话:%+v", rejected)
}
if _, present := rejected["messageId"]; present {
t.Fatalf("未落库的拒绝不许伪造 messageId:%+v", rejected)
}
// 老客户端(不带 clientMsgId):照旧收到拒绝帧,但两个身份键都必须缺席。
c.route(wsInbound{Type: "chat", Data: json.RawMessage(`{"sessionId":"s1","text":"again"}`)})
_, rejected = waitFullAndRejection(t, c)
if rejected["code"] != wsChatBusyCode {
t.Fatalf("定向拒绝帧 = %+v, want code=%q", rejected, wsChatBusyCode)
}
for _, key := range []string{"clientMsgId", "messageId"} {
if _, present := rejected[key]; present {
t.Fatalf("未携带身份时 %q 必须缺席:%+v", key, rejected)
}
}
}
// waitFullAndRejection 从连接上收集一条 pending_full 广播与一条定向拒绝帧。
func waitFullAndRejection(t *testing.T, c *wsConn) (full, rejected map[string]any) {
t.Helper()
deadline := time.After(3 * time.Second)
for full == nil || rejected == nil {
select {
case b := <-c.send:
var ev wsEvent
if err := json.Unmarshal(b, &ev); err != nil {
t.Fatalf("解码帧 %s: %v", b, err)
}
data, _ := ev.Data.(map[string]any)
switch {
case ev.Type == EventPendingFull:
full = data
case ev.Type == "error" && data["code"] == wsChatBusyCode:
rejected = data
}
case <-deadline:
t.Fatalf("等待 pending_full / 定向拒绝帧超时(full=%v rejected=%v)", full, rejected)
}
}
return full, rejected
}