package gateway import ( "context" "log/slog" "slices" "testing" "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.9「帧层」的回归:`turn_begin` 的发射(每次启动一路 // 恰好一帧,含续跑路)与 `steer_ack` 的帧壳 turnId。夹具复用 // session_parallel_test.go(同包)的可逐路放行 runner 与记录型状态汇聚点,因此 // 「哪一帧属于哪一路」是被观测到的事实,而不是靠睡眠赌出来的。 // // WS 层的对应断言(真连接上的 turn_begin 角色门、interrupt{turnId} 的端到端 // 行为)在 session_parallel_turn_interrupt_test.go。 // transcriptHistory 是 fakeHistory 的转录可读变体:`Recent` 返回已落库的行。 // 裸 fakeHistory 的 Recent 恒为空(那是 TestSteer_UnpersistedPendingStaysPending // 刻意依赖的行为,不能改),而 steer 边界的匹配**必须**读到转录行 —— 因此本 // 文件用嵌入的方式只覆盖这一个方法。 type transcriptHistory struct{ *fakeHistory } // Recent 返回该会话最近 n 条已落库行(最旧优先),与 *history.Store 的口径一致。 func (h *transcriptHistory) Recent(_ context.Context, sessionID string, n int) ([]history.Message, error) { rows := h.records() out := make([]history.Message, 0, len(rows)) for _, m := range rows { if m.SessionID == sessionID { out = append(out, m) } } if len(out) > n { out = out[len(out)-n:] } return out, nil } // frameMessageIDs 取出一帧 `turn_begin` 的 messageIds(缺席/形状不符时返回 nil)。 func frameMessageIDs(rec turnStateRec) []string { ids, _ := rec.data["messageIds"].([]string) return ids } // dataKeys 返回帧载荷的键(升序),用于「键集恰好是这几项」的断言 —— 多一个键 // 就是契约变更,少一个键客户端就配不上。 func dataKeys(data map[string]any) []string { out := make([]string, 0, len(data)) for k := range data { out = append(out, k) } slices.Sort(out) return out } // assertTurnBeginFrame 逐项断言一帧 `turn_begin`:帧壳 turnId 是该路的权威回合 // 标识,载荷键集与取值都按规范草案 §2.4 钉死。 func assertTurnBeginFrame(t *testing.T, what string, rec turnStateRec, kind string, leg, activeTurns int, turnID string, messageIDs []string) { t.Helper() if rec.sessionID != "web" { t.Fatalf("%s: 帧壳 sessionId = %q, want web", what, rec.sessionID) } if rec.typ != EventTurnBegin { t.Fatalf("%s: 帧类型 = %q, want %q", what, rec.typ, EventTurnBegin) } if rec.turnID != turnID { t.Fatalf("%s: 帧壳 turnId = %q, want %q(必须与该路的权威 turnId 同值,否则客户端两级分桶立即失配)", what, rec.turnID, turnID) } wantKeys := []string{"activeTurns", "kind", "leg", "messageIds"} if got := dataKeys(rec.data); !slices.Equal(got, wantKeys) { t.Fatalf("%s: 载荷键集 = %v, want %v", what, got, wantKeys) } if rec.data["kind"] != kind { t.Fatalf("%s: kind = %v, want %q", what, rec.data["kind"], kind) } if got := stateInt(rec, "leg"); got != leg { t.Fatalf("%s: leg = %d, want %d", what, got, leg) } if got := stateInt(rec, "activeTurns"); got != activeTurns { t.Fatalf("%s: activeTurns = %d, want %d", what, got, activeTurns) } if got := frameMessageIDs(rec); !slices.Equal(got, messageIDs) { t.Fatalf("%s: messageIds = %v, want %v(客户端据此把这些气泡置为灰态)", what, got, messageIDs) } } // --------------------------------------------------------------------------- // ① / 续跑:每次启动一路恰好一帧 turn_begin // --------------------------------------------------------------------------- // TestTurnBeginEmittedOncePerLegStarted 钉住①与续跑路:空闲启动的第一路发 // `turn_begin{kind:"turn", leg:1, activeTurns:1}` 且 messageIds 恰好是那条用户 // 消息的**权威 id**;队首续跑同样发一帧(kind 仍是 "turn",但它是一条**新路**, // turnId 与第一路不同)。两帧的帧壳 turnId 都必须等于交给 runner 的那个 id。 func TestTurnBeginEmittedOncePerLegStarted(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) } calls := f.run.waitStarted(1) frames := f.sink.ofType(EventTurnBegin) if len(frames) != 1 { t.Fatalf("第一路的 turn_begin 帧数 = %d, want 1(恰好一次)", len(frames)) } assertTurnBeginFrame(t, "第一路", frames[0], turnBeginKindTurn, 1, 1, first.TurnID, []string{"id-1"}) if got := loop.TurnIDFrom(calls[0].ctx); got != frames[0].turnID { t.Fatalf("交给 runner 的 turnId = %q,turn_begin 帧壳是 %q:两处必须同值", got, frames[0].turnID) } // 忙时第二条(未声明并行)⇒ 入队;它的落库 id 就是续跑路的 messageIds。 second := f.o.Submit(context.Background(), "web", []model.Message{userMsg("第二问")}, loop.OwnerEnv()) if !second.Pending || second.QueueDepth != 1 { t.Fatalf("第二条 = %+v, want Pending(1)", second) } f.run.release(0) calls = f.run.waitStarted(2) frames = f.sink.ofType(EventTurnBegin) if len(frames) != 2 { t.Fatalf("turn_begin 帧数 = %d, want 2(续跑也是一条新路,必须有自己的帧)", len(frames)) } resumeTurnID := loop.TurnIDFrom(calls[1].ctx) if resumeTurnID == first.TurnID { t.Fatal("续跑路必须是新路:turnId 不能与上一路相同") } assertTurnBeginFrame(t, "续跑路", frames[1], turnBeginKindTurn, 2, 1, resumeTurnID, []string{"id-2"}) f.run.release(1) if !waitIdle(f.o, "web") { t.Fatal("会话没有回到空闲") } if got := len(f.sink.ofType(EventTurnBegin)); got != 2 { t.Fatalf("收尾后的 turn_begin 帧数 = %d, want 2(收尾绝不再补发一帧)", got) } } // --------------------------------------------------------------------------- // ② 并行路:第二路有自己的 turn_begin(kind:"parallel") // --------------------------------------------------------------------------- // TestTurnBeginSecondLegIsParallelKind 钉住②:显式声明并行且照准的第二路发 // `turn_begin{kind:"parallel", leg:2, activeTurns:2}`,且它的 messageIds **只是** // 它自己那条消息 —— 两帧各钉各的消息,客户端因此能把两条回复分到两个气泡。 func TestTurnBeginSecondLegIsParallelKind(t *testing.T) { t.Parallel() f := newParallelFixture(5, 2) first := f.o.Submit(context.Background(), "web", []model.Message{userMsg("第一问")}, loop.OwnerEnv()) 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) } calls := f.run.waitStarted(2) waitActiveTurns(t, f.o, "web", 2) frames := f.sink.ofType(EventTurnBegin) if len(frames) != 2 { t.Fatalf("turn_begin 帧数 = %d, want 2(两条路各一帧)", len(frames)) } assertTurnBeginFrame(t, "第一路", frames[0], turnBeginKindTurn, 1, 1, first.TurnID, []string{"id-1"}) assertTurnBeginFrame(t, "第二路", frames[1], turnBeginKindParallel, 2, 2, second.TurnID, []string{"id-2"}) if got := loop.TurnIDFrom(calls[1].ctx); got != frames[1].turnID { t.Fatalf("第二路交给 runner 的 turnId = %q,帧壳是 %q:两处必须同值", got, frames[1].turnID) } f.run.release(1) f.run.release(0) if !waitIdle(f.o, "web") { t.Fatal("会话没有回到空闲") } } // --------------------------------------------------------------------------- // ③ steer_ack 的帧壳 turnId // --------------------------------------------------------------------------- // TestSteerAckFrameCarriesLegTurnID 钉住③:被转向吃进的那条消息,其 // `steer_ack` **帧壳**必须带吃进它的那一路的 turnId(真并行下客户端靠它把灰态 // 配到正确那一路的 `turn.end`)。这里直接用该路挂在 ctx 上的转向源驱动边界 —— // 与 loop 在回合内边界调用的是同一个对象。 func TestSteerAckFrameCarriesLegTurnID(t *testing.T) { t.Parallel() run := newGatedLegRunner() sink := &turnSink{} o := NewOrchestrator(OrchestratorOptions{ Run: run.Run, Emit: sink.Emit, EmitTurn: sink.EmitTurn, Log: slog.New(slog.NewTextHandler(discardWriter{}, nil)), MaxPending: 5, MaxTail: 20, History: &transcriptHistory{fakeHistory: &fakeHistory{}}, MaxParallelTurns: 2, }) first := o.Submit(context.Background(), "web", []model.Message{userMsg("第一条")}, loop.OwnerEnv()) if !first.Immediate { t.Fatalf("第一条 = %+v, want Immediate", first) } calls := run.waitStarted(1) if sr := o.Submit(context.Background(), "web", []model.Message{userMsg("忙时新消息")}, loop.OwnerEnv()); !sr.Pending { t.Fatalf("忙时第二条 = %+v, want Pending", sr) } src := loop.SteerSourceFrom(calls[0].ctx) if src == nil { t.Fatal("本路的 ctx 上没有挂转向源") } steered := src.Steer(calls[0].ctx, loop.SteerRequest{SessionID: "web", Step: 2}) if len(steered) != 1 || messageText(steered[0]) != "忙时新消息" { t.Fatalf("注入 = %v, want [忙时新消息]", windowText(steered)) } acks := sink.ofType(wsSteerAck) if len(acks) != 1 { t.Fatalf("steer_ack 帧数 = %d, want 1", len(acks)) } if acks[0].turnID != first.TurnID { t.Fatalf("steer_ack 帧壳 turnId = %q, want %q(= 吃进它的那一路的 turnId)", acks[0].turnID, first.TurnID) } if acks[0].data["messageId"] != "id-2" || stateInt(acks[0], "atStep") != 2 { t.Fatalf("steer_ack 载荷 = %v, want {messageId:id-2, atStep:2}", acks[0].data) } run.release(0) if !waitIdle(o, "web") { t.Fatal("会话没有回到空闲") } } // --------------------------------------------------------------------------- // ④ interrupt{turnId} 只停目标路 // --------------------------------------------------------------------------- // TestInterruptTurnStopsOnlyTargetLeg 钉住④的编排器侧:`InterruptTurn` 只取消 // 目标那一路 —— 另一路照常跑完并落库,被取消的那一路不补说明(现状语义), // session_interrupted 如实报 cancelledTurns=1 / dropped=0,且在另一路仍在飞时 // **不报空闲**。 func TestInterruptTurnStopsOnlyTargetLeg(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.Parallel { t.Fatalf("第二路 = %+v, want Parallel", second) } f.run.waitStarted(2) waitActiveTurns(t, f.o, "web", 2) if !f.o.InterruptTurn("web", second.TurnID) { t.Fatalf("InterruptTurn(%q) = false, want true(该路确实在途)", second.TurnID) } waitActiveTurns(t, f.o, "web", 1) intr := f.sink.ofType(EventSessionInterrupt) if len(intr) != 1 { t.Fatalf("session_interrupted 帧数 = %d, want 1", len(intr)) } if got := stateInt(intr[0], "cancelledTurns"); got != 1 { t.Fatalf("cancelledTurns = %d, want 1(只停了一路)", got) } if got := stateInt(intr[0], "dropped"); got != 0 { t.Fatalf("dropped = %d, want 0(目标化打断不碰待处理队列)", got) } if got := f.sink.ofType(EventSessionIdle); len(got) != 0 { t.Fatalf("另一路仍在飞 ⇒ 不该报空闲,得到 %v", got) } // 另一路(第一路)照常跑完:它的助手回复必须落库,而被取消的第二路不补说明。 f.run.release(0) if !waitIdle(f.o, "web") { t.Fatal("另一路收尾后会话没有回到空闲") } if got := assistantContents(f.history.records()); !slices.Equal(got, []string{storedParagraph("reply-1")}) { t.Fatalf("助手回复 = %v, want [

reply-1

](另一路继续作答,被取消路不补说明)", got) } }