feat(orchestration,gateway): 子代理结果登 WS 帧 subagent_result(裁决 02.4.3)
- 编排层改为在 subagent.result 上发布带父会话归属的 SubagentResultEvent,后台批次结果不再消失在进程内总线 - gateway 中继:新帧常量 wsSubagentResult="subagent_result",eventBridge/bridgePatterns 登记该 topic,normalizeEvent 专用成形(帧壳 sessionId = 派生该批次的父会话) - 角色门 fail-closed:未登记进 frameReachesAll,owner/admin 可达、viewer 不可达,真 JWT 三角色 WS 断言钉住 - 前景(2s 内跑完)批次仍只回工具结果,不发此帧
This commit is contained in:
1 parent
0ce498754a
commit
8c6466cb00
4 files changed
+295
-22
No files matched your search
@@ -0,0 +1,211 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
|
||||
)
|
||||
|
||||
// 本文件锁住 02.4.3 的结果回填链路:编排层在
|
||||
// orchestration.SubagentResultSubject 上发布的后台批次结果,必须经
|
||||
// ws_bridge 的中继变成 `subagent_result` 下行帧、并**按父会话归属**
|
||||
// (帧壳 sessionId),从而出现在父会话的实时流里(design/05 §9);
|
||||
// 它是内容帧,因此只发 owner + admin(design/11 §7.1,fail-closed)。
|
||||
//
|
||||
// 断言走真路径:真 bus Publish → hub 的真订阅与中继 → 真 /ws/chat 连接
|
||||
// (真 JWT)。viewer 的负数用例(收不到)与 owner/admin 的正数用例
|
||||
// (收得到 + 载荷形状)在同一次发布上一起判定。
|
||||
|
||||
// subagentResultReadWindow 是排空一条连接时的单帧等待窗口。广播是异步的
|
||||
// (bus worker → relay → hub → 连接写协程),窗口内应收到全部相关帧;
|
||||
// 读超时即视为排空完毕。
|
||||
const subagentResultReadWindow = 1500 * time.Millisecond
|
||||
|
||||
// subagentFrame 是测试侧的线上帧视图(等价于客户端解码):帧壳 type/sessionId 直接
|
||||
// 解出,data 保留原始 JSON,使断言既能按形状解码、又能锁住键集。
|
||||
type subagentFrame struct {
|
||||
Type string `json:"type"`
|
||||
SessionID string `json:"sessionId"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// subagentFrameData 是 api-client §3.10 登记的 data 形状。
|
||||
type subagentFrameData struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary"`
|
||||
Agents []struct {
|
||||
ID string `json:"id"`
|
||||
Goal string `json:"goal"`
|
||||
Role string `json:"role"`
|
||||
Mode string `json:"mode"`
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary"`
|
||||
Text string `json:"text"`
|
||||
Steps int `json:"steps"`
|
||||
} `json:"agents"`
|
||||
}
|
||||
|
||||
// TestSubagentResultReachesOwnerAndAdminOnly 是后台结果回填的真 WS 帧断言:
|
||||
// 真 bus 上发布编排层的真实载荷类型 → owner/admin 收到 `subagent_result`
|
||||
// 帧(带父会话与逐代理结果),viewer **收不到**(内容帧默认拒)。
|
||||
func TestSubagentResultReachesOwnerAndAdminOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
// 新帧属内容帧:不在那 7 个状态机信号白名单里,必须落 fail-closed 的
|
||||
// owner+admin 档(design/11 §7.1「要放进白名单必须有明确理由并在本表登记」)。
|
||||
if frameReachesAll(wsSubagentResult) {
|
||||
t.Fatalf("%s reached every connection; a result frame carries content and must stay owner/admin only", wsSubagentResult)
|
||||
}
|
||||
|
||||
const parentSessionID = "web:subagent-dispatch"
|
||||
env := newEnv(t, nil, false)
|
||||
|
||||
conns := map[auth.Role]*websocket.Conn{}
|
||||
for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin, auth.RoleViewer} {
|
||||
conn := dialWS(t, env.ts.URL, env.token(t, role))
|
||||
defer conn.Close()
|
||||
conns[role] = conn
|
||||
}
|
||||
waitConnCount(t, env.srv, len(conns))
|
||||
|
||||
// 编排层发布的真实载荷(与 SpawnTool.await 同类型同键集)。
|
||||
payload := &orchestration.SubagentResultEvent{
|
||||
SessionID: parentSessionID,
|
||||
SpawnResult: &orchestration.SpawnResult{
|
||||
Status: "done",
|
||||
Summary: "子代理汇总: 共 1 个, 1 成功, 0 失败, 0 中止",
|
||||
Agents: []orchestration.SpawnAgentResult{{
|
||||
ID: "sub-a1", Goal: "调研 X", Role: "cyrene", Mode: "engineer",
|
||||
Status: "done", Summary: "完成 (步骤=2): 结论",
|
||||
Text: "结论", Steps: 2,
|
||||
}},
|
||||
},
|
||||
}
|
||||
if err := env.bus.Publish(context.Background(), orchestration.SubagentResultSubject, payload); err != nil {
|
||||
t.Fatalf("publish %s: %v", orchestration.SubagentResultSubject, err)
|
||||
}
|
||||
// 哨兵:一个白名单状态信号收尾。它让每条连接的「本批帧已到齐」从猜测的
|
||||
// 空闲窗口变成可判定的边界;viewer 也必定收到它,故「viewer 什么都没收到」
|
||||
// 不会与「连接根本没工作」混淆。
|
||||
if err := env.bus.Publish(context.Background(), wsPrefix+scopeSentinel, map[string]any{"sessionId": parentSessionID}); err != nil {
|
||||
t.Fatalf("publish sentinel: %v", err)
|
||||
}
|
||||
|
||||
for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin} {
|
||||
frames := drainFrames(t, conns[role])
|
||||
assertFrameCount(t, string(role), frames, wsSubagentResult, 1)
|
||||
assertFrameCount(t, string(role), frames, scopeSentinel, 1)
|
||||
if len(frames) != 2 {
|
||||
t.Fatalf("%s received %d frames, want exactly the result + the sentinel: %v", role, len(frames), frameTypesOf(frames))
|
||||
}
|
||||
assertSubagentResultFrame(t, string(role), findFrame(t, string(role), frames, wsSubagentResult), parentSessionID, payload)
|
||||
}
|
||||
|
||||
viewerFrames := drainFrames(t, conns[auth.RoleViewer])
|
||||
if len(viewerFrames) != 1 || viewerFrames[0].Type != scopeSentinel {
|
||||
t.Fatalf("viewer received %v, want only the whitelisted %q: the result frame is owner/admin only (design/11 §7.1)",
|
||||
frameTypesOf(viewerFrames), scopeSentinel)
|
||||
}
|
||||
}
|
||||
|
||||
// assertSubagentResultFrame 按 api-client §3.10 登记的帧形状逐项断言一帧
|
||||
// `subagent_result`:帧壳 sessionId 必须是被回填的父会话,data 的**键集**必须
|
||||
// 恰好是登记的那四个,其汇总与逐代理字段必须与该批次发布的载荷一致。
|
||||
func assertSubagentResultFrame(t *testing.T, role string, frame subagentFrame, parentSessionID string, payload *orchestration.SubagentResultEvent) {
|
||||
t.Helper()
|
||||
if frame.SessionID != parentSessionID {
|
||||
t.Fatalf("%s frame sessionId = %q, want the parent session %q (the result must land in that session's stream)",
|
||||
role, frame.SessionID, parentSessionID)
|
||||
}
|
||||
wantKeys := []string{"sessionId", "status", "summary", "agents"}
|
||||
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)
|
||||
}
|
||||
if len(keys) != len(wantKeys) {
|
||||
t.Fatalf("%s frame data keys = %v, want exactly %v (api-client §3.10)", role, keys, wantKeys)
|
||||
}
|
||||
for _, k := range wantKeys {
|
||||
if _, ok := keys[k]; !ok {
|
||||
t.Fatalf("%s frame data lacks key %q (api-client §3.10): %v", role, k, keys)
|
||||
}
|
||||
}
|
||||
var data subagentFrameData
|
||||
if err := json.Unmarshal(frame.Data, &data); err != nil {
|
||||
t.Fatalf("%s frame data = %s: %v", role, frame.Data, err)
|
||||
}
|
||||
if data.SessionID != parentSessionID || data.Status != "done" {
|
||||
t.Fatalf("%s frame data = %+v, want the parent session and a settled batch", role, data)
|
||||
}
|
||||
if data.Summary != payload.Summary {
|
||||
t.Fatalf("%s frame summary = %q, want %q", role, data.Summary, payload.Summary)
|
||||
}
|
||||
if len(data.Agents) != 1 {
|
||||
t.Fatalf("%s frame agents = %+v, want one entry", role, data.Agents)
|
||||
}
|
||||
if got := data.Agents[0]; got.ID != "sub-a1" || got.Goal != "调研 X" || got.Role != "cyrene" ||
|
||||
got.Mode != "engineer" || got.Status != "done" || got.Text != "结论" || got.Steps != 2 {
|
||||
t.Fatalf("%s frame agent = %+v, want the dispatched sub-agent's outcome", role, got)
|
||||
}
|
||||
}
|
||||
|
||||
// drainFrames 读取连接直到读超时,返回按到达顺序收到的帧。它只用于
|
||||
// 已发布的批次之后:第一批帧到达后剩余窗口用于确认「没有别的帧了」,
|
||||
// 这正是 viewer 负数用例所需的判定。
|
||||
func drainFrames(t *testing.T, conn *websocket.Conn) []subagentFrame {
|
||||
t.Helper()
|
||||
var out []subagentFrame
|
||||
for {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(subagentResultReadWindow))
|
||||
_, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
var frame subagentFrame
|
||||
if uerr := json.Unmarshal(msg, &frame); uerr != nil {
|
||||
t.Fatalf("decode frame %s: %v", msg, uerr)
|
||||
}
|
||||
out = append(out, frame)
|
||||
}
|
||||
}
|
||||
|
||||
// assertFrameCount 断言角色收到的某类型帧恰好 want 条。
|
||||
func assertFrameCount(t *testing.T, role string, frames []subagentFrame, typ string, want int) {
|
||||
t.Helper()
|
||||
got := 0
|
||||
for _, f := range frames {
|
||||
if f.Type == typ {
|
||||
got++
|
||||
}
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("%s received %d %q frames, want %d (all frames: %v)", role, got, typ, want, frameTypesOf(frames))
|
||||
}
|
||||
}
|
||||
|
||||
// findFrame 返回角色收到的第一条 typ 帧。
|
||||
func findFrame(t *testing.T, role string, frames []subagentFrame, typ string) subagentFrame {
|
||||
t.Helper()
|
||||
for _, f := range frames {
|
||||
if f.Type == typ {
|
||||
return f
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s received no %q frame (all frames: %v)", role, typ, frameTypesOf(frames))
|
||||
return subagentFrame{}
|
||||
}
|
||||
|
||||
// frameTypesOf 列出收到的帧类型,用于失败信息。
|
||||
func frameTypesOf(frames []subagentFrame) []string {
|
||||
out := make([]string, 0, len(frames))
|
||||
for _, f := range frames {
|
||||
out = append(out, f.Type)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user