Files
Cyrene_v2/internal/services/gateway/session_pending_full_test.go
T
AskaEth 3a01b4e236
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): 语音回合进编排+落库、G5 队列满带回身份、voice_output 归属(安卓 G5/C1/C3)
- 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,避免与契约批次打架)。
2026-10-04 17:59:28 +08:00

239 lines
9.3 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 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
}