package gateway import ( "context" "encoding/json" "strings" "sync" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" "git.yeij.top/AskaEth/Cyrene_v2/internal/db" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "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/persist" ) // 本文件锁定裁决 13.4.5 在 WS 上行 chat 路径上的落地口径(design/15 §2.1): // **会话消息不能被 drop** ——「bus 的 drop 是给事件/通知用的」,用户消息丢了 // 就是上下文不完整。因此上行 chat 直接进入会话编排器的非阻塞 Submit, // 绝不经可丢弃的 bus 通道去驱动回合。 // saturatedBus 模拟 bus 文档明示的代价:订阅的有界缓冲满时 Publish 丢弃事件。 // 它记录每一次发布而不投递,用于证明会话消息不再依赖那条通道 —— // 若 chat 仍经 bus,用户消息会在「bus 已饱和」时静默消失(既不落库也不驱动回合)。 type saturatedBus struct { mu sync.Mutex published []string } func (b *saturatedBus) Publish(_ context.Context, subject string, _ any) error { b.mu.Lock() b.published = append(b.published, subject) b.mu.Unlock() return nil } func (b *saturatedBus) Subscribe(context.Context, string, bus.Handler) (func(), error) { return func() {}, nil } func (b *saturatedBus) Close() error { return nil } // subjects 返回迄今被发布的主题快照。 func (b *saturatedBus) subjects() []string { b.mu.Lock() defer b.mu.Unlock() return append([]string(nil), b.published...) } // recordingRunner 是一个把每次被驱动的回合记下来的 Chatter 接缝:断言 // 「用户消息真的到达了 agent」需要观测 runner 而非从广播反推。 type recordingRunner struct { mu sync.Mutex calls []string } func (r *recordingRunner) Run(_ context.Context, _ string, msgs []model.Message) (*loop.Result, error) { var text string if n := len(msgs); n > 0 { text = messageText(msgs[n-1]) } r.mu.Lock() r.calls = append(r.calls, text) r.mu.Unlock() return &loop.Result{Text: "收到", Steps: 1, StopReason: "stop"}, nil } // count 返回已驱动的回合数。 func (r *recordingRunner) count() int { r.mu.Lock() defer r.mu.Unlock() return len(r.calls) } // texts 返回被驱动的回合的用户文本快照。 func (r *recordingRunner) texts() []string { r.mu.Lock() defer r.mu.Unlock() return append([]string(nil), r.calls...) } // TestWSChatFrameSurvivesSaturatedBus 证明一条 WS 上行 chat 帧在 bus 完全 // 不投递(饱和丢弃)时仍然完整处理:驱动回合 + 用户消息与助手回复都落库。 // 修复前该帧只被 publishPublic 丢进 bus,于是既不驱动回合也不落库 —— // 用户消息在「忙时连发」场景下静默消失(13.4.5 的完整性)。 func TestWSChatFrameSurvivesSaturatedBus(t *testing.T) { t.Parallel() run := &recordingRunner{} hist := newReplayHistoryStore() sb := &saturatedBus{} srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{Loop: run, History: hist, Bus: sb}) conn := dialWS(t, ts.URL, signAny(t, jwt, "acct", auth.RoleOwner)) defer conn.Close() waitConnCount(t, srv, 1) if err := conn.WriteJSON(wsInbound{ Type: "chat", Data: json.RawMessage(`{"sessionId":"web","text":"第一条","deviceId":"web:test:1"}`), }); err != nil { t.Fatalf("write chat: %v", err) } // 回合被驱动,且携带的正是用户刚发的那条文本。 waitUntil(t, "chat 帧驱动回合", func() bool { return run.count() == 1 }) if got := run.texts(); len(got) != 1 || got[0] != "第一条" { t.Fatalf("driven turns = %v, want [\"第一条\"]", got) } // 用户消息与助手回复都落库:消息不丢、会话上下文完整。 waitUntil(t, "用户+助手消息落库", func() bool { return len(hist.records()) == 2 }) recs := hist.records() if recs[0].Role != "user" || recs[0].Content != "第一条" { t.Fatalf("user record = %+v, want the sent text persisted", recs[0]) } if recs[1].Role != "assistant" || recs[1].Content != storedParagraph("收到") { t.Fatalf("assistant record = %+v, want the reply persisted", recs[1]) } // 会话消息绝不经可丢弃通道:chat 主题上不应有任何发布。 for _, subject := range sb.subjects() { if subject == wsChatSubject { t.Fatalf("chat 帧仍被发布到可丢弃的 bus 主题 %q(design/15 §2.1 禁止会话消息走 drop 通道)", subject) } } } // TestWSChatFrameBusyStaysPendingNotDropped 锁定 design/15 §2.1 的忙碌分支: // 上一条回合未结束时又发一条,第二条不阻塞、不被丢,而是挂进该会话的 // pending 队列(裁决 13.4.5「真并发、不阻塞主会话」的消息层落地)。 func TestWSChatFrameBusyStaysPendingNotDropped(t *testing.T) { t.Parallel() release := make(chan struct{}) run := &blockingRunner{release: release} hist := newReplayHistoryStore() sb := &saturatedBus{} srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{Loop: run, History: hist, Bus: sb}) conn := dialWS(t, ts.URL, signAny(t, jwt, "acct", auth.RoleOwner)) defer conn.Close() waitConnCount(t, srv, 1) if err := conn.WriteJSON(wsInbound{Type: "chat", Data: json.RawMessage(`{"sessionId":"web","text":"A"}`)}); err != nil { t.Fatalf("write chat A: %v", err) } waitUntil(t, "A 回合在飞", func() bool { return run.started() == 1 }) if err := conn.WriteJSON(wsInbound{Type: "chat", Data: json.RawMessage(`{"sessionId":"web","text":"B"}`)}); err != nil { t.Fatalf("write chat B: %v", err) } // B 到达时 A 仍在飞:它必须进 pending(深度 1),且立即落库。 waitUntil(t, "B 进入 pending", func() bool { return srv.hub.orchestrator.QueueDepth("web") == 1 }) waitUntil(t, "A 与 B 的消息都落库", func() bool { return len(hist.records()) == 2 }) if got := hist.records(); got[0].Content != "A" || got[1].Content != "B" { t.Fatalf("persisted = %q,%q, want A,B (排队消息也必须落库,不得丢)", got[0].Content, got[1].Content) } close(release) waitUntil(t, "会话排空", func() bool { return !srv.hub.orchestrator.Busy("web") && srv.hub.orchestrator.QueueDepth("web") == 0 }) if got := run.texts(); len(got) != 2 || got[0] != "A" || got[1] != "B" { t.Fatalf("driven turns = %v, want A then B (有序续接,不丢)", got) } } // blockingRunner 是一个停留在 Run 里直到 release 被关闭的 Chatter:它让会话 // 稳定处于 busy,使测试能确定地断言「忙时到达」的分支。 type blockingRunner struct { release chan struct{} mu sync.Mutex calls []string seen int } func (r *blockingRunner) Run(ctx context.Context, _ string, msgs []model.Message) (*loop.Result, error) { var text string if n := len(msgs); n > 0 { text = messageText(msgs[n-1]) } r.mu.Lock() r.calls = append(r.calls, text) r.seen++ r.mu.Unlock() select { case <-r.release: case <-ctx.Done(): return nil, ctx.Err() } return &loop.Result{Text: "reply-" + text, Steps: 1, StopReason: "stop"}, nil } func (r *blockingRunner) started() int { r.mu.Lock() defer r.mu.Unlock() return r.seen } func (r *blockingRunner) texts() []string { r.mu.Lock() defer r.mu.Unlock() return append([]string(nil), r.calls...) } // loopChatAdapter 是集成用例使用的真实 model.Adapter:第一轮请求一个工具 // (使循环落下一份**可恢复**的 tool_calls checkpoint),第二轮阻塞到 ctx 被 // 取消(复现用户按「停下」中断在飞回合),之后每次直接收尾。 // // unwind 是被取消之后到交还控制流之间的延迟:真实 provider 关闭在流、上层 // 拆解在途请求都不是瞬时的,因此「中断」与「被放弃回合的快照落定」之间客观 // 存在一个窗口。默认 0(瞬时),确定性用例把它拉长成一个可测窗口。 type loopChatAdapter struct { mu sync.Mutex n int reqs []model.Request unwind time.Duration } func (a *loopChatAdapter) Name() string { return "loop-chat" } func (a *loopChatAdapter) Chat(ctx context.Context, req model.Request, _ model.Options) (model.Response, error) { a.mu.Lock() a.n++ n := a.n a.reqs = append(a.reqs, req) a.mu.Unlock() switch n { case 1: return model.Response{Content: `{"tool_calls":[{"name":"echo","args":{"q":"x"}}]}`}, nil case 2: <-ctx.Done() if a.unwind > 0 { time.Sleep(a.unwind) } return model.Response{}, ctx.Err() default: return model.Response{Content: "好的"}, nil } } func (a *loopChatAdapter) ChatStream(ctx context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) { return a.Chat(ctx, req, model.Options{}) } // calls 返回已发生的模型调用次数。 func (a *loopChatAdapter) calls() int { a.mu.Lock() defer a.mu.Unlock() return a.n } // lastRequest 返回最近一次模型请求。 func (a *loopChatAdapter) lastRequest() model.Request { a.mu.Lock() defer a.mu.Unlock() if len(a.reqs) == 0 { return model.Request{} } return a.reqs[len(a.reqs)-1] } // TestInterruptThenChatKeepsLatestContextEndToEnd 是 13.4.5 的集成用例 // (真 WS + 真编排器 + **真 loop** + 真 persist.Store,只有模型适配器是脚本化的): // 回合 A 在工具循环中途被 `interrupt` 帧中断后,下一条消息 B 必须带着 B 自己的 // 最新上下文到达模型,而不是继承 A 留下的旧窗口。 // // 它同时钉住本组改动所依赖的组合事实:编排器的每个回合都以**同一个会话 id** // 驱动 loop,因此 loop 的 checkpoint(键 `sessionID:step`)在回合之间是共享的 —— // 被放弃回合的快照若不落定为不可恢复,下一条消息必然被它顶掉。 // // 本用例把「中断」到「被放弃回合收尾」之间的窗口交给调度(适配器瞬时交还), // 因此整包运行时它偶发失败;把同一条产品行为钉在确定性窗口下的孪生用例是 // TestInterruptThenChatSlowUnwindKeepsLatestContext。 func TestInterruptThenChatKeepsLatestContextEndToEnd(t *testing.T) { t.Parallel() ctx := context.Background() log := gwTestLogger() conn, err := db.Open(ctx, ":memory:", log) if err != nil { t.Fatalf("open memory db: %v", err) } t.Cleanup(func() { _ = conn.Close() }) cp, err := persist.New(ctx, conn, log) if err != nil { t.Fatalf("persist.New: %v", err) } ad := &loopChatAdapter{} ag, err := agent.New("You are a helpful assistant.", nil, nil, agent.WithModel(ad, nil)) if err != nil { t.Fatalf("agent.New: %v", err) } l := loop.New(loop.Options{Agent: ag, Checkpoint: cp, MaxRounds: 8}) hist := newReplayHistoryStore() sb := &saturatedBus{} srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{Loop: l, History: hist, Bus: sb}) conn1 := dialWS(t, ts.URL, signAny(t, jwt, "acct", auth.RoleOwner)) defer conn1.Close() waitConnCount(t, srv, 1) // 回合 A:步 1 请求工具(写下可恢复快照),步 2 的模型调用被中断。 if err := conn1.WriteJSON(wsInbound{Type: "chat", Data: json.RawMessage(`{"sessionId":"web","text":"A"}`)}); err != nil { t.Fatalf("write chat A: %v", err) } waitUntil(t, "回合 A 进入第二次模型调用", func() bool { return ad.calls() >= 2 }) if err := conn1.WriteJSON(wsInbound{Type: "interrupt", Data: json.RawMessage(`{"sessionId":"web"}`)}); err != nil { t.Fatalf("write interrupt: %v", err) } // 下一条消息 B:必须带着 B 自己到达模型(不得继承 A 的旧窗口)。 if err := conn1.WriteJSON(wsInbound{Type: "chat", Data: json.RawMessage(`{"sessionId":"web","text":"B"}`)}); err != nil { t.Fatalf("write chat B: %v", err) } waitUntil(t, "回合 B 被驱动", func() bool { return ad.calls() >= 3 }) got := ad.lastRequest() var texts []string hasB := false for _, m := range got.Messages { text := messageText(m) texts = append(texts, string(m.Role)+":"+text) if text == "B" { hasB = true } if strings.Contains(text, "tool echo result") { t.Fatalf("中断之后的回合仍带着被放弃回合的旧窗口:%v", texts) } } if !hasB { t.Fatalf("中断之后的回合上下文不含新消息 B:%v(上下文不最新/不完整)", texts) } } // TestInterruptThenChatSlowUnwindKeepsLatestContext 是上一条用例的确定性版本。 // 上一条把「中断」到「被放弃回合收尾」之间的窗口交给调度(适配器瞬时交还), // 整包运行时该窗口会被调度压开,因此它偶发失败;本用例把收尾拉长成一个确定 // 可测的窗口(取消之后适配器仍需 300ms 才交还控制流,快照落定因此必然晚于 // 中断本身),使同一条产品行为不再靠时序运气: // // 无论被中断的回合收尾多慢,下一条消息都必须等它真正返回之后再开始 —— 否则 // 它会在收尾落定之前读到被放弃回合那份**仍可恢复**的旧快照并续接它,把刚发出 // 的消息整段顶掉(裁决 13.4.5 的最新性 / 完整性)。围栏见 session_turn_fence.go。 func TestInterruptThenChatSlowUnwindKeepsLatestContext(t *testing.T) { t.Parallel() ctx := context.Background() log := gwTestLogger() conn, err := db.Open(ctx, ":memory:", log) if err != nil { t.Fatalf("open memory db: %v", err) } t.Cleanup(func() { _ = conn.Close() }) cp, err := persist.New(ctx, conn, log) if err != nil { t.Fatalf("persist.New: %v", err) } ad := &loopChatAdapter{unwind: 300 * time.Millisecond} ag, err := agent.New("You are a helpful assistant.", nil, nil, agent.WithModel(ad, nil)) if err != nil { t.Fatalf("agent.New: %v", err) } l := loop.New(loop.Options{Agent: ag, Checkpoint: cp, MaxRounds: 8}) hist := newReplayHistoryStore() srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{Loop: l, History: hist, Bus: &saturatedBus{}}) conn1 := dialWS(t, ts.URL, signAny(t, jwt, "acct", auth.RoleOwner)) defer conn1.Close() waitConnCount(t, srv, 1) // 回合 A:步 1 请求工具,步 2 的模型调用被中断,且取消后仍要 300ms 才 // 交还控制流 —— 被放弃回合的 checkpoint 落定因此晚于这条新消息。 if err := conn1.WriteJSON(wsInbound{Type: "chat", Data: json.RawMessage(`{"sessionId":"web","text":"A"}`)}); err != nil { t.Fatalf("write chat A: %v", err) } waitUntil(t, "回合 A 进入第二次模型调用", func() bool { return ad.calls() >= 2 }) if err := conn1.WriteJSON(wsInbound{Type: "interrupt", Data: json.RawMessage(`{"sessionId":"web"}`)}); err != nil { t.Fatalf("write interrupt: %v", err) } if err := conn1.WriteJSON(wsInbound{Type: "chat", Data: json.RawMessage(`{"sessionId":"web","text":"B"}`)}); err != nil { t.Fatalf("write chat B: %v", err) } waitUntil(t, "回合 B 被驱动", func() bool { return ad.calls() >= 3 }) got := ad.lastRequest() var texts []string hasB := false for _, m := range got.Messages { text := messageText(m) texts = append(texts, string(m.Role)+":"+text) if text == "B" { hasB = true } if strings.Contains(text, "no tool dispatcher configured") { t.Fatalf("中断之后的回合仍续接了被放弃回合的旧窗口:%v", texts) } } if !hasB { t.Fatalf("中断之后的回合上下文不含新消息 B:%v(上下文不最新/不完整)", texts) } }