新下行帧 turn_begin:编排器每启动一路恰好一帧(单路 kind:"turn" / 并行路 "parallel"),
data{kind, messageIds, leg, activeTurns};角色门 fail-closed 仅 owner+admin,未进 frameReachesAll。
steer_ack 增帧壳 turnId(与该路 turn_begin 同源)—— 并行下没有它配不上正确的 turn.end。
上行 interrupt.turnId(仅 scope:"current" 生效):精确停某一路;目标不存在 ⇒ 定向
{type:"error",data:{code:"bad_request"}},不广播、不动任何会话;省略 turnId = v0.65 原口径(全部在途路)。
帧序按实现:session_busy / pending_drain → message_persisted(user) → turn_begin
(规范草案 §2.9 的时序图需按实现更正:messageIds 必须是已落库的权威 id,故只能在落库之后)。
测试:编排器侧 + 真 WS/JWT 端到端;变异 5 条(每路一帧 / 帧壳 turnId / 目标化打断 / 取消筛选)。
Docs 待升 v0.66(由监工登记)。
293 lines
12 KiB
Go
293 lines
12 KiB
Go
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{"reply-1"}) {
|
||
t.Fatalf("助手回复 = %v, want [reply-1](另一路继续作答,被取消路不补说明)", got)
|
||
}
|
||
}
|