Files
Cyrene_v2/internal/runtime/loop/memory_async_test.go
T
AskaEth 91e8d7a029 docs(runtime): internal/runtime/loop 44 个文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;本模块按长期分批协议(一个模块一次派工)执行。
注释行数 1329 → 1329 逐行 1:1(含中文 33 → 1304);行尾注释 5 条一并译。
校验:机械校验(非注释改动行)= 空;独立 Go AST 程序(剥注释复渲)证明 44/44 文件代码全等;
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
gofmt:capacity_loop_test.go / flow.go 改动前即未格式化,按铁律保留原样未动。
2026-10-03 22:28:21 +08:00

229 lines
7.7 KiB
Go

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))
}
}