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:
AskaEth committed 2026-10-04 12:07:46 +08:00
1 parent 0ce498754a
commit 8c6466cb00
4 files changed
+295 -22

No files matched your search

@@ -176,10 +176,10 @@ func TestSpawnToolMovesSlowBatchToBackground(t *testing.T) {
s.runner = &mockRunner{gate: gate, result: &loop.Result{Text: "late", Steps: 2}}
sp := NewSpawnTool(s, WithSpawnWait(40*time.Millisecond))
results := make(chan *MergedResult, 1)
results := make(chan *SubagentResultEvent, 1)
unsub, err := s.Bus().Subscribe(context.Background(), SubagentResultSubject,
func(_ context.Context, e bus.Event) error {
if res, ok := e.Payload.(*MergedResult); ok {
if res, ok := e.Payload.(*SubagentResultEvent); ok {
select {
case results <- res:
default:
@@ -192,8 +192,11 @@ func TestSpawnToolMovesSlowBatchToBackground(t *testing.T) {
}
defer unsub()
// 派发回合的会话(loop 在 Run 顶部把它播进 ctx):回填载荷必须带上它,
// gateway 的中继才能把结果归属到父会话的实时流(design/05 §9)。
ctx := loop.WithFlowBranch(context.Background(), parentSession)
start := time.Now()
raw, err := sp.Execute(context.Background(), map[string]any{"goal": "slow"})
raw, err := sp.Execute(ctx, map[string]any{"goal": "slow"})
elapsed := time.Since(start)
if err != nil {
t.Fatalf("Execute: %v", err)
@@ -212,14 +215,32 @@ func TestSpawnToolMovesSlowBatchToBackground(t *testing.T) {
close(gate)
select {
case res := <-results:
if res.Total != 1 || res.Succeeded != 1 {
t.Fatalf("background merge = %d total / %d ok, want 1/1", res.Total, res.Succeeded)
if res.SessionID != parentSession {
t.Fatalf("result sessionId = %q, want the dispatching session %q", res.SessionID, parentSession)
}
if res.Agents[0].Text != "late" {
t.Fatalf("background result text = %q, want late", res.Agents[0].Text)
if res.Status != "done" || len(res.Agents) != 1 {
t.Fatalf("result = %+v, want one settled agent", res)
}
if a := res.Agents[0]; a.Text != "late" || a.Steps != 2 || a.Goal != "slow" || a.Status != string(StatusDone) {
t.Fatalf("result agent = %+v, want the late/2-steps/done projection", a)
}
// 载荷形状是 api-client §3.10 登记的 WS 帧 data:键集锁定,避免
// 生产者悄悄改键而文档与实现分叉。
wire, merr := json.Marshal(res)
if merr != nil {
t.Fatalf("marshal result payload: %v", merr)
}
var keys map[string]json.RawMessage
if uerr := json.Unmarshal(wire, &keys); uerr != nil {
t.Fatalf("unmarshal result payload: %v", uerr)
}
for _, want := range []string{"sessionId", "status", "summary", "agents"} {
if _, ok := keys[want]; !ok {
t.Fatalf("result payload %s lacks key %q", wire, want)
}
}
case <-time.After(waitTimeout):
t.Fatal("a background batch never published its merged result")
t.Fatal("a background batch never published its result payload")
}
}