package gateway import ( "context" "fmt" "log/slog" "sync" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // 本文件是 design/15 §2.5「忙时并行回合(多路)」**基础管线**的回归套件 //(主人 2026-10-05 裁决 B)。它钉住六件事: // // ① 省略 `parallel` ⇒ 行为逐字不变(不并行、不新增帧键、报空闲时点不变); // ② 显式 `parallel` 且额度未满 ⇒ 真的起第二路(两条不同的 turnId、各自的路标识); // ③ 额度满 ⇒ 落回排队(不报错、不丢消息),并如实给出 reason; // ④ 单路时 `session_idle` 的时点不变; // ⑤ 并行时 `session_idle` 等到**所有路**收尾; // ⑥ turnId 一致性(编排器生成的那个 = 交给 Run、并由流帧盖上壳的那个)。 // // 夹具刻意用**可逐路放行**的 runner(放行顺序即收尾顺序),因此「谁先收尾」是 // 确定性的,断言的是时序而不是睡眠。 // gatedLegRunner 是可控的 TurnRunner:每条路的 Run 都阻塞在自己的放行闸上, // 测试按启动顺序逐路放行,因此能精确断言「一路收尾而另一路仍在飞」的那一刻。 type gatedLegRunner struct { mu sync.Mutex calls []callRecord gates []chan struct{} started chan struct{} } func newGatedLegRunner() *gatedLegRunner { return &gatedLegRunner{started: make(chan struct{}, 8)} } func (g *gatedLegRunner) Run(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (*loop.Result, error) { gate := make(chan struct{}) g.mu.Lock() idx := len(g.calls) g.calls = append(g.calls, callRecord{sessionID: sessionID, messages: append([]model.Message(nil), messages...), env: env, ctx: ctx}) g.gates = append(g.gates, gate) g.mu.Unlock() g.started <- struct{}{} select { case <-gate: case <-ctx.Done(): return nil, ctx.Err() } return &loop.Result{Text: fmt.Sprintf("reply-%d", idx+1), Steps: 1, StopReason: "stop"}, nil } // waitStarted 等到至少 n 条路真的进了 runner(而不是只被登记)。 func (g *gatedLegRunner) waitStarted(n int) []callRecord { deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { g.mu.Lock() got := len(g.calls) out := append([]callRecord(nil), g.calls...) g.mu.Unlock() if got >= n { return out } time.Sleep(time.Millisecond) } panic("timed out waiting for leg starts") } // release 放行第 i 条启动的路(i 从 0 起)。它幂等(重复关闭会 panic, // 因此用 select 保护),使测试的放行顺序就是收尾顺序。 func (g *gatedLegRunner) release(i int) { g.mu.Lock() gate := g.gates[i] g.mu.Unlock() select { case <-gate: default: close(gate) } } // turnStateRec 是一次状态帧(含帧壳上的 turnId,可能为空)。 type turnStateRec struct { sessionID string turnID string typ string data map[string]any } // turnSink 同时实现 StateSink 与 TurnStateSink,把两类帧记在同一个序列里, // 使「带 turnId 的变体真的把 turnId 盖到帧壳上」可被直接断言。 type turnSink struct { mu sync.Mutex events []turnStateRec } func (s *turnSink) Emit(sessionID, typ string, data map[string]any) { s.record(turnStateRec{sessionID: sessionID, typ: typ, data: data}) } func (s *turnSink) EmitTurn(sessionID, turnID, typ string, data map[string]any) { s.record(turnStateRec{sessionID: sessionID, turnID: turnID, typ: typ, data: data}) } func (s *turnSink) record(rec turnStateRec) { s.mu.Lock() s.events = append(s.events, rec) s.mu.Unlock() } func (s *turnSink) snapshot() []turnStateRec { s.mu.Lock() defer s.mu.Unlock() return append([]turnStateRec(nil), s.events...) } // ofType 返回某类状态帧(按发生顺序)。 func (s *turnSink) ofType(typ string) []turnStateRec { var out []turnStateRec for _, e := range s.snapshot() { if e.typ == typ { out = append(out, e) } } return out } // parallelFixture 是并行管线用例的公共夹具。 type parallelFixture struct { o *Orchestrator run *gatedLegRunner sink *turnSink history *fakeHistory } // newParallelFixture 按给定的队列容量与并行上限构建编排器(Log 走丢弃型, // 避免并行用例互相污染日志断言)。 func newParallelFixture(maxPending, maxParallel int) *parallelFixture { run := newGatedLegRunner() sink := &turnSink{} hist := &fakeHistory{} o := NewOrchestrator(OrchestratorOptions{ Run: run.Run, Emit: sink.Emit, EmitTurn: sink.EmitTurn, Log: slog.New(slog.NewTextHandler(discardWriter{}, nil)), MaxPending: maxPending, MaxTail: 20, History: hist, MaxParallelTurns: maxParallel, }) return ¶llelFixture{o: o, run: run, sink: sink, history: hist} } // discardWriter 接收被丢弃的日志字节(用例只关心状态帧与历史行)。 type discardWriter struct{} func (discardWriter) Write(p []byte) (int, error) { return len(p), nil } // waitActiveTurns 等到某会话的在途路数等于 want(超时即失败),使断言不靠睡眠。 func waitActiveTurns(t *testing.T, o *Orchestrator, sessionID string, want int) { t.Helper() deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { if o.ActiveTurns(sessionID) == want { return } time.Sleep(time.Millisecond) } t.Fatalf("在途路数 = %d, want %d", o.ActiveTurns(sessionID), want) } // stateInt 从状态帧的 data 里取一个整数键(缺失或类型不符时返回 -1)。 func stateInt(rec turnStateRec, key string) int { v, ok := rec.data[key].(int) if !ok { return -1 } return v } // stateKeyPresent 报告状态帧的 data 里某个键是否**在场**(用于「该键缺席」的断言: // 缺席与空串对客户端含义不同,绝不伪造)。 func stateKeyPresent(rec turnStateRec, key string) bool { _, ok := rec.data[key] return ok } // assistantContents 返回已落库记录里的助手回复正文(按顺序)。 func assistantContents(msgs []history.Message) []string { out := make([]string, 0, len(msgs)) for _, m := range msgs { if m.Role == history.RoleAssistant { out = append(out, m.Content) } } return out } // windowText 返回一路播种到的模型消息正文(按顺序),供断言 I4「每路一份窗口」。 func windowText(msgs []model.Message) []string { out := make([]string, 0, len(msgs)) for _, m := range msgs { out = append(out, messageText(m)) } return out } // parallelCtx 返回一条**显式声明并行**的提交上下文(与 WS/REST 边界同源的 ctx 通道)。 func parallelCtx() context.Context { return withParallelTurn(context.Background(), true) } // --------------------------------------------------------------------------- // ① 省略 parallel ⇒ 逐字不变 // --------------------------------------------------------------------------- // TestParallelOmittedKeepsQueueingBehaviour 钉住「老客户端零影响」:不声明 parallel // 的忙时消息仍然入队,帧形状不变(**没有** reason 键),且始终只有一条在途路。 func TestParallelOmittedKeepsQueueingBehaviour(t *testing.T) { t.Parallel() f := newParallelFixture(5, 2) if sr := f.o.Submit(context.Background(), "web", []model.Message{userMsg("第一问")}, loop.OwnerEnv()); !sr.Immediate || sr.Parallel { t.Fatalf("空闲提交 = %+v, want Immediate 且非 Parallel", sr) } f.run.waitStarted(1) if got := f.o.ActiveTurns("web"); got != 1 { t.Fatalf("在途路数 = %d, want 1", got) } // 忙时第二条(未声明并行)⇒ 排队,且帧上没有任何新键。 sr := f.o.Submit(context.Background(), "web", []model.Message{userMsg("第二问")}, loop.OwnerEnv()) if !sr.Pending || sr.Parallel || sr.QueueDepth != 1 { t.Fatalf("忙时未声明并行的提交 = %+v, want Pending(1) 且非 Parallel", sr) } if got := f.o.ActiveTurns("web"); got != 1 { t.Fatalf("排队时在途路数 = %d, want 1(绝不新开路)", got) } acks := f.sink.ofType(EventPendingAck) if len(acks) != 1 { t.Fatalf("pending_ack 帧数 = %d, want 1", len(acks)) } if stateKeyPresent(acks[0], "reason") { t.Fatalf("未声明并行的排队不该带 reason,得到 %v", acks[0].data) } if stateInt(acks[0], "queueDepth") != 1 { t.Fatalf("pending_ack queueDepth = %v, want 1", acks[0].data["queueDepth"]) } if stateInt(acks[0], "activeTurns") != -1 { t.Fatalf("pending_ack 不该带 activeTurns(契约里只有 session_busy/idle/drain/interrupted 带它):%v", acks[0].data) } // 放行第一路 ⇒ 弹队首续跑(仍是串行,一条在途路)。 f.run.release(0) calls := f.run.waitStarted(2) if got := f.o.ActiveTurns("web"); got != 1 { t.Fatalf("续跑时在途路数 = %d, want 1", got) } if got := loop.ParallelLegFrom(calls[1].ctx); got != "" { t.Fatalf("续跑路的路标识 = %q, want 空串(串行路不挂并发路标识)", got) } if loop.TurnIDFrom(calls[1].ctx) == loop.TurnIDFrom(calls[0].ctx) { t.Fatal("续跑路必须是一条**新路**,turnId 不能与上一路相同") } // 工具互斥身份是**另一条通道**(design/15 §2.5.7):续跑路同样必须带上它, // 取值就是它自己的 turnId —— 否则串行续跑路在写类工具上不受保护。它与 // checkpoint 归属键无关,所以上面那条「空串」断言不该被它推翻。 if got := loop.ToolLegIDFrom(calls[1].ctx); got != loop.TurnIDFrom(calls[1].ctx) { t.Fatalf("续跑路的互斥身份 = %q, want %q(每一路都要盖)", got, loop.TurnIDFrom(calls[1].ctx)) } if loop.ToolLegIDFrom(calls[1].ctx) == loop.ToolLegIDFrom(calls[0].ctx) { t.Fatal("两条路的互斥身份必须不同,否则同文件写会被当成「同一路自己的调用」而放行") } drains := f.sink.ofType(EventPendingDrain) if len(drains) != 1 || stateInt(drains[0], "activeTurns") != 1 { t.Fatalf("pending_drain = %v, want 一帧且 activeTurns=1", drains) } f.run.release(1) if !waitIdle(f.o, "web") { t.Fatal("会话没有回到空闲") } idle := f.sink.ofType(EventSessionIdle) if len(idle) != 1 || stateInt(idle[0], "activeTurns") != 0 { t.Fatalf("session_idle = %v, want 一帧且 activeTurns=0", idle) } busy := f.sink.ofType(EventSessionBusy) if len(busy) != 1 || stateInt(busy[0], "activeTurns") != 1 { t.Fatalf("session_busy = %v, want 一帧且 activeTurns=1", busy) } if got := len(assistantContents(f.history.records())); got != 2 { t.Fatalf("助手回复行数 = %d, want 2(每条消息各答一次)", got) } } // TestSessionIdleTimingSingleLegUnchanged 钉住④:单路(含排队续跑)的 // `session_idle` 时点与现状一致 —— 队列空且本路收尾就报,绝不多等、绝不少报。 func TestSessionIdleTimingSingleLegUnchanged(t *testing.T) { t.Parallel() f := newParallelFixture(5, 2) if sr := f.o.Submit(context.Background(), "web", []model.Message{userMsg("甲")}, loop.OwnerEnv()); !sr.Immediate { t.Fatalf("提交 = %+v, want Immediate", sr) } f.run.waitStarted(1) if got := f.sink.ofType(EventSessionIdle); len(got) != 0 { t.Fatalf("回合在飞时不该报空闲,得到 %v", got) } if got := f.o.ActiveTurns("web"); got != 1 { t.Fatalf("在途路数 = %d, want 1", got) } f.run.release(0) if !waitIdle(f.o, "web") { t.Fatal("会话没有回到空闲") } if got := f.sink.ofType(EventSessionIdle); len(got) != 1 { t.Fatalf("session_idle 帧数 = %d, want 1", len(got)) } if got := f.o.ActiveTurns("web"); got != 0 { t.Fatalf("收尾后在途路数 = %d, want 0", got) } } // --------------------------------------------------------------------------- // ② 显式 parallel 且额度未满 ⇒ 真的起第二路 // --------------------------------------------------------------------------- // TestParallelDeclaredStartsSecondLeg 钉住②与⑥:第二条显式声明并行的消息**立即** // 起一条独立的路(两路 turnId 不同、并行路带自己的路标识),两路同时生长,各自 // 落一行助手回复,且 `session_idle` 只在两路都收尾后出现一次。 func TestParallelDeclaredStartsSecondLeg(t *testing.T) { t.Parallel() f := newParallelFixture(5, 2) first := f.o.Submit(context.Background(), "web", []model.Message{userMsg("第一问")}, loop.OwnerEnv()) if !first.Immediate || first.TurnID == "" { t.Fatalf("第一路 = %+v, want Immediate 且带 turnId", first) } f.run.waitStarted(1) second := f.o.Submit(parallelCtx(), "web", []model.Message{userMsg("第二问")}, loop.OwnerEnv()) if !second.Immediate || !second.Parallel { t.Fatalf("显式并行提交 = %+v, want Immediate 且 Parallel", second) } if second.TurnID == "" || second.TurnID == first.TurnID { t.Fatalf("第二路 turnId = %q,第一路 = %q:必须各自不同", second.TurnID, first.TurnID) } if f.o.QueueDepth("web") != 0 { t.Fatalf("并行路不该入队,队列深度 = %d", f.o.QueueDepth("web")) } calls := f.run.waitStarted(2) waitActiveTurns(t, f.o, "web", 2) // I4:每一路都有**自己的一份窗口** —— 各自带来自己那条用户消息,且看不到 // 对方在跑的东西(对方的答复尚未落库,因此不在任何一路的转录里)。 if got := windowText(calls[0].messages); len(got) != 1 || got[0] != "第一问" { t.Fatalf("第一路的窗口 = %v, want [第一问]", got) } if got := windowText(calls[1].messages); len(got) != 1 || got[0] != "第二问" { t.Fatalf("第二路的窗口 = %v, want [第二问]", got) } // ⑥ 两路各自的 turnId:第一路不带并发路标识(checkpoint 归属键仍是 sessionID), // 第二路带自己的(归属键升为 {sessionID}#{turnID}),且都与提交时交回的 turnId 同值。 if got := loop.TurnIDFrom(calls[0].ctx); got != first.TurnID { t.Fatalf("第一路 ctx turnId = %q,提交回报 %q:两处必须同值", got, first.TurnID) } if got := loop.ParallelLegFrom(calls[0].ctx); got != "" { t.Fatalf("第一路不该挂并发路标识,得到 %q", got) } if got := loop.TurnIDFrom(calls[1].ctx); got != second.TurnID { t.Fatalf("第二路 ctx turnId = %q,提交回报 %q:两处必须同值", got, second.TurnID) } if got := loop.ParallelLegFrom(calls[1].ctx); got != second.TurnID { t.Fatalf("第二路路标识 = %q, want %q(checkpoint 归属键按路隔离)", got, second.TurnID) } // 忙态帧如实报告两路在途。 busy := f.sink.ofType(EventSessionBusy) if len(busy) != 2 || stateInt(busy[1], "activeTurns") != 2 { t.Fatalf("session_busy 帧 = %v, want 两帧且第二帧 activeTurns=2", busy) } // ⑤ 先收尾第二路:会话仍不空闲(第一路还在飞)。 f.run.release(1) waitActiveTurns(t, f.o, "web", 1) if got := f.sink.ofType(EventSessionIdle); len(got) != 0 { t.Fatalf("还有一路在飞时不该报空闲,得到 %v", got) } f.run.release(0) if !waitIdle(f.o, "web") { t.Fatal("所有路收尾后会话没有回到空闲") } idle := f.sink.ofType(EventSessionIdle) if len(idle) != 1 || stateInt(idle[0], "activeTurns") != 0 { t.Fatalf("session_idle = %v, want 恰好一帧且 activeTurns=0", idle) } // 两路各落一行助手回复(I5:绝不合并成一行、不覆盖对方),且每条用户消息只被 // 作答一次(两条 user 行、两条 assistant 行)。 rows := f.history.records() if got := len(assistantContents(rows)); got != 2 { t.Fatalf("助手回复行数 = %d, want 2", got) } users := 0 for _, m := range rows { if m.Role == history.RoleUser { users++ } } if users != 2 { t.Fatalf("用户消息行数 = %d, want 2(恰好一次)", users) } } // TestSessionIdleWaitsForAllLegs 是⑤的**最小时序**回归:并行两路,任意顺序收尾, // 只有第二路也收尾时才出现唯一的 session_idle。 func TestSessionIdleWaitsForAllLegs(t *testing.T) { t.Parallel() f := newParallelFixture(5, 2) f.o.Submit(context.Background(), "web", []model.Message{userMsg("A")}, loop.OwnerEnv()) f.run.waitStarted(1) f.o.Submit(parallelCtx(), "web", []model.Message{userMsg("B")}, loop.OwnerEnv()) f.run.waitStarted(2) waitActiveTurns(t, f.o, "web", 2) // 先收尾**第一**路(与上一个用例相反的收尾顺序,证明判据不是「最后启动的那一路」)。 f.run.release(0) waitActiveTurns(t, f.o, "web", 1) if got := f.sink.ofType(EventSessionIdle); len(got) != 0 { t.Fatalf("一路收尾、另一路仍在飞 ⇒ 不该报空闲,得到 %v", got) } f.run.release(1) if !waitIdle(f.o, "web") { t.Fatal("所有路收尾后会话没有回到空闲") } if got := f.sink.ofType(EventSessionIdle); len(got) != 1 { t.Fatalf("session_idle 帧数 = %d, want 1", len(got)) } } // --------------------------------------------------------------------------- // ③ 额度满 ⇒ 落回排队 // --------------------------------------------------------------------------- // TestParallelQuotaFullFallsBackToQueue 钉住③:额度(默认 2)用满后,再来的显式 // 并行消息**落回既有排队语义** —— 不报错、不打断、不丢,并如实给出 // `pending_ack{reason:"parallel_limit"}`;被排队的消息最终仍被作答一次。 func TestParallelQuotaFullFallsBackToQueue(t *testing.T) { t.Parallel() f := newParallelFixture(5, 2) f.o.Submit(context.Background(), "web", []model.Message{userMsg("第一问")}, loop.OwnerEnv()) f.run.waitStarted(1) if sr := f.o.Submit(parallelCtx(), "web", []model.Message{userMsg("第二问")}, loop.OwnerEnv()); !sr.Parallel { t.Fatalf("额度未满时应当照准并行,得到 %+v", sr) } f.run.waitStarted(2) waitActiveTurns(t, f.o, "web", 2) // 第三条声明并行的消息:额度已满 ⇒ 排队。 third := f.o.Submit(parallelCtx(), "web", []model.Message{userMsg("第三问")}, loop.OwnerEnv()) if !third.Accepted || !third.Pending || third.Parallel { t.Fatalf("额度满的并行提交 = %+v, want Accepted+Pending 且非 Parallel", third) } if third.QueueDepth != 1 { t.Fatalf("队列深度 = %d, want 1", third.QueueDepth) } acks := f.sink.ofType(EventPendingAck) if len(acks) != 1 || acks[0].data["reason"] != pendingAckReasonParallelLimit { t.Fatalf("pending_ack = %v, want reason=%q", acks, pendingAckReasonParallelLimit) } if got := f.o.ActiveTurns("web"); got != 2 { t.Fatalf("落回排队不该改变在途路数,得到 %d", got) } // 逐路收尾:排队的那条由结算中的一路续跑(起一条**串行**新路),最终被作答。 f.run.release(0) f.run.waitStarted(3) f.run.release(1) f.run.release(2) if !waitIdle(f.o, "web") { t.Fatal("会话没有回到空闲") } if got := len(assistantContents(f.history.records())); got != 3 { t.Fatalf("助手回复行数 = %d, want 3(排队的第三条也必须被作答一次)", got) } } // TestParallelDisabledByLimitBelowTwo 钉住「<2 = 关闭真并行 ⇒ 完全回落到现状」: // 上限为 1 时显式并行也一律排队,在途路数恒不越过 1。 func TestParallelDisabledByLimitBelowTwo(t *testing.T) { t.Parallel() f := newParallelFixture(5, 1) f.o.Submit(context.Background(), "web", []model.Message{userMsg("第一问")}, loop.OwnerEnv()) f.run.waitStarted(1) sr := f.o.Submit(parallelCtx(), "web", []model.Message{userMsg("第二问")}, loop.OwnerEnv()) if !sr.Pending || sr.Parallel { t.Fatalf("真并行被关闭时 = %+v, want Pending 且非 Parallel", sr) } if got := f.o.ActiveTurns("web"); got != 1 { t.Fatalf("在途路数 = %d, want 1(关闭真并行)", got) } acks := f.sink.ofType(EventPendingAck) if len(acks) != 1 || acks[0].data["reason"] != pendingAckReasonParallelLimit { t.Fatalf("pending_ack = %v, want 仍如实给出 reason", acks) } f.run.release(0) f.run.waitStarted(2) f.run.release(1) if !waitIdle(f.o, "web") { t.Fatal("会话没有回到空闲") } } // TestResolveMaxParallelTurns 钉住额度解析的三档边界(默认 / 关闭 / 硬上限)。 func TestResolveMaxParallelTurns(t *testing.T) { t.Parallel() cases := []struct { in int want int }{ {-5, defaultMaxParallelTurns}, {0, defaultMaxParallelTurns}, {1, 1}, {2, 2}, {3, 3}, {99, maxParallelTurnsHardCap}, } for _, c := range cases { if got := resolveMaxParallelTurns(c.in); got != c.want { t.Fatalf("resolveMaxParallelTurns(%d) = %d, want %d", c.in, got, c.want) } } }