//go:build medialive // 媒体记忆自动触发链的集成测试。 // // 与其他媒体测试的区别:**不手工调用任何一步**。这里只做两件事—— // 往 IOManager 注入一个 image 事件,然后等。之后全部由生产代码自己走: // // processMediaInput → captureBlockMedia(入 CAS) // → Prune → transferMediaRefs(L0→L2 引用转移) // → describePendingMedia(真实视觉模型生成描述) // → archiveColdDocs → commitTriplesWithMedia → bindSentenceMedia(L2→L3) // → 第二轮提问,验证 agent 真能召回 // // 为什么必须这样测:单测能证明每个函数正确,却证明不了它**被接上了**。 // 本文件的直接动机是一个真实缺陷——core.New() 漏了 rc.SetMediaStore(cfg.MediaStore), // 于是 L0→L2 引用转移在生产里永远静默 return,而手工注入 store 的单测全绿。 // // 需要真实 LLM,因此加 medialive build tag,默认 go test 不跑: // // MEDIALIVE_BASE_URL=http://127.0.0.1:8081/v1 \ // MEDIALIVE_API_KEY=sk-xxx \ // MEDIALIVE_MODEL=claude-opus-5 \ // MEDIALIVE_ADAPTER=openai \ // go test -tags medialive ./internal/agent/core/ -run TestMediaLive -v -timeout 20m // // 源、模型、密钥全部由调用方显式指定,测试自己不猜任何默认值—— // 猜一个默认端点会让测试在别人机器上打到意料之外的服务。 package core import ( "bytes" "compress/zlib" "encoding/binary" "fmt" "hash/crc32" "os" "path/filepath" "strconv" "strings" "testing" "time" agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua" "gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" "gitcode.com/JianFeeeee/HomeAgent/pkg/types" ) // liveCfg 是调用方通过环境变量显式提供的 LLM 源配置。 type liveCfg struct { baseURL string apiKey string model string adapter string } // requireLiveCfg 读取环境变量;缺任何一项就 Skip 而非猜默认值。 // // 刻意不提供 fallback:一个猜出来的 base_url 可能打到调用者机器上 // 完全不相干的服务,而测试会把那次调用的失败报成"媒体记忆有问题"。 func requireLiveCfg(t *testing.T) liveCfg { t.Helper() c := liveCfg{ baseURL: os.Getenv("MEDIALIVE_BASE_URL"), apiKey: os.Getenv("MEDIALIVE_API_KEY"), model: os.Getenv("MEDIALIVE_MODEL"), adapter: os.Getenv("MEDIALIVE_ADAPTER"), } var missing []string if c.baseURL == "" { missing = append(missing, "MEDIALIVE_BASE_URL") } if c.apiKey == "" { missing = append(missing, "MEDIALIVE_API_KEY") } if c.model == "" { missing = append(missing, "MEDIALIVE_MODEL") } if c.adapter == "" { missing = append(missing, "MEDIALIVE_ADAPTER") } if len(missing) > 0 { t.Skipf("缺少环境变量 %s——本测试要求调用方显式指定源/模型/密钥,不使用任何默认值", strings.Join(missing, ", ")) } return c } // livePNG 造一张横向三色带真 PNG(手工拼 IHDR/IDAT/IEND)。 // // 用可辨认的纯色而非随机字节:断言要能检查"模型是否真的看到了内容", // 随机噪声无法产生可验证的描述。 func livePNG(t *testing.T, w, h int, colors [][3]byte) []byte { t.Helper() chunk := func(typ string, data []byte) []byte { var b bytes.Buffer if err := binary.Write(&b, binary.BigEndian, uint32(len(data))); err != nil { t.Fatal(err) } body := append([]byte(typ), data...) b.Write(body) if err := binary.Write(&b, binary.BigEndian, crc32.ChecksumIEEE(body)); err != nil { t.Fatal(err) } return b.Bytes() } var raw bytes.Buffer for y := 0; y < h; y++ { raw.WriteByte(0) // filter type: none c := colors[y*len(colors)/h] for x := 0; x < w; x++ { raw.Write(c[:]) } } var comp bytes.Buffer zw := zlib.NewWriter(&comp) if _, err := zw.Write(raw.Bytes()); err != nil { t.Fatal(err) } zw.Close() var ihdr bytes.Buffer binary.Write(&ihdr, binary.BigEndian, uint32(w)) binary.Write(&ihdr, binary.BigEndian, uint32(h)) ihdr.Write([]byte{8, 2, 0, 0, 0}) // 8-bit truecolor var out bytes.Buffer out.Write([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) out.Write(chunk("IHDR", ihdr.Bytes())) out.Write(chunk("IDAT", comp.Bytes())) out.Write(chunk("IEND", nil)) return out.Bytes() } // liveEnv 是一套完整但完全独立的 agent 运行环境。 type liveEnv struct { agent *Agent io *agentIO.IOManager mediaSt *media.Store docStore *document.Store graph *memory.GraphDB dir string } // newLiveEnv 构造真 Agent:真 provider、真 CAS、真图库、真文档库。 // // 不注册任何插件:本测试关心记忆链路,插件会引入无关的外部副作用 // (网络轮询、写文件),而且生产插件目录里的进程不该被测试碰到。 func newLiveEnv(t *testing.T, c liveCfg) *liveEnv { t.Helper() dir := t.TempDir() vm := luaVM.NewVM(filepath.Join(dir, "adapters")) if err := vm.Start(); err != nil { t.Fatalf("lua vm: %v", err) } t.Cleanup(vm.Stop) // Vision: true —— 能力是声明的,不是探测的。网关可能静默剥离 // image_url 后仍返回 200,从响应无法推断它到底看见了没有。 prov := agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{ Model: c.model, BaseURL: c.baseURL, APIKey: c.apiKey, MaxTokens: 1200, Temperature: 0.3, Vision: true, }, vm, "medialive", c.adapter) pm := agentAPI.NewProviderManager() pm.Register("medialive", prov) if err := pm.SetDefault("medialive"); err != nil { t.Fatalf("set default provider: %v", err) } ms, err := media.New(filepath.Join(dir, "media"), 256<<20) if err != nil { t.Fatalf("media store: %v", err) } t.Cleanup(func() { ms.Close() }) graph, err := memory.NewGraphDB(filepath.Join(dir, "graph.db")) if err != nil { t.Fatalf("graph: %v", err) } t.Cleanup(func() { graph.Close() }) docStore := document.NewStore(filepath.Join(dir, "docs")) if err := docStore.Start(); err != nil { t.Fatalf("doc store: %v", err) } t.Cleanup(docStore.Stop) io := agentIO.NewIOManager() a := New(AgentConfig{ ID: types.AgentID("medialive"), SystemPrompt: "你是一个有长期记忆的助手。回答简洁准确。", Provider: prov, ProviderManager: pm, IO: io, Memory: graph, DocStore: docStore, MediaStore: ms, MediaGCInterval: 0, // 本测试自己控制 GC 时机 MediaDescribe: true, // 描述循环由测试直接调 describePendingMedia StageHost: NewStageHost(), MaxContextSize: 3, // 故意压低:第二轮就能触发 Prune 归档 InputProcessing: types.InputProcessingConfig{}, }) // 排空 outputCh:容量 256,但长跑不消费会堵住 emitResponse。 go func() { for { select { case <-io.OutputChan(): case <-a.ctx.Done(): return } } }() return &liveEnv{agent: a, io: io, mediaSt: ms, docStore: docStore, graph: graph, dir: dir} } // TestMediaLive_AutoTriggerChain 全自动触发链:只注入事件,不手工调任何一步。 func TestMediaLive_AutoTriggerChain(t *testing.T) { c := requireLiveCfg(t) env := newLiveEnv(t, c) a := env.agent defer a.Stop() img := livePNG(t, 96, 96, [][3]byte{{128, 0, 255}, {0, 64, 255}, {255, 0, 0}}) t.Logf("测试图片: %d 字节(紫/蓝/红三色带)", len(img)) // ── 阶段 1:注入 image 事件,验证 CAS 自动落盘 ── // // 直接调 handleInput 而不启 eventLoop:eventLoop 是纯转发(select → // handleInput),走同一条代码路径,但同步调用让断言不必猜时序。 evt := &agentIO.InputEvent{ RequestID: "live-1", Source: "test_channel", Type: "image", OutputChannel: "test_channel", Payload: map[string]interface{}{ "data": mediaB64(img), "mime": "image/png", "alt": "一张测试图片", }, } t0 := time.Now() a.handleInput(evt) t.Logf("第一轮(含真实 LLM 往返)耗时 %.1fs", time.Since(t0).Seconds()) // 用 Pending 而非 Search 查刚落盘的项:Search 的 WHERE 里带 // `COALESCE(description,'') != ''`,只返回**已描述**的媒体, // 此刻描述还没生成(阶段3 才做),Search 必然返回 0 条。 items, err := env.mediaSt.Pending(10) if err != nil { t.Fatalf("pending: %v", err) } if len(items) != 1 { t.Fatalf("CAS 应自动收到 1 张图,实际 %d 张(captureBlockMedia 未被触发?)", len(items)) } digest := items[0].Digest t.Logf("✓ 阶段1 CAS 自动落盘: digest=%s size=%d tool=%s", digest[:12], items[0].Size, items[0].Tool) stored, err := env.mediaSt.Get(digest) if err != nil || !bytes.Equal(stored, img) { t.Fatalf("落盘内容与原图不一致 (err=%v)", err) } // ── 阶段 2:引用自动挂到 ContextEvent 上 ── // // 这一步验证 bindEventMedia:事件必须拿到 ID 且 media_refs 里 // 有对应 context owner 记录。两者只写一个的后果是 GC 误删或永不清理。 var evtID string var summaryOK bool for _, e := range a.context.Recent(0) { if len(e.Media) > 0 { evtID = e.ID summaryOK = strings.Contains(e.Input, digest[:12]) break } } if evtID == "" { t.Fatal("没有任何 ContextEvent 挂上媒体(bindEventMedia 未被触发)") } ctxRefs, err := env.mediaSt.Refs(media.OwnerContext, evtID) if err != nil || len(ctxRefs) != 1 || ctxRefs[0] != digest { t.Fatalf("context owner 引用缺失: refs=%v err=%v", ctxRefs, err) } if !summaryOK { t.Error("事件 Input 里没有媒体摘要标记(mediaSummaryForEvent 未生效)——" + "L2/L3 靠正文里的短 digest 反查,缺了它整条召回链断掉") } t.Logf("✓ 阶段2 引用自动绑定: event=%s owner=context 摘要内嵌=%v", evtID, summaryOK) // ── 阶段 3:描述由后台循环自动生成(真实视觉模型)── pending, err := env.mediaSt.Pending(5) if err != nil { t.Fatal(err) } if len(pending) != 1 { t.Fatalf("应有 1 条待描述,实际 %d 条", len(pending)) } t1 := time.Now() a.describePendingMedia() t.Logf("描述生成耗时 %.1fs", time.Since(t1).Seconds()) it, err := env.mediaSt.Stat(digest) if err != nil { t.Fatal(err) } if it.Description == "" { t.Fatal("描述为空——describePendingMedia 未能通过视觉源生成描述") } sawColors := strings.Contains(it.Description, "紫") && strings.Contains(it.Description, "蓝") && strings.Contains(it.Description, "红") t.Logf("✓ 阶段3 描述自动生成 (%d 字, 源=%s): %s", len([]rune(it.Description)), it.DescribedBy, truncRunes(it.Description, 90)) if !sawColors { t.Errorf("描述未含紫/蓝/红三色,视觉模型可能没真正看到图片: %s", truncRunes(it.Description, 200)) } if left, _ := env.mediaSt.Pending(5); len(left) != 0 { t.Errorf("描述完成后仍在待描述队列(%d 条)——会被反复重描述", len(left)) } // 有描述之后 Search 才应能命中(它按 description 做 LIKE) if found, err := env.mediaSt.Search("紫", media.KindImage, 5); err != nil { t.Errorf("search: %v", err) } else if len(found) == 0 { t.Error("描述已生成但 Search(\"紫\") 命中 0 条——媒体库关键词入口失效") } else { t.Logf("✓ 阶段3 Search(\"紫\") 命中 %d 条", len(found)) } // ── 阶段 4:Prune 自动把引用从 L0 转移到 L2 ── // // MaxContextSize=3,多注入几轮文本把带图事件挤出活跃上下文。 // 这一步专门守 core.New() 里 rc.SetMediaStore 的接线:漏了它 // transferMediaRefs 直接 return,引用永久悬空在 context owner 上。 // 填充数量必须 > Prune 内部固定的 10 条保护窗口。 // // Prune 无条件保护最后 10 条事件(protected := events[len-10:]), // 只在更早的部分里挑归档对象。填 4 条时总数才 5,全落进保护窗口、 // candidates 为空、直接返回 0——这不是缺陷,是"最近的对话不该被归档" // 的设计。带图事件必须被推到第 11 条之前才可能被归档。 const fillerCount = 14 for i := 0; i < fillerCount; i++ { a.context.Append(ContextEvent{ Timestamp: time.Now(), Source: "filler", Input: fmt.Sprintf("无关的填充对话 %d,用来把带图事件挤出活跃窗口", i), Response: "好的。", }) } archived := a.context.Prune("当前输入", a.maxContextSize-1, env.docStore) t.Logf("Prune 归档 %d 条事件", archived) if archived == 0 { t.Fatal("Prune 未归档任何事件,无法验证引用转移") } docRefsFound := "" for _, d := range env.docStore.RecentDocs(20) { refs, err := env.mediaSt.Refs(media.OwnerDocument, d.ID) if err == nil && len(refs) > 0 && refs[0] == digest { docRefsFound = d.ID break } } if docRefsFound == "" { t.Fatal("引用未转移到 document owner——" + "core.New() 是否漏了 rc.SetMediaStore(cfg.MediaStore)?" + "(该缺陷曾真实存在:手工注入 store 的单测全绿,生产里永远静默 return)") } if left, _ := env.mediaSt.Refs(media.OwnerContext, evtID); len(left) != 0 { t.Errorf("旧的 context 引用未注销(%d 条),引用计数永不归零 → blob 永不回收", len(left)) } t.Logf("✓ 阶段4 引用自动转移: context/%s → document/%s", evtID, docRefsFound) // 转移全程内容必须可读:先挂后销的顺序若反了, // 计数会瞬时归零,并发 GC 会把仍被引用的内容当孤儿删掉。 if _, err := env.mediaSt.Get(digest); err != nil { t.Fatalf("转移后内容不可读: %v", err) } // ── 阶段 5:archiveColdDocs 自动把媒体带进 L3 图库 ── // // FindColdDocs(72h, 2) 要求文档足够"冷",测试里新建的文档不满足, // 因此把 LastAccess 往前推——这是为了触发生产代码路径, // 而不是替代它(Commit/bindSentenceMedia/releaseDocMedia 全部由它自己调)。 for _, d := range env.docStore.RecentDocs(20) { if d.ID == docRefsFound { d.LastAccess = time.Now().Add(-100 * time.Hour) d.AccessCount = 0 } } a.archiveColdDocs() sentRefs := 0 var boundSentence int64 rows, err := env.graph.Recall(nil, nil, 1, "") if err != nil { t.Fatalf("graph recall: %v", err) } t.Logf("图库实体数 %d", len(rows.Entities)) // 句子 id 是自增整数,扫前若干个足够覆盖本测试写入的量 for sid := int64(1); sid <= 40; sid++ { refs, err := env.mediaSt.Refs(media.OwnerGraphSentence, strconv.FormatInt(sid, 10)) if err == nil && len(refs) > 0 { sentRefs += len(refs) if boundSentence == 0 { boundSentence = sid } } } if sentRefs == 0 { t.Error("L2→L3 未绑定任何 graph_sentence 引用——" + "bindSentenceMedia 未被 commitTriplesWithMedia 触发," + "或句子正文里没有可反解的短 digest") } else { t.Logf("✓ 阶段5 L3 自动绑定: %d 个句子引用,首个 sentences.id=%d", sentRefs, boundSentence) got, err := env.agent.RecallMediaForSentence(boundSentence) if err != nil || len(got) == 0 || got[0] != digest { t.Errorf("从句子反查 digest 失败: got=%v err=%v", got, err) } else if raw, err := env.mediaSt.Get(got[0]); err != nil || !bytes.Equal(raw, img) { t.Errorf("从句子取回的字节与原图不一致 (err=%v)", err) } else { t.Logf("✓ 阶段5 反查取回 %d 字节,与原图逐字节一致", len(raw)) } } // ── 阶段 6:GC 不能删掉仍被记忆引用的内容 ── removed, freed, err := env.mediaSt.GC(0) // minAge=0,最激进 if err != nil { t.Fatal(err) } if _, err := env.mediaSt.Stat(digest); err != nil { t.Fatalf("被记忆引用的内容被 GC 删除了(清 %d 条/%d 字节)——"+ "引用计数或 owner 语义有误", removed, freed) } t.Logf("✓ 阶段6 GC(minAge=0) 清 %d 条,被引用内容仍在", removed) // ── 阶段 7:E2E — 第二轮提问,验证 agent 真能召回 ── // // 不再提供图片,只问"还记得吗"。能答出三色说明记忆链路端到端可用。 // L2 文档此刻已被 archiveColdDocs 删除(归档的语义就是搬完删源), // 所以这一轮只能靠 L3 图库召回——而自动注入路径依赖 indexer。 // 生产由 main.go 注入并周期 Sync;测试里手工建一个并同步一次。 a.indexer = memory.NewIndexer(env.graph) if err := a.indexer.Sync(); err != nil { t.Fatalf("indexer sync: %v", err) } if mc := a.buildMemoryContext("图片 颜色", 0); mc != "" { t.Logf("注入的记忆上下文: %s", truncRunes(mc, 200)) if strings.Contains(mc, "【关联媒体】") { t.Logf("✓ 记忆上下文含媒体段") } else { t.Error("记忆上下文缺少媒体段——L3 媒体检索接线未生效") } } else { t.Error("图库召回为空,agent 无从得知历史媒体") } ask := &agentIO.InputEvent{ RequestID: "live-2", Source: "test_channel", Type: "text", OutputChannel: "test_channel", Payload: map[string]interface{}{ "content": "你还记得我之前发给你的那张图片吗?它是什么样子的?请说出具体颜色。", }, } respCh := make(chan *agentIO.OutputEvent, 4) ask.ResponseCh = respCh t2 := time.Now() a.handleInput(ask) t.Logf("第二轮耗时 %.1fs", time.Since(t2).Seconds()) var answer string select { case out := <-respCh: answer, _ = out.Payload["content"].(string) case <-time.After(5 * time.Second): t.Fatal("第二轮没有收到回复") } t.Logf("agent 回答: %s", truncRunes(answer, 220)) recalled := strings.Contains(answer, "紫") && strings.Contains(answer, "蓝") && strings.Contains(answer, "红") if !recalled { t.Errorf("agent 未能召回三色。这可能是记忆注入链路问题,"+ "也可能是本轮上下文里已无相关记忆(描述在 L2/L3 但未被检索命中)。回答: %s", truncRunes(answer, 300)) } else { t.Logf("✓ 阶段7 E2E 召回成功:不给图,agent 答出紫/蓝/红") } st := env.mediaSt.Stats() t.Logf("收尾: %v 条 / %v 字节 / 已描述 %v / 无引用 %v", st["count"], st["total_bytes"], st["described"], st["unreferenced"]) } // TestMediaLive_NegativeControl 阴性对照:没有媒体记忆时不该"记得"。 // // 没有这条对照,阶段7 的"答出紫蓝红"可能只是模型在猜常见配色, // 无法区分真召回与先验偏好。 func TestMediaLive_NegativeControl(t *testing.T) { c := requireLiveCfg(t) env := newLiveEnv(t, c) a := env.agent defer a.Stop() ask := &agentIO.InputEvent{ RequestID: "neg-1", Source: "test_channel", Type: "text", OutputChannel: "test_channel", Payload: map[string]interface{}{ "content": "你还记得我之前发给你的那张图片吗?它是什么样子的?请说出具体颜色。", }, } respCh := make(chan *agentIO.OutputEvent, 4) ask.ResponseCh = respCh a.handleInput(ask) var answer string select { case out := <-respCh: answer, _ = out.Payload["content"].(string) case <-time.After(5 * time.Second): t.Fatal("阴性对照没有收到回复") } t.Logf("无记忆时的回答: %s", truncRunes(answer, 200)) // 上游不可用时这条对照没有意义:它只能证明"没答出颜色", // 而原因是调用失败而非缺少记忆。据此判 PASS 属于假阳性。 if strings.HasPrefix(answer, "处理错误:") { t.Skipf("上游 LLM 调用失败,阴性对照无法判定: %s", truncRunes(answer, 160)) } guessed := strings.Contains(answer, "紫") && strings.Contains(answer, "蓝") && strings.Contains(answer, "红") if guessed { t.Errorf("无任何媒体记忆却猜中紫/蓝/红——"+ "说明阳性用例的通过可能只是先验偏好而非真召回: %s", truncRunes(answer, 300)) } } // mediaB64 返回不带 data URL 前缀的 base64(processMediaInput 自己拼前缀)。 func mediaB64(b []byte) string { return media.DataURL("image/png", b)[len("data:image/png;base64,"):] } func truncRunes(s string, n int) string { r := []rune(strings.ReplaceAll(s, "\n", " ")) if len(r) <= n { return string(r) } return string(r[:n]) + "…" }