package main import ( "context" "strings" "sync" "testing" "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/memory" "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" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway" ) // 本文件锁住组合根那一条红线:循环用的提取器**必须**套上「剔除子代理注记」这一层 // (loopMemoryExtractor)。删掉那层装饰,子代理的结论就会被当成对话写进记忆库 —— // 那条污染线刚查过(84% 记忆来自内部会话),不能再添一条。 // // 断言的是**组合根这个入口**的语义,因此即使 gateway 那边的装饰器自己写对,把这里 // 换成 `base` 直通也会红。 // noteCaptureStore 是回填落库面的替身:只留下注记正文。 type noteCaptureStore struct { mu sync.Mutex rows []history.Message } func (s *noteCaptureStore) AppendFull(_ context.Context, msg history.Message) (history.Message, error) { s.mu.Lock() defer s.mu.Unlock() if msg.ID == "" { msg.ID = "msg-note" } s.rows = append(s.rows, msg) return msg, nil } // seenCaptureExtractor 记录内层提取器实际收到的对话。 type seenCaptureExtractor struct { mu sync.Mutex seen []model.Message } func (e *seenCaptureExtractor) Extract(_ context.Context, conversation []model.Message) ([]memory.MemoryItem, error) { e.mu.Lock() defer e.mu.Unlock() e.seen = append([]model.Message(nil), conversation...) return nil, nil } // TestLoopMemoryExtractorStripsBackfilledNotes 走真链路取一条注记(回填 → 落库 → // 转录播种),再把它连同一条主人输入交给组合根返回的提取器:内层必须只看得到主人 // 那一句。 func TestLoopMemoryExtractorStripsBackfilledNotes(t *testing.T) { b := bus.New() t.Cleanup(func() { _ = b.Close() }) store := ¬eCaptureStore{} bf, err := gateway.NewSubagentBackfill(gateway.SubagentBackfillDeps{Bus: b, History: store}) if err != nil { t.Fatalf("NewSubagentBackfill: %v", err) } if err := bf.Handle(context.Background(), bus.Event{ Subject: orchestration.SubagentResultSubject, Payload: &orchestration.SubagentResultEvent{ SessionID: history.MainSessionID, SpawnResult: &orchestration.SpawnResult{ Status: "done", Summary: "子代理汇总: 共 1 个, 1 成功", Agents: []orchestration.SpawnAgentResult{{ ID: "sub-a1", Goal: "调研 X", Status: "done", Summary: "结论:X 可行", }}, }, }, }); err != nil { t.Fatalf("Handle: %v", err) } if len(store.rows) != 1 { t.Fatalf("backfill wrote %d rows, want one system note", len(store.rows)) } note := transcript.Message(store.rows[0]) if note.Role != model.RoleSystem { t.Fatalf("seeded note role = %q, want system", note.Role) } inner := &seenCaptureExtractor{} extractor := loopMemoryExtractor(inner) if extractor == nil { t.Fatal("loopMemoryExtractor(non-nil) = nil") } if _, err := extractor.Extract(context.Background(), []model.Message{ note, model.TextMessage(model.RoleUser, "接着说"), }); err != nil { t.Fatalf("Extract: %v", err) } inner.mu.Lock() seen := inner.seen inner.mu.Unlock() if len(seen) != 1 { t.Fatalf("inner extractor saw %d messages, want only the user turn: the composition root dropped the note filter", len(seen)) } if !strings.Contains(messageTextOf(seen[0]), "接着说") { t.Fatalf("inner extractor lost the real dialogue: %+v", seen) } } // TestLoopMemoryExtractorKeepsNil 锁住未配置提取器的部署逐字不变。 func TestLoopMemoryExtractorKeepsNil(t *testing.T) { if got := loopMemoryExtractor(nil); got != nil { t.Fatalf("loopMemoryExtractor(nil) = %v, want nil", got) } } // messageTextOf 返回一条模型消息的正文(测试窄视图)。 func messageTextOf(m model.Message) string { var b strings.Builder for _, c := range m.Content { if c.Type == model.ContentTypeText { b.WriteString(c.Text) } } return b.String() }