- 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,避免与契约批次打架)。
239 lines
9.3 KiB
Go
239 lines
9.3 KiB
Go
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
|
||
}
|