Files
Cyrene_v2/cmd/cyrene/wire_subagent_backfill_test.go
AskaEth 3acc81cec8 feat(gateway): 子代理结果回填父会话(系统注记)+ 记忆抽取排除(M32)
断点(M29 勘测定论):后台批次结算后 `subagent.result` 发了、hub 也把它中继成
WS 帧,但**没有任何代码把结果回填父会话** —— `chat_messages` 无行、模型下一轮
看不到,于是「她拍了个子代理出去,但没有下文」。

回填拆成互不冒充的两条面(人格=身份红线:子代理结论绝不写成她的人格发言):
- 可观测/历史面(新文件 subagent_backfill.go):独立订阅者(不塞进 hub)订阅
  `subagent.result`,写父会话一条 `role=system` 注记(内容以 `[子代理结果]` 开头
  并点名 `sub-<id>`),随后走既有 `message_persisted` 扇出 ⇒ 刷新/重连后仍在;
- 模型面:同一行经转录播种成为下一回合的**系统注记**(`model.RoleSystem`),
  不是「她说过的话」。

落库口径:角色恒为 system(不冒充 user/assistant)、PersonaID 与 TurnID 留空
(注记不属于任何角色、也不属于任何一路回合)。

记忆抽取排除(新文件 subagent_backfill_extract.go):`WithoutSubagentNotes` 装饰
`memory.Extractor`,判据是元数据(role=system + 注记标记,与 loop 的 summaryMarker
同一手法),组合根经 `loopMemoryExtractor` 套上 ⇒ 子代理结论绝不进记忆库;LLM
提取器的异步能力原样保留,压缩摘要(同样是 system)不被误剔。

可观测补齐(M29 §7):`SpawnTool.await` 发布成功补带 source 的 INFO,`b==nil` 与
「无结果」的静默 return 改为 WARN,发布失败 WARN 带上 count/session;回填订阅者
顺手读 `bus.Dropped()`,涨了就记一条 WARN(总线丢事件不再是黑洞)。

测试(均可变异打红):父会话恰一条 system 行且零伪造助手发言;注记/摘要/批次正文
有界且逐代理点名;不可用载荷与落库失败各留 WARN 且不广播;Start 真订阅总线;
结果不进记忆抽取(同步/异步/端到端接缝);组合根真装配(真 bus 发布 → 父会话多
一行)。回归:go build ./... + go vet ./... + go test -count=1 ./... 全绿。

不做:不改 `subagent_result` 帧形状、不加进 frameReachesAll、不新开路由/帧族、
不注入人设、不改 Docs(契约归一建议报塔)。
2026-10-24 22:02:11 +08:00

83 lines
3.2 KiB
Go

package main
import (
"context"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
)
// 本文件锁住 M32 的接线本身:**组合根真的把回填订阅者挂上了那条总线**。
//
// 为什么单独测这一步:这次缺陷的形状就是「所有零件都在、没有一根线」。回填订阅者
// 写对了、总线也发得对,只要 `wireFrontend` 里少掉那一行调用,主人在界面上看到的
// 仍然是「没有下文」—— 而且没有任何测试会红。因此这里断言的是**装配后的行为**:
// 组合根接线 → 真 bus 上发布编排层载荷 → 父会话真的多出一条系统注记行。
// waitForBackfillRow 轮询历史,直到父会话出现一条消息或到期。
func waitForBackfillRow(t *testing.T, st *history.Store, sessionID string) []history.Message {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
rows, err := st.Recent(context.Background(), sessionID, 10)
if err != nil {
t.Fatalf("read recent: %v", err)
}
if len(rows) > 0 {
return rows
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("the parent session never received the backfilled note: the composition root did not wire the subscriber")
return nil
}
// TestWireSubagentBackfillPersistsThroughBus 是 ④ 的正面证据:装配 → 发布 → 落行。
func TestWireSubagentBackfillPersistsThroughBus(t *testing.T) {
b := bus.New()
t.Cleanup(func() { _ = b.Close() })
a := &app{bus: b, history: newTestHistory(t), log: testLog()}
wireSubagentBackfill(a, nil)
payload := &orchestration.SubagentResultEvent{
SessionID: history.MainSessionID,
SpawnResult: &orchestration.SpawnResult{
Status: "done",
Summary: "子代理汇总: 共 1 个, 1 成功",
Agents: []orchestration.SpawnAgentResult{{
ID: "sub-a1", Goal: "调研 X", Role: "cyrene", Status: "done", Summary: "结论:X 可行",
}},
},
}
if err := b.Publish(context.Background(), orchestration.SubagentResultSubject, payload); err != nil {
t.Fatalf("publish: %v", err)
}
rows := waitForBackfillRow(t, a.history, history.MainSessionID)
if len(rows) != 1 {
t.Fatalf("parent session has %d messages, want exactly the one system note: %+v", len(rows), rows)
}
row := rows[0]
if row.Role != history.RoleSystem {
t.Fatalf("backfilled row role = %q, want %q (a sub-agent conclusion is not her own words)", row.Role, history.RoleSystem)
}
if row.TurnID != "" || row.PersonaID != "" {
t.Fatalf("backfilled row = %+v, want no turn_id and no persona_id", row)
}
}
// TestWireSubagentBackfillWithoutHistoryWarns 锁住降级口径:没有历史存储时不装配、
// 不 panic,而是留一条可见的告警(绝不假装回填成立)。
func TestWireSubagentBackfillWithoutHistoryWarns(t *testing.T) {
a := &app{bus: bus.New(), log: testLog()}
wireSubagentBackfill(a, nil) // 没有 history:只允许告警
if err := a.bus.Publish(context.Background(), orchestration.SubagentResultSubject,
&orchestration.SubagentResultEvent{SessionID: history.MainSessionID}); err != nil {
t.Fatalf("publish on an unwired root: %v", err)
}
}