package gateway 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" ) // 本文件锁住 M32 的第二道红线:**子代理结果绝不进记忆抽取**。 // // 它不是「提示词里让模型别提取」,而是一条元数据判据(role=system + 注记标记): // 抽取器的输入在到达内层之前就被剔干净。测试因此断言的是**内层实际收到了什么** // —— 这正是缺陷的判定点(那条线刚查过:84% 记忆来自内部会话,不能再添污染源)。 // captureExtractor 记录每次抽取收到的对话,返回预设条目。 type captureExtractor struct { mu sync.Mutex seen [][]model.Message items []memory.MemoryItem } func (e *captureExtractor) Extract(_ context.Context, conversation []model.Message) ([]memory.MemoryItem, error) { e.mu.Lock() defer e.mu.Unlock() e.seen = append(e.seen, append([]model.Message(nil), conversation...)) return e.items, nil } func (e *captureExtractor) last() []model.Message { e.mu.Lock() defer e.mu.Unlock() if len(e.seen) == 0 { return nil } return e.seen[len(e.seen)-1] } // captureAsyncExtractor 是带异步能力的捕获提取器(对应 memory.LLMExtractor)。 type captureAsyncExtractor struct { *captureExtractor } func (e *captureAsyncExtractor) ExtractAsync(ctx context.Context, conversation []model.Message) (<-chan memory.AsyncResult, error) { items, err := e.Extract(ctx, conversation) out := make(chan memory.AsyncResult, 1) out <- memory.AsyncResult{Items: items, Err: err} close(out) return out, nil } // noteMessage 返回一条与回填落库行**同源**的系统注记模型消息:它经过 // `transcript.Message` 的映射,因此走的就是下一回合真正播种的那条路。 func noteMessage(t *testing.T) model.Message { t.Helper() row := history.Message{ ID: "msg-note", SessionID: "web", Role: history.RoleSystem, Content: subagentNote(settledResult("web", orchestration.SpawnAgentResult{ID: "sub-a", Status: "done", Summary: "子代理的结论"})), } return transcript.Message(row) } // userMessage 构造一条普通主人输入。 func userMessage(text string) model.Message { return model.TextMessage(model.RoleUser, text) } // TestWithoutSubagentNotesStripsBackfilledNotes 锁住同步路径:注记在内层提取器看到 // 之前就被剔掉,其余对话逐字保留,提取结果原样透传。 func TestWithoutSubagentNotesStripsBackfilledNotes(t *testing.T) { t.Parallel() inner := &captureExtractor{items: []memory.MemoryItem{{Content: "记住的事"}}} wrapped := WithoutSubagentNotes(inner) conv := []model.Message{ userMessage("你好"), noteMessage(t), model.TextMessage(model.RoleAssistant, "在的"), } items, err := wrapped.Extract(context.Background(), conv) if err != nil { t.Fatalf("Extract: %v", err) } if len(items) != 1 || items[0].Content != "记住的事" { t.Fatalf("items = %+v, want the inner extractor's result passed through", items) } seen := inner.last() if len(seen) != 2 { t.Fatalf("inner extractor saw %d messages, want 2 (the note must be removed): %+v", len(seen), seen) } for _, m := range seen { if strings.HasPrefix(modelText(m), subagentNoteMarker) { t.Fatalf("inner extractor saw the sub-agent note %q: it would be ingested as dialogue", modelText(m)) } } if modelText(seen[1]) != "在的" { t.Fatalf("conversation order changed: %+v", seen) } } // TestWithoutSubagentNotesKeepsCompactionSummary 锁住**不过度剔除**:压缩摘要同样是 // system 角色(loop 的 summaryMarker),它是对话的合法压缩,必须继续参与抽取。 func TestWithoutSubagentNotesKeepsCompactionSummary(t *testing.T) { t.Parallel() inner := &captureExtractor{} wrapped := WithoutSubagentNotes(inner) summary := model.TextMessage(model.RoleSystem, "[前文摘要 · history summary]\n\n之前聊过口味") if _, err := wrapped.Extract(context.Background(), []model.Message{summary, userMessage("我喜欢淡一点")}); err != nil { t.Fatalf("Extract: %v", err) } if got := len(inner.last()); got != 2 { t.Fatalf("inner extractor saw %d messages, want both the summary and the user turn", got) } } // TestWithoutSubagentNotesKeepsAsync 锁住「装饰之后仍然异步」:慢提取绝不能从后台 // goroutine 掉回回合落定的同步路径上(那会直接拖慢回复)。 func TestWithoutSubagentNotesKeepsAsync(t *testing.T) { t.Parallel() inner := &captureAsyncExtractor{&captureExtractor{}} wrapped := WithoutSubagentNotes(inner) async, ok := wrapped.(memoryAsyncExtractor) if !ok { t.Fatal("the decorator dropped ExtractAsync: a slow LLM extraction would now block turn settlement") } ch, err := async.ExtractAsync(context.Background(), []model.Message{noteMessage(t), userMessage("你好")}) if err != nil { t.Fatalf("ExtractAsync: %v", err) } <-ch seen := inner.last() if len(seen) != 1 || modelText(seen[0]) != "你好" { t.Fatalf("async extractor saw %+v, want only the user turn", seen) } } // TestWithoutSubagentNotesIsNoopWithoutNotes 锁住零成本路径:没有注记时原样返回入参 // (不分配、不改顺序),且 nil 提取器仍是 nil(未配置提取器的部署逐字不变)。 func TestWithoutSubagentNotesIsNoopWithoutNotes(t *testing.T) { t.Parallel() if got := WithoutSubagentNotes(nil); got != nil { t.Fatalf("WithoutSubagentNotes(nil) = %v, want nil", got) } conv := []model.Message{userMessage("你好")} got := dialogueOnly(conv) if len(got) != len(conv) { t.Fatalf("dialogueOnly changed a note-free conversation: %+v", got) } if &got[0] != &conv[0] { t.Fatal("a note-free conversation must be returned as-is (no copy on the hot path)") } } // TestBackfilledNoteStaysOutOfMemoryExtraction 是这条防线的**端到端**接缝断言: // 真回填落库 → 转录播种 → 抽取。注记在整条链上看得到(历史与模型上下文都有它), // 但抽取器**一次也看不到**它。 func TestBackfilledNoteStaysOutOfMemoryExtraction(t *testing.T) { t.Parallel() b := bus.New() t.Cleanup(func() { _ = b.Close() }) store := ¬eStore{} bf, err := NewSubagentBackfill(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: 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) } // 下一回合的模型上下文 = 播种的转录行(这就是 loop 交给提取器的 msgs 的来源)。 nextTurn := []model.Message{transcript.Message(rows[0]), userMessage("接着说")} inner := &captureExtractor{} if _, err := WithoutSubagentNotes(inner).Extract(context.Background(), nextTurn); err != nil { t.Fatalf("Extract: %v", err) } seen := inner.last() if len(seen) != 1 { t.Fatalf("extractor input = %+v, want only the user turn", seen) } if !strings.Contains(modelText(seen[0]), "接着说") { t.Fatalf("extractor lost the real dialogue: %+v", seen) } } // modelText 返回一条模型消息的正文(测试窄视图)。 func modelText(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() }