package loop import ( "context" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // LLM extractor 是 design/03 §1 背后具备异步能力的实现:循环 // 对此能力做类型断言,把提取脱离到后台 // goroutine,因此该契约在此锁定,而不是在运行时才发现。 var ( _ memory.Extractor = (*memory.LLMExtractor)(nil) _ asyncExtractor = (*memory.LLMExtractor)(nil) _ memory.Extractor = (*memory.ManualExtractor)(nil) ) // gatedExtractor 是一个同时实现 asyncExtractor 的 memory.Extractor。 // 它的提取会阻塞直到 release 被关闭(或 ctx 被取消),使测试能 // 证明循环的 turn 在提取仍在途时就结束,且 // 提取能在调用方自身的取消下存活。 type gatedExtractor struct { release chan struct{} started chan struct{} items []memory.MemoryItem dispatch int } func newGatedExtractor() *gatedExtractor { return &gatedExtractor{ release: make(chan struct{}), started: make(chan struct{}, 1), items: []memory.MemoryItem{{ Scope: memory.ScopeUser, Kind: memory.KindRelationship, Content: "用户习惯:爱喝冰美式", Confidence: 0.9, DecayWeight: 1, }}, } } func (g *gatedExtractor) Extract(ctx context.Context, _ []model.Message) ([]memory.MemoryItem, error) { select { case g.started <- struct{}{}: default: } select { case <-g.release: return g.items, nil case <-ctx.Done(): return nil, ctx.Err() } } func (g *gatedExtractor) ExtractAsync(ctx context.Context, conv []model.Message) (<-chan memory.AsyncResult, error) { g.dispatch++ out := make(chan memory.AsyncResult, 1) go func() { defer close(out) items, err := g.Extract(ctx, conv) out <- memory.AsyncResult{Items: items, Err: err} }() return out, nil } // TestExtractMemoryAsync_LLMExtractorThroughLoop 把真实的 LLM extractor // (非假实现)接入循环:turn 落定后,extractor 自身的模型 // 调用在后台运行,其解析出的条目落入 store,因此 // 生产路径(design/03 §1 LLM 异步自动提取)被端到端演练。 func TestExtractMemoryAsync_LLMExtractorThroughLoop(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) ad := &mockAdapter{chat: []mgResp{ {resp: model.Response{Content: "记住了"}}, {resp: model.Response{Content: `[{"content":"用户爱喝冰美式","scope":"user","kind":"relationship","confidence":0.92}]`}}, }} a := newTestAgent(t, ad, nil) l := New(Options{ Agent: a, Memory: store, PersonaID: "persona-a", MaxRounds: 8, Extractor: memory.NewLLMExtractor(ad, "persona-a", "owner"), }) if _, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "我喜欢冰美式")}); err != nil { t.Fatalf("Run: %v", err) } waitForMemoryCount(t, store, "persona-a", 1) items, err := store.ListByPersona(context.Background(), "persona-a") if err != nil { t.Fatalf("ListByPersona: %v", err) } if items[0].Content != "用户爱喝冰美式" || items[0].Scope != memory.ScopeUser { t.Fatalf("stored item = %+v, want the extracted user-scope memory", items[0]) } if items[0].PersonaID != "persona-a" { t.Fatalf("item persona = %q, want persona-a", items[0].PersonaID) } extractCalls := 0 for _, req := range ad.reqs { if req.Purpose == "memory_extract" { extractCalls++ } } if extractCalls != 1 { t.Fatalf("memory_extract model calls = %d, want 1 (the LLM extractor drove the background call)", extractCalls) } } // waitForMemoryCount 轮询该角色已存的记忆直到达到 want, // 提取始终未落定时让测试失败。 func waitForMemoryCount(t *testing.T, store *memory.Store, personaID string, want int) { t.Helper() deadline := time.Now().Add(2 * time.Second) for { items, err := store.ListByPersona(context.Background(), personaID) if err != nil { t.Fatalf("ListByPersona: %v", err) } if len(items) == want { return } if time.Now().After(deadline) { t.Fatalf("stored memories = %d, want %d (extraction never landed)", len(items), want) } time.Sleep(5 * time.Millisecond) } } // TestExtractMemoryAsync_DoesNotBlockTurn 证明 turn 在 LLM // 提取仍在运行时返回,且提取在调用方的 context 已被取消之后 // 仍能完成(并持久化)—— 脱离的 context // 使请求作用域的取消无法中止后台提取。 func TestExtractMemoryAsync_DoesNotBlockTurn(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) ext := newGatedExtractor() ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "记住了"}}}} a := newTestAgent(t, ad, nil) l := New(Options{Agent: a, Memory: store, Extractor: ext, PersonaID: "persona-a", MaxRounds: 8}) ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { _, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, "记住 我爱喝冰美式")}) done <- err }() select { case err := <-done: if err != nil { t.Fatalf("Run: %v", err) } case <-time.After(2 * time.Second): t.Fatal("Run blocked on memory extraction: the async path must not gate the reply") } if ext.dispatch != 1 { t.Fatalf("async dispatch count = %d, want 1 (one extraction per settled turn)", ext.dispatch) } items, err := store.ListByPersona(context.Background(), "persona-a") if err != nil { t.Fatalf("ListByPersona: %v", err) } if len(items) != 0 { t.Fatalf("store already holds %d items after Run, want 0 (extraction still in flight)", len(items)) } // 该 turn 已结束,因此其 context 被取消;后台提取 // 仍必须完成并持久化。 cancel() select { case <-ext.started: case <-time.After(2 * time.Second): t.Fatal("extraction goroutine never started") } close(ext.release) waitForMemoryCount(t, store, "persona-a", 1) } // TestExtractMemoryAsync_RestrictedEnvNeverDispatches 证明群聊 / // 陌生人隔离对异步路径同样设门:什么也不派发,因此从只读 // 对话绝无法写入主人私有记忆。 func TestExtractMemoryAsync_RestrictedEnvNeverDispatches(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) ext := newGatedExtractor() l := New(Options{Memory: store, Extractor: ext, PersonaID: "persona-a"}) ctx := WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}) l.extractMemoryAsync(ctx, []model.Message{model.TextMessage(model.RoleUser, "记住 我爱喝冰美式")}, 1) if ext.dispatch != 0 { t.Fatalf("async dispatch count = %d, want 0 (restricted context is read-only)", ext.dispatch) } items, err := store.ListByPersona(context.Background(), "persona-a") if err != nil { t.Fatalf("ListByPersona: %v", err) } if len(items) != 0 { t.Fatalf("restricted async extract wrote %d items, want 0 (read-only isolation)", len(items)) } } // TestExtractMemoryAsync_ManualExtractorStaysSync 证明确定性路径 // 未变:不具备异步能力的 extractor 仍就地运行,因此 // 显式的 "记住 X" 记忆在该调用返回时已存入。 func TestExtractMemoryAsync_ManualExtractorStaysSync(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) l := New(Options{Memory: store, Extractor: memory.NewManualExtractor("persona-a", "owner"), PersonaID: "persona-a"}) ctx := WithSessionEnv(context.Background(), OwnerEnv()) l.extractMemoryAsync(ctx, []model.Message{model.TextMessage(model.RoleUser, "记住 我爱喝冰美式")}, 1) items, err := store.ListByPersona(context.Background(), "persona-a") if err != nil { t.Fatalf("ListByPersona: %v", err) } if len(items) != 1 { t.Fatalf("stored memories = %d, want 1 (deterministic extractor runs inline)", len(items)) } }