package gateway import ( "context" "errors" "io" "log/slog" "strings" "sync" "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/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/transcript" ) // 本文件锁住 M32 的两条面(design/05 §2/§5/§9)与它们**不互相冒充**这条红线: // // - 可观测 / 历史面:父会话多出**恰好一条** `role=system` 的注记行(不是 user、 // 不是 assistant ⇒ 不是「她说的」),并随既有的 `message_persisted` 扇出; // - 模型面:同一行经转录播种后在下一回合是一条 **system** 消息,且**绝不**进入 // 记忆抽取的输入面。 // // 每条断言都对着一个可能被写坏的实现:把角色写成 assistant(人格冒充)、把 // `turn_id` 编一个(冒充某一路回合)、把注记漏掉标记(于是它会被当成对话吞进记忆 // 库)、或者落库失败还照样广播(假的「已回填」)。 // noteStore 是回填落库面的测试替身:记录每次写入并按需注入失败。 type noteStore struct { mu sync.Mutex written []history.Message err error } func (s *noteStore) AppendFull(_ context.Context, msg history.Message) (history.Message, error) { s.mu.Lock() defer s.mu.Unlock() if s.err != nil { return history.Message{}, s.err } if msg.ID == "" { msg.ID = "msg-backfill-1" } if msg.CreatedAt == 0 { msg.CreatedAt = 1 } s.written = append(s.written, msg) return msg, nil } func (s *noteStore) rows() []history.Message { s.mu.Lock() defer s.mu.Unlock() return append([]history.Message(nil), s.written...) } // noteSink 是下行扇出的测试替身。 type noteSink struct { mu sync.Mutex sent []history.Message } func (s *noteSink) BroadcastPersisted(msg history.Message) { s.mu.Lock() defer s.mu.Unlock() s.sent = append(s.sent, msg) } func (s *noteSink) frames() []history.Message { s.mu.Lock() defer s.mu.Unlock() return append([]history.Message(nil), s.sent...) } // recordSink 收集结构化日志记录,使「失败必须可见」能被断言而不是靠人看输出。 type recordSink struct { mu sync.Mutex recs []slog.Record } func (r *recordSink) Enabled(context.Context, slog.Level) bool { return true } func (r *recordSink) Handle(_ context.Context, rec slog.Record) error { r.mu.Lock() defer r.mu.Unlock() r.recs = append(r.recs, rec.Clone()) return nil } func (r *recordSink) WithAttrs([]slog.Attr) slog.Handler { return r } func (r *recordSink) WithGroup(string) slog.Handler { return r } // has 报告是否有一条指定级别且 msg 含 want 的记录。 func (r *recordSink) has(level slog.Level, want string) bool { r.mu.Lock() defer r.mu.Unlock() for _, rec := range r.recs { if rec.Level == level && strings.Contains(rec.Message, want) { return true } } return false } // backfillFor 构建一个带真 bus(不订阅)的回填订阅者与它的两个替身。 func backfillFor(t *testing.T, log *slog.Logger) (*SubagentBackfill, *noteStore, *noteSink) { t.Helper() b := bus.New() t.Cleanup(func() { _ = b.Close() }) store, sink := ¬eStore{}, ¬eSink{} if log == nil { log = slog.New(slog.NewTextHandler(io.Discard, nil)) } bf, err := NewSubagentBackfill(SubagentBackfillDeps{Bus: b, History: store, Broadcast: sink, Log: log}) if err != nil { t.Fatalf("NewSubagentBackfill: %v", err) } return bf, store, sink } // settledResult 构造一个与 SpawnTool.await 同形同键集的已结算批次载荷。 func settledResult(session string, agents ...orchestration.SpawnAgentResult) *orchestration.SubagentResultEvent { return &orchestration.SubagentResultEvent{ SessionID: session, SpawnResult: &orchestration.SpawnResult{ Status: "done", Summary: "子代理汇总: 共 1 个, 1 成功", Agents: agents, }, } } // TestSubagentBackfillWritesSystemNoteOnly 是 M32 的第一条面:结果写成父会话的一条 // **系统注记**,绝不写成她的人格发言,也不冒充任何一路回合。 func TestSubagentBackfillWritesSystemNoteOnly(t *testing.T) { t.Parallel() bf, store, sink := backfillFor(t, nil) payload := settledResult("web", orchestration.SpawnAgentResult{ ID: "sub-a1", Goal: "调研 X", Role: "cyrene", Mode: "engineer", Status: "done", Summary: "结论:X 可行", Text: "结论:X 可行", Steps: 2, }) if err := bf.Handle(context.Background(), bus.Event{Subject: orchestration.SubagentResultSubject, Payload: payload}); err != nil { t.Fatalf("Handle: %v", err) } rows := store.rows() if len(rows) != 1 { t.Fatalf("persisted %d rows, want exactly one system note (no fabricated speech): %+v", len(rows), rows) } row := rows[0] if row.SessionID != "web" { t.Fatalf("row session = %q, want the parent session %q", row.SessionID, "web") } if row.Role != history.RoleSystem { t.Fatalf("row role = %q, want %q (the note is not the owner's words and not the persona's)", row.Role, history.RoleSystem) } // 显式锁住「不冒充」:既不是 user、也不是 assistant —— 这两条是本条红线的全部内容。 for _, forbidden := range []string{history.RoleUser, history.RoleAssistant} { if row.Role == forbidden { t.Fatalf("row role = %q: a sub-agent conclusion must never be written as %s", row.Role, forbidden) } } if !strings.HasPrefix(row.Content, subagentNoteMarker) { t.Fatalf("content = %q, want the %q marker (it is what keeps the note out of memory extraction)", row.Content, subagentNoteMarker) } for _, want := range []string{"sub-a1", "cyrene/engineer", "结论:X 可行"} { if !strings.Contains(row.Content, want) { t.Fatalf("content = %q, want it to name %q (attribution must be traceable)", row.Content, want) } } if row.TurnID != "" { t.Fatalf("turn_id = %q, want empty: a background batch may settle after its turn ended and belongs to no lane", row.TurnID) } if row.PersonaID != "" { t.Fatalf("persona_id = %q, want empty: the note's author is no persona", row.PersonaID) } frames := sink.frames() if len(frames) != 1 || frames[0].ID != row.ID { t.Fatalf("broadcast %+v, want exactly the persisted row %q", frames, row.ID) } } // TestSubagentBackfillNoteIsBoundedAndPerAgent 锁住注记的**形状**:批次逐代理一行、 // 每行点名 id,且整条有界(失控子代理不能淹掉父会话上下文)。 func TestSubagentBackfillNoteIsBoundedAndPerAgent(t *testing.T) { t.Parallel() long := strings.Repeat("长", maxSubagentNoteBodyRunes*3) payload := settledResult("web", orchestration.SpawnAgentResult{ID: "sub-a", Status: "done", Summary: "结论A"}, orchestration.SpawnAgentResult{ID: "sub-b", Status: "failed", Summary: long}, orchestration.SpawnAgentResult{ID: "sub-c", Status: "aborted"}, ) note := subagentNote(payload) for _, want := range []string{"[子代理结果] 3 个子代理批次已完成", "- sub-a 已完成:结论A", "- sub-b 失败:", "已中止", subagentNoteEmptyBody} { if !strings.Contains(note, want) { t.Fatalf("note = %q, want it to contain %q", note, want) } } if got := len([]rune(note)); got > maxSubagentNoteRunes { t.Fatalf("note is %d runes, want at most %d", got, maxSubagentNoteRunes) } // 单代理批次的正文也不能无限长。 single := subagentNote(settledResult("web", orchestration.SpawnAgentResult{ID: "sub-x", Status: "done", Text: long})) if got := len([]rune(single)); got > maxSubagentNoteRunes { t.Fatalf("single-agent note is %d runes, want at most %d", got, maxSubagentNoteRunes) } if !strings.Contains(single, "子代理 sub-x 已完成") { t.Fatalf("single-agent note = %q, want the one-line shape", single) } } // TestSubagentBackfillSkipsUnusablePayloads 锁住两种「不能写」的情形:载荷形状不对, // 或没有父会话可归属。两者都必须留一条 WARN(绝不静默丢结果)。 func TestSubagentBackfillSkipsUnusablePayloads(t *testing.T) { t.Parallel() sinkLog := &recordSink{} bf, store, sink := backfillFor(t, slog.New(sinkLog)) cases := []struct { name string subject string payload any }{ {name: "wrong payload type", subject: orchestration.SubagentResultSubject, payload: map[string]any{"sessionId": "web"}}, {name: "no spawn result", subject: orchestration.SubagentResultSubject, payload: &orchestration.SubagentResultEvent{SessionID: "web"}}, {name: "no parent session", subject: orchestration.SubagentResultSubject, payload: settledResult("", orchestration.SpawnAgentResult{ID: "sub-a", Status: "done"})}, {name: "other subject", subject: "task.done", payload: settledResult("web")}, } for _, tc := range cases { if err := bf.Handle(context.Background(), bus.Event{Subject: tc.subject, Payload: tc.payload}); err != nil { t.Fatalf("%s: Handle: %v", tc.name, err) } } if rows := store.rows(); len(rows) != 0 { t.Fatalf("persisted %+v, want no rows for unusable payloads", rows) } if frames := sink.frames(); len(frames) != 0 { t.Fatalf("broadcast %+v, want no frames for unusable payloads", frames) } if !sinkLog.has(slog.LevelWarn, "unexpected payload") { t.Fatal("a malformed payload must leave a WARN, not vanish silently") } if !sinkLog.has(slog.LevelWarn, "no parent session") { t.Fatal("a result with no parent session must leave a WARN, not vanish silently") } if !sinkLog.has(slog.LevelWarn, "carries no spawn result") { t.Fatal("a payload without a spawn result must leave a WARN (the bus swallows panics)") } } // TestSubagentBackfillStoreFailureIsNotSilent 锁住「落库失败 ⇒ 既不广播也不静默」: // 广播一条并不存在的行会让客户端以为回填成立(假成功)。 func TestSubagentBackfillStoreFailureIsNotSilent(t *testing.T) { t.Parallel() sinkLog := &recordSink{} bf, store, sink := backfillFor(t, slog.New(sinkLog)) store.err = errors.New("db down") if err := bf.Handle(context.Background(), bus.Event{ Subject: orchestration.SubagentResultSubject, Payload: settledResult("web", orchestration.SpawnAgentResult{ID: "sub-a", Status: "done", Summary: "结论"}), }); err != nil { t.Fatalf("Handle: %v", err) } if frames := sink.frames(); len(frames) != 0 { t.Fatalf("broadcast %+v after a failed write, want none (never claim a row that does not exist)", frames) } if !sinkLog.has(slog.LevelError, "persist result note") { t.Fatal("a failed write must leave an ERROR with its source, not vanish silently") } } // TestSubagentBackfillStartSubscribesBus 锁住「Start 真的挂上总线」:真 bus 上发布 // 一条结果 ⇒ 父会话多出一条注记行。少了这一步,整条回填就只是一个没人调用的方法。 func TestSubagentBackfillStartSubscribesBus(t *testing.T) { t.Parallel() b := bus.New() t.Cleanup(func() { _ = b.Close() }) store := ¬eStore{} bf, err := NewSubagentBackfill(SubagentBackfillDeps{Bus: b, History: store, Log: slog.New(slog.NewTextHandler(io.Discard, nil))}) if err != nil { t.Fatalf("NewSubagentBackfill: %v", err) } if err := bf.Start(context.Background()); err != nil { t.Fatalf("Start: %v", err) } if err := b.Publish(context.Background(), orchestration.SubagentResultSubject, settledResult("web", orchestration.SpawnAgentResult{ID: "sub-a", Status: "done", Summary: "结论"})); err != nil { t.Fatalf("publish: %v", err) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if len(store.rows()) > 0 { break } time.Sleep(5 * time.Millisecond) } rows := store.rows() if len(rows) != 1 || rows[0].Role != history.RoleSystem { t.Fatalf("rows = %+v, want one system note from the bus subscription", rows) } } // TestSubagentBackfillRequiresStoreAndBus 锁住装配期的诚实失败:没有落库面的订阅者 // 只会把结果丢掉,却让「已接线」看起来成立。 func TestSubagentBackfillRequiresStoreAndBus(t *testing.T) { t.Parallel() if _, err := NewSubagentBackfill(SubagentBackfillDeps{Bus: bus.New()}); err == nil { t.Fatal("NewSubagentBackfill without a history store must fail") } if _, err := NewSubagentBackfill(SubagentBackfillDeps{History: ¬eStore{}}); err == nil { t.Fatal("NewSubagentBackfill without a bus must fail") } } // TestBackfilledNoteNeverReframedAsAssistant 是两条面之间的**接缝**断言:落库行经 // 转录播种(下一回合真正会走的那条路)之后,是一条 `model.RoleSystem` 消息 —— 不是 // assistant,因此模型不会把它读成「她自己说过的话」。 func TestBackfilledNoteNeverReframedAsAssistant(t *testing.T) { t.Parallel() bf, store, _ := backfillFor(t, nil) if err := bf.Handle(context.Background(), bus.Event{ Subject: orchestration.SubagentResultSubject, Payload: settledResult("web", orchestration.SpawnAgentResult{ID: "sub-a", Status: "done", Summary: "结论"}), }); err != nil { t.Fatalf("Handle: %v", err) } rows := store.rows() if len(rows) != 1 { t.Fatalf("rows = %+v, want one", rows) } seeded := transcript.Message(rows[0]) if seeded.Role != model.RoleSystem { t.Fatalf("seeded role = %q, want system: the next turn must read the note as a system note", seeded.Role) } if seeded.SenderName != "" { t.Fatalf("seeded sender = %q, want empty: no persona may be credited with the sub-agent's words", seeded.SenderName) } }