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 }