feat(loop,gateway): 13.4.5 目标态 —— 回合内 steer(不打断、新消息进同一上下文流)+ steer_ack 帧
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

规格=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 由各自回合作答(不丢)
This commit is contained in:
AskaEth committed 2026-10-04 14:53:40 +08:00
1 parent 8c6c796a29
commit 3d8822c922
9 files changed
+1306 -22

No files matched your search

@@ -0,0 +1,466 @@
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)
}
}