Files
Cyrene_v2/internal/services/gateway/session_steer_test.go
T
AskaEth 3d8822c922
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(loop,gateway): 13.4.5 目标态 —— 回合内 steer(不打断、新消息进同一上下文流)+ steer_ack 帧
规格=design/15 末尾「13.4.5 目标态规格」(不变量 I1/I2/I3)。调研依据:豆包无公开技术文档(不编造);
同类成熟 Agent 产品无一做同会话并行回合,共同范式= queue + 回合内 steer(Claude Code / Cline PR #13958 /
Codex Queue·Steer / open-webui / LangGraph)。

- 注入点=**回合内边界**(上一模型步 + 它那批工具调用都已结束、下一次模型调用还没开始):
  driveTurn 每 step 组装前调用一次 SteerSource(run.go)——完成 step 只能靠工具批次推进,
  故唯一可注入处就是「工具批次结束之后」,循环里没有任何「起第二个回合」的分支(**I1 结构性成立**)
- **I2 恰好一次**:注入只进**本轮 view、绝不写回 msgs**(checkpoint 与记忆提取面保持干净);
  被注入的消息从 pending 移除,不会由自己的回合再答一次
- **I3 转向不取消工具**:注入调用点在 runToolCalls **之后**(此刻无工具在飞),循环从不因注入 cancel 任何 ctx;
  边界序列有测试钉死(恰为 [1 2 …],无批次中途值)
- 判定口径= **pending ∩ 转录**:候选是本会话 pending 条目,每个边界重读 history.Recent 验证已落库,
  按**行 id** 去重、按 pending FIFO 注入。**不用时间戳**(Windows time.Now() 步长 ~0.44ms,连发常同毫秒并列,
  任何 ms 基线都会漏注入或重复注入);跨重启不成立也不需要(在飞回合与 pending 都在内存)
- 新下行帧 `steer_ack`:`{type, sessionId, data:{messageId, atStep}}`,语义=「已被本回合吃进、尚未被正面回答」
  (驱动前端灰态);**不进 frameReachesAll** ⇒ fail-closed 只发 owner+admin(测试双钉);经 hub 广播、不经 bus
- 组合根**零改动**:per-turn 状态用 ctx 承载(loop.WithSteerSource),编排器在回合启动/续跑时挂
- 守卫:session_orchestrator.go 加完接线后 505 行 ⇒ 按职责纯搬出 session_events.go(常量取值未改、行为零变化)
- 复核:build/vet 绿 + **全树 0 FAIL** + linux/darwin 交叉编译绿;loop 6 例(含 3 子测)+ gateway 2 例 E2E;
  **4 条变异反证**(删 consumePending → 模型步数 4≠3;注入写回 msgs → 快照被污染;批次中途注入 → 边界 [1 1 2];
  把帧加进白名单 → viewer 收到)均已还原
- 未做(登记):IM 自有 Chatter 落库的消息不参与 steer(有意,避免重复作答);pending 条目无自落库 id
  ⇒ 配对用 (内容,图片) 逐行认领;同回合超 20 条用户消息时超出者留 pending 由各自回合作答(不丢)
2026-10-04 14:53:40 +08:00

