- turnId 由编排器生成、loop Run 复用(硬前提:否则 turn_begin 与流帧各铸一个,客户端两级分桶立即失配) - StateSink 增带 turnId 的变体(未接线时退化为现签名,不丢状态帧) - 上行 chat.parallel(WS chat 帧 + REST 三入口同形;省略 = 逐字不变) - 单会话额度默认 2 / 硬上限 3 / <2 关闭;额度满或未声明 ⇒ 落回既有排队语义 - I1′:checkpoint 归属、历史配对、忙态计数三处从会话级单例升为路级 - session_idle 收紧为「所有在途路收尾且 pending 空」(仅并行时生效;单路逐字不变) - 预留接口(形状定死、无实现无接线):tool/legfile.go 的 FileClaimer / ErrFileBusy / WithLegID - 顺手修一个真 bug:并行路 ctx 派生自提交请求作用域,REST 同步入口一写响应就把它连坐杀死(改 context.WithoutCancel 派生) - 23 个新用例(含 -race -count=3);变异 6 条(turnId 复用 / idle 时点 / 并行升级 / checkpoint 键 / 编排器交 id / pending_ack reason) - 契约变更点 10 条待监工升 v0.65(本人未改 Docs/**)
234 lines
9.5 KiB
Go
234 lines
9.5 KiB
Go
package gateway
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
)
|
||
|
||
// 本文件是 design/15 §2.5 基础管线的**协议面**回归:turnId 一致性
|
||
// (编排器生成 → loop 复用 → 流帧帧壳同值)、带 turnId 的状态通道变体、
|
||
// 上行 `parallel` 的解码,以及 REST 三条入口的应答形状。夹具留在
|
||
// session_parallel_test.go(同包),两半共用。
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ⑥ turnId 一致性(编排器生成 → loop 复用 → 流帧帧壳同值)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// TestRunTurnReusesOrchestratorTurnID 钉住①的硬前提:hub 的 runTurn 必须**复用**
|
||
// 编排器已经生成并交给 Run 的那个 turnId(否则 `turn_begin` 与流帧各生成一个,
|
||
// 客户端两级分桶立即失配);未挂载时才新铸一个,因此单路路径无可见差异。
|
||
func TestRunTurnReusesOrchestratorTurnID(t *testing.T) {
|
||
t.Parallel()
|
||
h := newTestHub(&stubRunner{res: &loop.Result{Text: "ok", StopReason: "stop"}}, nil)
|
||
conn := newOwnerConn(h, 16)
|
||
h.register(conn)
|
||
|
||
const want = "turn-orchestrator-made"
|
||
ctx := loop.WithTurnID(context.Background(), want)
|
||
if _, err := h.runTurn(ctx, "web", []model.Message{userMsg("你好")}, loop.OwnerEnv()); err != nil {
|
||
t.Fatalf("runTurn: %v", err)
|
||
}
|
||
// 一回合发两帧(stream_chunk + stream_end),两帧的帧壳都必须带上同一个 id。
|
||
for _, typ := range []string{"stream_chunk", "stream_end"} {
|
||
if ev := waitFrame(t, conn, typ); ev.TurnID != want {
|
||
t.Fatalf("%s 帧壳 turnId = %q, want %q(编排器生成的那个)", typ, ev.TurnID, want)
|
||
}
|
||
}
|
||
|
||
// 未挂载 ⇒ 每回合一个全新的随机 id(老契约不变)。
|
||
if _, err := h.runTurn(context.Background(), "web", []model.Message{userMsg("再来")}, loop.OwnerEnv()); err != nil {
|
||
t.Fatalf("runTurn: %v", err)
|
||
}
|
||
waitFrame(t, conn, "stream_chunk")
|
||
ev := waitFrame(t, conn, "stream_end")
|
||
if ev.TurnID == "" || ev.TurnID == want {
|
||
t.Fatalf("未挂载时应新铸一个 turnId,得到 %q", ev.TurnID)
|
||
}
|
||
}
|
||
|
||
// TestEmitTurnVariantCarriesTurnID 钉住②:带 turnId 的状态通道变体把 turnId 盖到
|
||
// 帧壳上;未接线该变体时退化为普通状态帧(绝不因此丢掉一条状态迁移)。
|
||
func TestEmitTurnVariantCarriesTurnID(t *testing.T) {
|
||
t.Parallel()
|
||
sink := &turnSink{}
|
||
o := NewOrchestrator(OrchestratorOptions{Run: fixedRunner(nil), Emit: sink.Emit, EmitTurn: sink.EmitTurn})
|
||
o.emitTurnState("web", "turn-1", EventSessionBusy, busyPayload("web", ModeThinking, 2))
|
||
got := sink.snapshot()
|
||
if len(got) != 1 || got[0].turnID != "turn-1" || got[0].typ != EventSessionBusy {
|
||
t.Fatalf("带 turnId 的变体 = %+v, want turnId=turn-1 的 session_busy", got)
|
||
}
|
||
if stateInt(got[0], "activeTurns") != 2 {
|
||
t.Fatalf("activeTurns = %v, want 2", got[0].data["activeTurns"])
|
||
}
|
||
|
||
plain := &turnSink{}
|
||
o2 := NewOrchestrator(OrchestratorOptions{Run: fixedRunner(nil), Emit: plain.Emit})
|
||
o2.emitTurnState("web", "turn-1", EventSessionBusy, busyPayload("web", ModeThinking, 1))
|
||
got = plain.snapshot()
|
||
if len(got) != 1 || got[0].turnID != "" {
|
||
t.Fatalf("未接线变体时应退化为普通帧(无 turnId),得到 %+v", got)
|
||
}
|
||
}
|
||
|
||
// TestChatStreamRequestDecodesParallel 钉住 WS 上行帧的解码:`parallel:true` 被
|
||
// 读懂,省略时为零值(因此省略该字段的老客户端行为逐字不变)。
|
||
func TestChatStreamRequestDecodesParallel(t *testing.T) {
|
||
t.Parallel()
|
||
var on chatStreamRequest
|
||
if err := json.Unmarshal([]byte(`{"sessionId":"web","text":"hi","parallel":true}`), &on); err != nil {
|
||
t.Fatalf("解码: %v", err)
|
||
}
|
||
if !on.Parallel {
|
||
t.Fatal("parallel:true 没有被解码到")
|
||
}
|
||
var off chatStreamRequest
|
||
if err := json.Unmarshal([]byte(`{"sessionId":"web","text":"hi"}`), &off); err != nil {
|
||
t.Fatalf("解码: %v", err)
|
||
}
|
||
if off.Parallel {
|
||
t.Fatal("省略 parallel 时必须为零值")
|
||
}
|
||
}
|
||
|
||
// TestRESTParallelAckShapes 钉住 REST 两条入口的应答形状:stream 路径在照准并行时
|
||
// 明确回报 parallel+turnId(而不是一个什么都没说的 200),同步路径回 202(第二路在
|
||
// 后台跑,拿不到同步回复)。
|
||
func TestRESTParallelAckShapes(t *testing.T) {
|
||
t.Parallel()
|
||
s := &Server{}
|
||
rec := httptest.NewRecorder()
|
||
s.writeChatStreamAck(rec, SubmitResult{Accepted: true, Immediate: true, Parallel: true, TurnID: "turn-9"}, "web")
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("stream 并行 ack 状态码 = %d, want 200", rec.Code)
|
||
}
|
||
var body struct {
|
||
Data map[string]any `json:"data"`
|
||
}
|
||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||
t.Fatalf("解码 ack: %v", err)
|
||
}
|
||
if body.Data["stream"] != true || body.Data["parallel"] != true || body.Data["turnId"] != "turn-9" {
|
||
t.Fatalf("stream 并行 ack = %v, want {stream:true, parallel:true, turnId:turn-9}", body.Data)
|
||
}
|
||
|
||
// 现状不动:排队仍是 202 + queued。
|
||
rec = httptest.NewRecorder()
|
||
s.writeChatStreamAck(rec, SubmitResult{Accepted: true, Pending: true, QueueDepth: 3}, "web")
|
||
if rec.Code != http.StatusAccepted {
|
||
t.Fatalf("排队 ack 状态码 = %d, want 202", rec.Code)
|
||
}
|
||
}
|
||
|
||
// blockingChatter 是**永不返回**的旧式 Chatter(`Deps.Loop`):它把会话钉在
|
||
// 「忙」上,使黑盒用例能在真实 HTTP 层断言并行分支的应答形状 —— 而不必让第一条
|
||
// 请求同步跑完。它尊重 ctx:网关关闭 / 中断会让它立刻返回,不留泄漏的 goroutine。
|
||
type blockingChatter struct {
|
||
started chan struct{}
|
||
release chan struct{}
|
||
stopped sync.Once
|
||
}
|
||
|
||
func newBlockingChatter() *blockingChatter {
|
||
return &blockingChatter{started: make(chan struct{}, 8), release: make(chan struct{})}
|
||
}
|
||
|
||
func (b *blockingChatter) Run(ctx context.Context, _ string, _ []model.Message) (*loop.Result, error) {
|
||
b.started <- struct{}{}
|
||
select {
|
||
case <-b.release:
|
||
return &loop.Result{Text: "ok", StopReason: "stop"}, nil
|
||
case <-ctx.Done():
|
||
return nil, ctx.Err()
|
||
}
|
||
}
|
||
|
||
func (b *blockingChatter) stop() { b.stopped.Do(func() { close(b.release) }) }
|
||
|
||
// TestRESTParallelEntriesShareOneShape 是 design/15 §2.5.2 的黑盒回归:REST 的
|
||
// 三条入口**同形** ——
|
||
// - 第一条经 `stream:true` 进入(提交即返回,回合在后台被钉住,会话因此忙碌);
|
||
// - 第二条走默认(同步)入口 + `parallel:true` ⇒ 202 + `parallel:true` + turnId
|
||
// (第二路在后台跑,同步入口拿不到回复,绝不回一个 res 为 nil 的 200);
|
||
// - 第三条省掉 `parallel` ⇒ 仍是既有的 202 `{queued:true, queueDepth}`。
|
||
//
|
||
// 它同时覆盖 Deps.MaxParallelTurns → 编排器的接线(第二路真的起来了,用 turnId
|
||
// 佐证:只有真的开了一路才会有它)。
|
||
func TestRESTParallelEntriesShareOneShape(t *testing.T) {
|
||
t.Parallel()
|
||
ch := newBlockingChatter()
|
||
srv, ts, jwt := buildEnv(t, nil, Deps{Loop: ch, MaxParallelTurns: 2})
|
||
t.Cleanup(func() { ch.stop(); srv.Close() })
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
|
||
resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/chat", map[string]any{
|
||
"sessionId": "web", "text": "第一问", "stream": true,
|
||
}, tok)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("stream 入口状态码 = %d, want 200", resp.StatusCode)
|
||
}
|
||
select {
|
||
case <-ch.started:
|
||
case <-time.After(3 * time.Second):
|
||
t.Fatal("后台回合没有开跑(会话应当是忙碌的)")
|
||
}
|
||
if !srv.hub.orchestrator.Busy("web") {
|
||
t.Fatal("会话没有进入忙碌态,后面的并行断言不成立")
|
||
}
|
||
|
||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/chat", map[string]any{
|
||
"sessionId": "web", "text": "第二问", "parallel": true,
|
||
}, tok)
|
||
if resp.StatusCode != http.StatusAccepted {
|
||
t.Fatalf("同步入口 + parallel 状态码 = %d, want 202(第二路在后台跑)", resp.StatusCode)
|
||
}
|
||
var accepted map[string]any
|
||
if err := json.Unmarshal(decodeBody(t, body)["data"], &accepted); err != nil {
|
||
t.Fatalf("解码并行 ack: %v (body=%s)", err, body)
|
||
}
|
||
if accepted["parallel"] != true {
|
||
t.Fatalf("同步入口并行 ack = %v, want parallel:true", accepted)
|
||
}
|
||
if turnID, _ := accepted["turnId"].(string); turnID == "" {
|
||
t.Fatalf("并行 ack 必须给出该路的 turnId,得到 %v", accepted)
|
||
}
|
||
waitActiveTurns(t, srv.hub.orchestrator, "web", 2)
|
||
|
||
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/chat", map[string]any{
|
||
"sessionId": "web", "text": "第三问",
|
||
}, tok)
|
||
if resp.StatusCode != http.StatusAccepted {
|
||
t.Fatalf("未声明并行的忙时提交状态码 = %d, want 202", resp.StatusCode)
|
||
}
|
||
var queued map[string]any
|
||
if err := json.Unmarshal(decodeBody(t, body)["data"], &queued); err != nil {
|
||
t.Fatalf("解码排队 ack: %v (body=%s)", err, body)
|
||
}
|
||
if queued["queued"] != true || queued["parallel"] != nil {
|
||
t.Fatalf("未声明并行的忙时 ack = %v, want {queued:true}(形状逐字不变)", queued)
|
||
}
|
||
}
|
||
|
||
// TestParallelTurnContextIsAdditive 钉住并行声明的 ctx 通道本身:false 是空操作
|
||
// (ctx 上不留痕迹),nil ctx 不 panic。
|
||
func TestParallelTurnContextIsAdditive(t *testing.T) {
|
||
t.Parallel()
|
||
if parallelRequested(withParallelTurn(context.Background(), false)) {
|
||
t.Fatal("false 必须是空操作:老客户端的 ctx 上不该留任何痕迹")
|
||
}
|
||
if !parallelRequested(withParallelTurn(context.Background(), true)) {
|
||
t.Fatal("true 必须可被读取侧看到")
|
||
}
|
||
if parallelRequested(nil) {
|
||
t.Fatal("nil ctx 不该被判为声明了并行")
|
||
}
|
||
}
|