467 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"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"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件锁定裁决 13.4.5 目标态的**网关侧**(design/15 §2.0/§2.1/§2.4/§5):
//
// I1 忙时新消息在**下一个回合内边界**被注入**同一回合** —— 模型在该步看到的窗口
// 里有它,且整轮只有一次助手回答(不是「注入 + 再开一回合」);
// I2 注入=已消费 —— 那条消息从 pending 移除,绝不被第二个回合再答一遍;
// §5 steer_ack 让前端把插入气泡渲染成灰态,且它只发 owner + admin(fail-closed)。
//
// 断言走**真路径**:真 ws chat 帧 → 真编排器(真 pending 队列)→ **真 loop**
// (真模型步、真工具批次、真转录重读)→ 真 SQLite history 存储 → 真 /ws/chat
// 连接(真 JWT)。注入链路的每一环都是生产实现,只有模型适配器是脚本化的。
// steerToolCallJSON 返回第 step 步请求的工具调用(每一步产生一次回合内边界:工具
// 批次结束之后才会走下一步)。**参数按步变化**是刻意的:同一个工具调用跨连续步完全
// 重复会命中循环自身的「关键工具循环安全网」而被跳过,那样就测不到两个真的工具批次。
// 两步让测试能同时验证「边界注入」与「跨多个边界不重复注入」(I2)。
func steerToolCallJSON(step int) string {
return fmt.Sprintf(`{"tool_calls":[{"name":"steer_slow","args":{"q":%d}}]}`, step)
}
// steerScriptedToolSteps 是脚本里请求工具调用的步数:第 1、2 步请求工具,第 3 步收尾。
const steerScriptedToolSteps = 2
// steerAdapter 是三步脚本的模型适配器:前两步请求工具调用,最后给出最终答复。
// 它的第一次调用被一道门闸挡住,使测试能确定地在「回合在飞」时投递第二条消息
// (否则第二条可能在第一个回合开始之前就到达,走的就不是忙碌分支了)。
type steerAdapter struct {
mu sync.Mutex
steps int
reqs []model.Request
entered chan struct{}
enterOne sync.Once
gate chan struct{}
}
func newSteerAdapter() *steerAdapter {
return &steerAdapter{entered: make(chan struct{}), gate: make(chan struct{})}
}
func (a *steerAdapter) Name() string { return "steer-script" }
func (a *steerAdapter) Chat(ctx context.Context, req model.Request, _ model.Options) (model.Response, error) {
a.mu.Lock()
a.steps++
step := a.steps
a.mu.Unlock()
if step == 1 {
a.enterOne.Do(func() { close(a.entered) })
select {
case <-a.gate:
case <-ctx.Done():
return model.Response{}, ctx.Err()
}
}
a.mu.Lock()
a.reqs = append(a.reqs, req)
a.mu.Unlock()
if step <= steerScriptedToolSteps {
return model.Response{Content: steerToolCallJSON(step)}, nil
}
return model.Response{Content: "三步收尾"}, nil
}
func (a *steerAdapter) ChatStream(ctx context.Context, req model.Request, on func(model.StreamEvent)) (model.Response, error) {
return a.Chat(ctx, req, model.Options{})
}
// steps 返回已发生的模型步数。
func (a *steerAdapter) stepCount() int {
a.mu.Lock()
defer a.mu.Unlock()
return a.steps
}
// request 返回第 n 次(0 起)模型调用所见到的窗口。
func (a *steerAdapter) request(n int) model.Request {
a.mu.Lock()
defer a.mu.Unlock()
return a.reqs[n]
}
// enteredCh 暴露门闸前的「已进入第一次模型调用」信号。
func (a *steerAdapter) enteredCh() <-chan struct{} { return a.entered }
// release 放行第一次模型调用,使回合继续走到工具批次与第 2 步。
func (a *steerAdapter) release() { close(a.gate) }
// steerRecordingTool 记录自己被完整执行了多少次(I2 的旁证:一次回合里那些工具
// 只该跑一次)。
type steerRecordingTool struct {
mu sync.Mutex
calls int
}
func (t *steerRecordingTool) Name() string { return "steer_slow" }
func (t *steerRecordingTool) Description() string { return "steering test tool" }
func (t *steerRecordingTool) Parameters() (map[string]any, error) {
return nil, nil
}
func (t *steerRecordingTool) Execute(context.Context, any) (any, error) {
t.mu.Lock()
defer t.mu.Unlock()
t.calls++
return "steer-tool-done", nil
}
func (t *steerRecordingTool) count() int {
t.mu.Lock()
defer t.mu.Unlock()
return t.calls
}
// newSteerDispatcher 装配一个注册了给定工具的 dispatcher。
func newSteerDispatcher(t *testing.T, tools ...sdk.Tool) *tool.Dispatcher {
t.Helper()
d, err := tool.New(capreg.New())
if err != nil {
t.Fatalf("tool.New: %v", err)
}
for _, tk := range tools {
if err := d.Register(tk); err != nil {
t.Fatalf("register %s: %v", tk.Name(), err)
}
}
return d
}
// countHistoryRows 按角色统计该会话在真存储里的消息条数。
func countHistoryRows(t *testing.T, store *history.Store, sessionID, role string) []history.Message {
t.Helper()
recent, err := store.Recent(context.Background(), sessionID, 50)
if err != nil {
t.Fatalf("history.Recent: %v", err)
}
out := make([]history.Message, 0, len(recent))
for _, m := range recent {
if m.Role == role {
out = append(out, m)
}
}
return out
}
// TestSteer_BusyMessageJoinsSameTurnAndIsNotAnsweredTwice 是本设计的核心端到端断言:
// 一个真 loop 跑着两步回合时又到一条用户消息 —— 它在**下一个回合内边界**被吃进同一
// 回合(模型第 2 步的窗口里有它),整轮**只有一次助手回答**,pending 被清空,且
// steer_ack 只到达 owner + admin。
func TestSteer_BusyMessageJoinsSameTurnAndIsNotAnsweredTwice(t *testing.T) {
t.Parallel()
// steer_ack 携带具体消息身份:它绝不属于「状态机信号」白名单(fail-closed)。
if frameReachesAll(wsSteerAck) {
t.Fatalf("%s reached every connection; steer_ack carries a message identity and must stay owner/admin only", wsSteerAck)
}
f := newSteerFixture(t)
orch := f.srv.hub.orchestrator
// 第一条消息:正常起一个回合,它停在第一次模型调用上。
writeChat(t, f.conns[auth.RoleOwner], `{"sessionId":"web","text":"第一条","deviceId":"web:test:steer"}`)
select {
case <-f.adapter.enteredCh():
case <-time.After(3 * time.Second):
t.Fatal("第一个回合没有进入模型调用")
}
// 忙时第二条:不阻塞、即刻落库、入 pending(这一句正是要在**本回合内**被吃进的)。
writeChat(t, f.conns[auth.RoleOwner], `{"sessionId":"web","text":"忙时新消息","deviceId":"web:test:steer"}`)
waitUntil(t, "第二条进入 pending", func() bool { return orch.QueueDepth("web") == 1 })
waitUntil(t, "两条用户消息都落库", func() bool {
return len(countHistoryRows(t, f.store, "web", history.RoleUser)) == 2
})
// 放行:第 1 步给出工具调用 → 工具批次结束 → 第 2 步前注入第二条消息 → 收尾。
f.adapter.release()
waitUntil(t, "会话排空(pending 已被吃进,不再续跑第二个回合)", func() bool {
return !orch.Busy("web") && orch.QueueDepth("web") == 0
})
assertSteerSameTurnWindow(t, f)
assertSteerExactlyOnce(t, f.store)
assertSteerAckFrames(t, f)
}
// steerFixture 是一次端到端转向场景的全部生产依赖(真 loop、真存储、真 WS 连接)。
type steerFixture struct {
store *history.Store
adapter *steerAdapter
tool *steerRecordingTool
srv *Server
bus bus.Bus
conns map[auth.Role]*websocket.Conn
}
// newSteerFixture 装配真 loop(只有模型适配器是脚本化的)+ 真 SQLite history +
// 真 /ws/chat 连接的 owner/admin/viewer 三端。
func newSteerFixture(t *testing.T) *steerFixture {
t.Helper()
store := newHistoryStoreForGateway(t)
ad := newSteerAdapter()
steerTool := &steerRecordingTool{}
dispatcher := newSteerDispatcher(t, steerTool)
ag, err := agent.New("你是测试助手。", dispatcher, nil, agent.WithModel(ad, nil))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
realLoop := loop.New(loop.Options{Agent: ag, Tools: dispatcher, MaxRounds: 8, Log: gwTestLogger()})
b := bus.New()
srv, ts, jwt := buildEnv(t, nil, Deps{Loop: realLoop, History: store, Bus: b})
conns := map[auth.Role]*websocket.Conn{}
for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin, auth.RoleViewer} {
conn := dialWS(t, ts.URL, signAny(t, jwt, "acct", role))
t.Cleanup(func() { _ = conn.Close() })
conns[role] = conn
}
waitConnCount(t, srv, len(conns))
return &steerFixture{store: store, adapter: ad, tool: steerTool, srv: srv, bus: b, conns: conns}
}
// assertSteerSameTurnWindow 断言 I1 与 §2.4 的最新性/完整性:整轮只有脚本的 3 步
// (同一回合,而不是「注入 + 再开一回合」),每一步窗口末条都是被注入的忙碌消息,
// 且它在后续窗口里**只出现一次**(跨边界不重复注入,I2),旧窗口内容(工具结果、
// 第一条消息)都还在。
func assertSteerSameTurnWindow(t *testing.T, f *steerFixture) {
t.Helper()
wantSteps := steerScriptedToolSteps + 1
if got := f.adapter.stepCount(); got != wantSteps {
t.Fatalf("模型步数 = %d, want %d(注入必须留在同一回合内,而不是再开一回合)", got, wantSteps)
}
for i := 1; i < wantSteps; i++ {
view := f.adapter.request(i).Messages
last := view[len(view)-1]
if last.Role != model.RoleUser || messageText(last) != "忙时新消息" {
t.Fatalf("第 %d 步窗口末条 = %s:%q, want 忙时新消息(§2.4 最新性 + 一致性:同一窗口)",
i+1, last.Role, messageText(last))
}
if n := occurrences(view, "忙时新消息"); n != 1 {
t.Fatalf("第 %d 步窗口里忙碌消息出现 %d 次, want 1(跨边界绝不重复注入)", i+1, n)
}
if !windowHasText(view, "steer-tool-done") || !windowHasText(view, "第一条") {
t.Fatalf("第 %d 步窗口丢了旧内容(注入只能追加,不得顶掉旧窗口):%v", i+1, windowTextList(view))
}
}
if got := f.tool.count(); got != steerScriptedToolSteps {
t.Fatalf("工具执行 %d 次, want %d(注入不取消已发起的工具,也不重跑它们)", got, steerScriptedToolSteps)
}
}
// assertSteerExactlyOnce 断言 I2:恰好被处理一次 —— 一条助手回答、两条用户消息,
// pending 没有把被吃进的那条当第二个回合再答一遍。
func assertSteerExactlyOnce(t *testing.T, store *history.Store) {
t.Helper()
assistants := countHistoryRows(t, store, "web", history.RoleAssistant)
if len(assistants) != 1 {
t.Fatalf("助手回答 = %d 条, want 1(注入的消息不得被二次作答)", len(assistants))
}
users := countHistoryRows(t, store, "web", history.RoleUser)
if len(users) != 2 {
t.Fatalf("用户消息 = %d 条, want 2(不得丢,也不得凭空多出)", len(users))
}
}
// assertSteerAckFrames 断言 design/15 §5 的帧壳/载荷键集/角色门:owner 与 admin 各
// 收到恰好一帧 steer_ack(messageId = 被吃进那条的权威 id、atStep = 2),viewer 收不到
// (负例以白名单哨兵确实到达它为前提,否则「什么都没收到」不可判定)。
func assertSteerAckFrames(t *testing.T, f *steerFixture) {
t.Helper()
if err := f.bus.Publish(context.Background(), wsPrefix+scopeSentinel, map[string]any{"sessionId": "web"}); err != nil {
t.Fatalf("publish sentinel: %v", err)
}
users := countHistoryRows(t, f.store, "web", history.RoleUser)
if len(users) != 2 {
t.Fatalf("用户消息 = %d 条, want 2", len(users))
}
for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin} {
frames := drainFrames(t, f.conns[role])
assertFrameCount(t, string(role), frames, wsSteerAck, 1)
assertSteerAckFrame(t, string(role), findFrame(t, string(role), frames, wsSteerAck), users[1])
}
viewerFrames := drainFrames(t, f.conns[auth.RoleViewer])
assertFrameCount(t, string(auth.RoleViewer), viewerFrames, wsSteerAck, 0)
if frameCountOf(viewerFrames, scopeSentinel) == 0 {
t.Fatalf("viewer 未收到白名单哨兵 %q,负例不可判定(收到的帧:%v)", scopeSentinel, frameTypesOf(viewerFrames))
}
}
// frameCountOf 统计角色收到的某类型帧条数。
func frameCountOf(frames []subagentFrame, typ string) int {
n := 0
for _, f := range frames {
if f.Type == typ {
n++
}
}
return n
}
// assertSteerAckFrame 按 design/15 §5 逐项断言一帧 steer_ack:帧壳 sessionId 是该
// 会话,载荷**键集**恰好是 {messageId, atStep},且 messageId 是那条被吃进的消息的
// 权威 id、atStep 是吃进它的模型步序号(第 2 步)。
func assertSteerAckFrame(t *testing.T, role string, frame subagentFrame, userRow history.Message) {
t.Helper()
if frame.SessionID != userRow.SessionID {
t.Fatalf("%s frame sessionId = %q, want %q", role, frame.SessionID, userRow.SessionID)
}
var keys map[string]json.RawMessage
if err := json.Unmarshal(frame.Data, &keys); err != nil {
t.Fatalf("%s frame data is not a JSON object: %v (%s)", role, err, frame.Data)
}
wantKeys := []string{"messageId", "atStep"}
if len(keys) != len(wantKeys) {
t.Fatalf("%s frame data keys = %v, want exactly %v (design/15 §5)", role, keys, wantKeys)
}
for _, k := range wantKeys {
if _, ok := keys[k]; !ok {
t.Fatalf("%s frame data lacks key %q (design/15 §5): %v", role, k, keys)
}
}
var data struct {
MessageID string `json:"messageId"`
AtStep int `json:"atStep"`
}
if err := json.Unmarshal(frame.Data, &data); err != nil {
t.Fatalf("%s frame data = %s: %v", role, frame.Data, err)
}
if data.MessageID != userRow.ID {
t.Fatalf("%s steer_ack messageId = %q, want the persisted id %q(前端据此把乐观气泡对齐灰态)",
role, data.MessageID, userRow.ID)
}
if data.AtStep != 2 {
t.Fatalf("%s steer_ack atStep = %d, want 2(被第 2 个模型步吃进)", role, data.AtStep)
}
}
// TestSteer_UnpersistedPendingStaysPending 是失败路径的反向断言:转录里看不到那条
// 消息时(这里用不返回任何行的历史替身模拟「尚未落库 / 读转录失败」),注入让路 ——
// 消息留在 pending,照旧作为自己的回合同答,**绝不静默消失**。
func TestSteer_UnpersistedPendingStaysPending(t *testing.T) {
t.Parallel()
run := newGatedRunner(2)
hist := &fakeHistory{}
o := NewOrchestrator(OrchestratorOptions{Run: run.Run, Log: gwTestLogger(), History: hist, MaxPending: 5})
if sr := o.Submit(context.Background(), "web", []model.Message{userMsg("第一条")}, loop.OwnerEnv()); !sr.Immediate {
t.Fatalf("submit = %+v, want Immediate", sr)
}
// 票还没放:回合停在 runner 里,会话稳定处于 busy(这正是「忙时」)。
if !waitQueueDepth(o, "web", 0) {
t.Fatal("前提不成立:队列一开始必须是空的")
}
if !waitBusy(o, "web") {
t.Fatal("第一个回合没有进入在飞状态")
}
if sr := o.Submit(context.Background(), "web", []model.Message{userMsg("忙时新消息")}, loop.OwnerEnv()); !sr.Pending {
t.Fatalf("busy submit = %+v, want Pending", sr)
}
if !waitQueueDepth(o, "web", 1) {
t.Fatal("第二条没有进入 pending")
}
// 真 loop 会在边界上调用转向源;这里也一样(替身给不出转录行 ⇒ 不注入),
// 因此第二条必须照旧由**自己的回合**作答 —— 注入让路不等于消息被丢。
run.tickets <- struct{}{}
run.tickets <- struct{}{}
if !waitIdle(o, "web") {
t.Fatal("会话没有排空")
}
got := run.waitCalls(2)
if closingText(got[1].messages) != "忙时新消息" {
t.Fatalf("第二个回合 = %v, want 忙时新消息(注入让路时消息必须被正常作答,不得丢)",
closingText(got[1].messages))
}
}
// waitBusy 轮询直到该会话报告在飞。
func waitBusy(o *Orchestrator, sessionID string) bool {
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if o.Busy(sessionID) {
return true
}
time.Sleep(time.Millisecond)
}
return false
}
// waitQueueDepth 轮询直到该会话的 pending 深度达到 want。
func waitQueueDepth(o *Orchestrator, sessionID string, want int) bool {
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if o.QueueDepth(sessionID) == want {
return true
}
time.Sleep(time.Millisecond)
}
return false
}
// windowHasText 报告窗口里是否有任一消息的文本包含 want。
func windowHasText(msgs []model.Message, want string) bool {
for _, m := range msgs {
if strings.Contains(messageText(m), want) {
return true
}
}
return false
}
// occurrences 统计窗口里含 want 的消息条数(用于「跨边界只注入一次」的断言)。
func occurrences(msgs []model.Message, want string) int {
n := 0
for _, m := range msgs {
if strings.Contains(messageText(m), want) {
n++
}
}
return n
}
// windowTextList 把窗口渲染为 role:文本,供失败信息定位。
func windowTextList(msgs []model.Message) []string {
out := make([]string, 0, len(msgs))
for _, m := range msgs {
out = append(out, string(m.Role)+":"+messageText(m))
}
return out
}
// closingText 返回窗口最后一条消息的文本。
func closingText(msgs []model.Message) string {
if len(msgs) == 0 {
return ""
}
return messageText(msgs[len(msgs)-1])
}
// writeChat 在给定连接上发一条 chat 上行帧(与前端同一条上行契约)。
func writeChat(t *testing.T, c *websocket.Conn, data string) {
t.Helper()
if err := c.WriteJSON(wsInbound{Type: "chat", Data: json.RawMessage(data)}); err != nil {
t.Fatalf("write chat: %v", err)
}
}