test(memory)!: 块 union 化判据重写(CAS 淘汰第二阶段:测试)

生产已在上一提交(86572d13)全绿;本提交把 CAS 专属测试改到块语义,
并修掉几处判据自身的缺陷(判据错 ≠ 生产错):

- sdk/memory_impl_test: media.Store→块;NewGraphMemoryWithPlugin
- core/{graphmedia,mediaref,medialive,medialoop,inputunify}_test:
  mediaStore→memory;PayloadDigest→Data/BlockData;shortDigest→shortID
- graphmedia: 无引用句子用独立块 ID(shortID 20 字符会与 blk_src_ 撞长度)
- memory/sentence_block: BlockByIDPrefix 加 len<12 下限(短引用守卫)
- core/medialoop: reembedOneBlock 按 BlockData 兜底取字节(lean 列无 data)
- knowledge: broken.png 改目录造读失败(chmod 0000 对 root 无效);
  dense/attach 测试补 Data 并先落盘后 AttachMedia
- webui: graph_pulse fixture payload_digest→has_data

注:internal/plugin/proc 的 shmVersion 不匹配(内核1/插件2)是
SDK 仓(777c3a0,v2 模板)与本分支内核(v1)的跨仓版本错位,
与块 union 无关(stash 全部改动后仍复现),属 proc-extra 特性线。
This commit is contained in:
JianFeeeee
2026-10-12 14:26:13 +08:00
parent 86572d134d
commit 33e1a0bfaf
16 changed files with 630 additions and 858 deletions

View File

@ -9,28 +9,27 @@ import (
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
"github.com/JianFeeeee/HomeAgent/internal/memory"
"github.com/JianFeeeee/HomeAgent/internal/memory/document"
"github.com/JianFeeeee/HomeAgent/internal/memory/media"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
)
// 统一输入主干(processInput / resolveInput / injectedBlocks)与
// 模型可调用工具的媒体接线测试。
// 模型可调用工具的媒体接线测试(块 union 化之后)。
//
// 这一层此前的结构性缺陷:text 与 image/audio 各有一个 process 函数,
// 这层此前的结构性缺陷:text 与 image/audio 各有一个 process 函数,
// 媒体那条缺了去重、no_memory、通道 Cleaner、中断语义、EventRawInput 五项。
// 归一成一条主干后,这些行为对所有模态一致——下面的断言就是这个不变量。
func newInputTestAgent(t *testing.T) (*Agent, *media.Store) {
func newInputTestAgent(t *testing.T) *Agent {
t.Helper()
dir := t.TempDir()
ms, err := media.New(filepath.Join(dir, "media"))
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
if err != nil {
t.Fatalf("media.New: %v", err)
t.Fatalf("NewGraphDB: %v", err)
}
t.Cleanup(func() { ms.Close() })
t.Cleanup(func() { g.Close() })
return &Agent{mediaStore: ms}, ms
return &Agent{memory: g}
}
// ---------- injectedBlocks ----------
@ -107,7 +106,7 @@ func TestInjectedBlocks_AcceptsBothStaticTypes(t *testing.T) {
// ---------- resolveInput ----------
func TestResolveInput_UnifiesAllModalities(t *testing.T) {
a, _ := newInputTestAgent(t)
a := newInputTestAgent(t)
t.Run("用户上传图片", func(t *testing.T) {
in, ok := a.resolveInput(&agentIO.InputEvent{
@ -191,60 +190,51 @@ func TestResolveInput_UnifiesAllModalities(t *testing.T) {
})
}
// ---------- 模型工具侧:memory_digests 结构化传递 ----------
// ---------- 模型工具侧:media_refs 结构化传递 ----------
// 模型只知道 digest(从对话或 memory_recall 的「关联媒体」读到)。
// 它不再需要自己拼任何标记:digest 作为结构化字段随三元组提交。
func TestResolveMediaDigestsAndNoMarkerText(t *testing.T) {
a, ms := newInputTestAgent(t)
digest, err := ms.Put([]byte("marker-bytes"), media.Item{MIME: "image/png"})
if err != nil {
t.Fatalf("Put: %v", err)
// 模型只知道块 ID(从对话或 memory_recall 的「关联媒体」读到)。
// 它不再需要自己拼任何标记:块引用作为结构化字段随三元组提交。
func TestResolveMediaRefsAndNoMarkerText(t *testing.T) {
a := newInputTestAgent(t)
b := mediaBlock("image/png", []byte("marker-bytes"))
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
t.Fatalf("PutMemoryBlocks: %v", err)
}
t.Run("短digest补全", func(t *testing.T) {
got := a.resolveMediaDigests([]string{digest[:12]})
if len(got) != 1 || got[0] != digest {
t.Fatalf("短 digest 应补全为完整 digest,得到 %v", got)
t.Run("短引用补全", func(t *testing.T) {
got := a.resolveMediaRefs([]string{b.ID[:12]})
if len(got) != 1 || got[0] != b.ID {
t.Fatalf("短引用应补全为完整块 ID,得到 %v", got)
}
})
t.Run("无法解析的digest被丢弃", func(t *testing.T) {
if got := a.resolveMediaDigests([]string{"ffffffffffff"}); len(got) != 0 {
t.Errorf("不存在的 digest 不该保留: %v", got)
t.Run("无法解析的引用被丢弃", func(t *testing.T) {
if got := a.resolveMediaRefs([]string{"ffffffffffff"}); len(got) != 0 {
t.Errorf("不存在的块引用不该保留: %v", got)
}
})
t.Run("无媒体存储时返回nil", func(t *testing.T) {
t.Run("无图库时返回nil", func(t *testing.T) {
bare := &Agent{}
if got := bare.resolveMediaDigests([]string{digest}); got != nil {
t.Errorf("无媒体存储时应返回 nil: %v", got)
if got := bare.resolveMediaRefs([]string{b.ID}); got != nil {
t.Errorf("无图库时应返回 nil: %v", got)
}
})
}
// 句子文本必须保持原样:媒体归属走结构化块边,不往文本里贴 marker。
func TestMemoryCommit_DoesNotPolluteSentenceText(t *testing.T) {
dir := t.TempDir()
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
if err != nil {
a := newInputTestAgent(t)
b := mediaBlock("image/png", []byte("clean-sentence"))
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
t.Fatal(err)
}
defer g.Close()
ms, err := media.New(filepath.Join(dir, "media"))
if err != nil {
t.Fatal(err)
}
defer ms.Close()
a := &Agent{memory: g, mediaStore: ms}
digest, _ := ms.Put([]byte("clean-sentence"), media.Item{MIME: "image/png"})
sentence := "用户发来一张图。"
triples := []memory.Triple{{
Subject: "用户", Relation: "发来", Object: "图片",
SentenceText: sentence,
MediaDigests: a.resolveMediaDigests([]string{digest[:12]}),
MediaDigests: a.resolveMediaRefs([]string{b.ID[:12]}),
}}
if _, _, _, err := a.commitTriplesWithMedia(triples, "s1", 0, nil); err != nil {
t.Fatal(err)
@ -260,49 +250,51 @@ func TestMemoryCommit_DoesNotPolluteSentenceText(t *testing.T) {
if res.Relations[0].SentenceText != sentence {
t.Errorf("句子文本被污染: %q", res.Relations[0].SentenceText)
}
// ★ 挂载点已从「sentences 表行号」改成「原句块 ID」
// (CommitWithMedia 返回值随之改为 map[string]string)
// ★ 挂载点是「原句块 ID」
blocks, err := a.memory.BlocksForNode("block",
memory.SentenceBlockID(res.Relations[0].SentenceText))
if err != nil {
t.Fatal(err)
}
if len(blocks) != 1 || blocks[0].PayloadDigest != digest {
if len(blocks) != 1 || blocks[0].ID != b.ID {
t.Errorf("块应挂到句子,实际 %+v", blocks)
}
}
// ---------- resolveMediaDigests ----------
// ---------- resolveMediaRefs ----------
func TestResolveMediaDigests(t *testing.T) {
a, ms := newInputTestAgent(t)
d1, _ := ms.Put([]byte("one"), media.Item{MIME: "image/png"})
d2, _ := ms.Put([]byte("two"), media.Item{MIME: "image/png"})
func TestResolveMediaRefs(t *testing.T) {
a := newInputTestAgent(t)
b1 := mediaBlock("image/png", []byte("one"))
b2 := mediaBlock("image/png", []byte("two"))
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b1, b2}); err != nil {
t.Fatal(err)
}
got := a.resolveMediaDigests([]string{d1[:10], d2, d1, "ffffffffffff"})
got := a.resolveMediaRefs([]string{b1.ID[:10], b2.ID, b1.ID, "ffffffffffff"})
if len(got) != 2 {
t.Fatalf("got = %v,期望 2 条(去重 + 丢弃无法解析的)", got)
}
for _, d := range got {
if len(d) != 64 {
t.Errorf("应返回完整 digest,实际 %q", d)
for _, id := range got {
if !strings.HasPrefix(id, "blk_media_") {
t.Errorf("应返回完整块 ID,实际 %q", id)
}
}
if a.resolveMediaDigests(nil) != nil {
if a.resolveMediaRefs(nil) != nil {
t.Error("空输入应返回 nil")
}
bare := &Agent{}
if bare.resolveMediaDigests([]string{d1}) != nil {
t.Error("无媒体存储时应返回 nil")
if bare.resolveMediaRefs([]string{b1.ID}) != nil {
t.Error("无图库时应返回 nil")
}
}
// ---------- 文档持有的一等记忆块 ----------
func TestDocCommit_StoresBlocks(t *testing.T) {
// doc_commit 带 media_digests 时,媒体应作为一等块直接存在文档上,
// 并随 doc 一起持久化(不再靠 media_refs 保活)。
// doc_commit 带媒体时,媒体应作为一等块直接存在文档上,
// 并随 doc 一起持久化(块自带字节,不靠外部引用保活)。
dir := t.TempDir()
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := ds.Start(); err != nil {
@ -310,21 +302,10 @@ func TestDocCommit_StoresBlocks(t *testing.T) {
}
defer ds.Stop()
ms, err := media.New(filepath.Join(dir, "media"))
if err != nil {
t.Fatal(err)
}
defer ms.Close()
b1 := mediaBlock("image/png", []byte("doc-one"))
b2 := mediaBlock("image/png", []byte("doc-two"))
d1, _ := ms.Put([]byte("doc-one"), media.Item{MIME: "image/png"})
d2, _ := ms.Put([]byte("doc-two"), media.Item{MIME: "image/png"})
doc := &document.Doc{ID: "doc_x", Summary: "s", Content: "c"}
for _, d := range []string{d1, d2} {
if b, ok := (&Agent{mediaStore: ms}).blockFromDigest(d); ok {
doc.Blocks = append(doc.Blocks, b)
}
}
doc := &document.Doc{ID: "doc_x", Summary: "s", Content: "c", Blocks: []memory.MemoryBlock{b1, b2}}
if err := ds.Insert(doc); err != nil {
t.Fatal(err)
}
@ -335,27 +316,23 @@ func TestDocCommit_StoresBlocks(t *testing.T) {
}
seen := map[string]bool{}
for _, b := range blocks {
seen[b.PayloadDigest] = true
seen[b.ID] = true
}
if !seen[d1] || !seen[d2] {
t.Errorf("块 digest 不对: %+v", blocks)
if !seen[b1.ID] || !seen[b2.ID] {
t.Errorf("块身份不对: %+v", blocks)
}
}
// ---------- 文档持有块标签(doc_query 展示用) ----------
func TestBlockLabelsForDoc(t *testing.T) {
a, ms := newInputTestAgent(t)
digest, _ := ms.Put([]byte("ctx-bytes"), media.Item{MIME: "image/png"})
b, ok := a.blockFromDigest(digest)
if !ok {
t.Fatal("blockFromDigest 失败")
}
a := newInputTestAgent(t)
b := mediaBlock("image/png", []byte("ctx-bytes"))
t.Run("从文档持有的一等块渲染", func(t *testing.T) {
got := a.blockLabelsForDoc(&document.Doc{ID: "doc_1", Blocks: []memory.MemoryBlock{b}})
if !strings.Contains(got, shortDigest(digest)) {
t.Errorf("标签应含短 digest: %q", got)
if !strings.Contains(got, shortID(b.ID)) {
t.Errorf("标签应含短块 ID: %q", got)
}
if !strings.Contains(got, "image/png") {
t.Errorf("标签应含 MIME: %q", got)
@ -367,42 +344,28 @@ func TestBlockLabelsForDoc(t *testing.T) {
t.Errorf("应返回空串,实际 %q", got)
}
})
t.Run("无媒体存储", func(t *testing.T) {
bare := &Agent{}
if got := bare.blockLabelsForDoc(&document.Doc{ID: "doc_x"}); got != "" {
t.Errorf("无媒体存储时应返回空串,实际 %q", got)
}
})
}
// ---------- mediaLabel ----------
// ---------- blockLabel ----------
// 媒体标签的唯一生成处:只含 MIME 与短 digest,不含任何生成的描述。
func TestMediaLabel(t *testing.T) {
a, ms := newInputTestAgent(t)
_ = a
digest, _ := ms.Put([]byte("labelled"), media.Item{MIME: "image/png"})
it, err := ms.Stat(digest)
if err != nil {
t.Fatal(err)
}
got := mediaLabel(it)
// 媒体标签的唯一生成处:只含 MIME 与短块 ID,不含任何生成的描述。
func TestBlockLabel(t *testing.T) {
b := mediaBlock("image/png", []byte("labelled"))
got := blockLabel(&b)
if !strings.Contains(got, "image/png") {
t.Errorf("标签应含 MIME: %q", got)
}
if !strings.Contains(got, shortDigest(digest)) {
t.Errorf("必须带短 digest 供反查: %q", got)
if !strings.Contains(got, shortID(b.ID)) {
t.Errorf("必须带短块 ID 供反查: %q", got)
}
if got := mediaLabel(nil); got != "" {
if got := blockLabel(nil); got != "" {
t.Errorf("nil 应返回空串,实际 %q", got)
}
}
// ---------- 模型工具端到端:memory_commit / doc_commit / doc_query ----------
func newToolTestAgent(t *testing.T) (*Agent, *media.Store) {
func newToolTestAgent(t *testing.T) *Agent {
t.Helper()
dir := t.TempDir()
@ -418,27 +381,23 @@ func newToolTestAgent(t *testing.T) (*Agent, *media.Store) {
}
t.Cleanup(func() { ds.Stop() })
ms, err := media.New(filepath.Join(dir, "media"))
if err != nil {
t.Fatalf("media.New: %v", err)
}
t.Cleanup(func() { ms.Close() })
emb := memory.NewStaticEmbedder("")
a := &Agent{
id: "tester",
memory: g,
docStore: ds,
mediaStore: ms,
context: NewRelevanceContext("", emb),
id: "tester",
memory: g,
docStore: ds,
context: NewRelevanceContext("", emb),
}
return a, ms
return a
}
// memory_commit 带 media_digests:三元组入库后必须能从句子反查回那份字节。
// memory_commit 带 media_refs:三元组入库后必须能从句子反查回那份字节。
func TestToolMemoryCommit_BindsMedia(t *testing.T) {
a, ms := newToolTestAgent(t)
digest, _ := ms.Put([]byte("commit-bytes"), media.Item{MIME: "image/png"})
a := newToolTestAgent(t)
b := mediaBlock("image/png", []byte("commit-bytes"))
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
t.Fatal(err)
}
out := a.executeMemoryTool(agentAPI.ToolCall{
Name: "memory_commit",
@ -448,7 +407,7 @@ func TestToolMemoryCommit_BindsMedia(t *testing.T) {
"subject": "配色方案",
"relation": "参考",
"object": "三色带图",
"media_digests": []interface{}{digest[:12]},
"media_digests": []interface{}{b.ID[:12]},
},
},
},
@ -462,26 +421,29 @@ func TestToolMemoryCommit_BindsMedia(t *testing.T) {
t.Fatalf("Recall: %v", err)
}
// ★ 断言改用**原句文本**(2026-10-04):
// SentenceID 是旧 sentences 表行号,退场后恒为 0 ——
// 而它是这里唯一与「有没有句子落点」有关的字段,于是恒红。
// SentenceID 是旧 sentences 表行号,退场后恒为 0。
if len(res.Relations) == 0 || res.Relations[0].SentenceText == "" {
t.Fatal("没有句子落点 —— 媒体引用无从挂起")
}
// ★ 挂载点已从「sentences 表行号」改成「原句块 ID」
// (CommitWithMedia 返回值随之改为 map[string]string)
// ★ 挂载点是「原句块 ID」
blocks, err := a.memory.BlocksForNode("block",
memory.SentenceBlockID(res.Relations[0].SentenceText))
if err != nil {
t.Fatalf("BlocksForNode: %v", err)
}
if len(blocks) != 1 || blocks[0].PayloadDigest != digest {
t.Errorf("句子块 = %+v,期望 [%s]", blocks, digest)
if len(blocks) != 1 || blocks[0].ID != b.ID {
t.Errorf("句子块 = %+v,期望 [%s]", blocks, b.ID)
}
// 字节随块可取回(块即 union)
data, err := a.memory.BlockData(b.ID)
if err != nil || string(data) != "commit-bytes" {
t.Errorf("块字节应可取回: %q / %v", data, err)
}
}
// 不带 media_digests 时行为与本特性上线前一致(不多写句子、不报错)。
// 不带 media_refs 时行为与本特性上线前一致(不多写句子、不报错)。
func TestToolMemoryCommit_WithoutMedia(t *testing.T) {
a, _ := newToolTestAgent(t)
a := newToolTestAgent(t)
out := a.executeMemoryTool(agentAPI.ToolCall{
Name: "memory_commit",
Arguments: map[string]interface{}{
@ -500,7 +462,7 @@ func TestToolMemoryCommit_WithoutMedia(t *testing.T) {
// sentence_text 必须透传:丢了它,图谱就回不到原文。
func TestToolMemoryCommit_CarriesSentenceText(t *testing.T) {
a, _ := newToolTestAgent(t)
a := newToolTestAgent(t)
a.executeMemoryTool(agentAPI.ToolCall{
Name: "memory_commit",
Arguments: map[string]interface{}{
@ -523,17 +485,20 @@ func TestToolMemoryCommit_CarriesSentenceText(t *testing.T) {
}
}
// doc_commit 带 media_digests:媒体成为文档直接持有的一等块;正文保持原样。
// doc_commit 带 media_refs:媒体成为文档直接持有的一等块;正文保持原样。
func TestToolDocCommit_BindsMedia(t *testing.T) {
a, ms := newToolTestAgent(t)
digest, _ := ms.Put([]byte("doc-commit-bytes"), media.Item{MIME: "image/png"})
a := newToolTestAgent(t)
b := mediaBlock("image/png", []byte("doc-commit-bytes"))
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
t.Fatal(err)
}
out := a.executeDocTool(agentAPI.ToolCall{
Name: "doc_commit",
Arguments: map[string]interface{}{
"content": "这是一篇带图的笔记正文。",
"summary": "带图笔记",
"media_digests": []interface{}{digest[:12]},
"media_digests": []interface{}{b.ID[:12]},
},
})
if !strings.Contains(out, "关联") {
@ -549,27 +514,30 @@ func TestToolDocCommit_BindsMedia(t *testing.T) {
t.Errorf("正文不该被媒体标记污染: %q", d.Content)
}
var held bool
for _, b := range d.Blocks {
if b.PayloadDigest == digest {
for _, blk := range d.Blocks {
if blk.ID == b.ID {
held = true
}
}
if !held {
t.Errorf("文档应持有一等记忆块 [%s],实际 %+v", digest, d.Blocks)
t.Errorf("文档应持有一等记忆块 [%s],实际 %+v", b.ID, d.Blocks)
}
}
// doc_query 必须把媒体说明附在返回值里,否则模型检索到带图文档也不知道有图。
func TestToolDocQuery_ShowsMedia(t *testing.T) {
a, ms := newToolTestAgent(t)
digest, _ := ms.Put([]byte("query-bytes"), media.Item{MIME: "image/png"})
a := newToolTestAgent(t)
b := mediaBlock("image/png", []byte("query-bytes"))
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
t.Fatal(err)
}
a.executeDocTool(agentAPI.ToolCall{
Name: "doc_commit",
Arguments: map[string]interface{}{
"content": "紫蓝红三色带配色说明正文",
"summary": "紫蓝红三色带",
"media_digests": []interface{}{digest},
"media_digests": []interface{}{b.ID},
},
})
@ -581,7 +549,7 @@ func TestToolDocQuery_ShowsMedia(t *testing.T) {
// 正文进的是 cold_storage 事件(工具返回值只给引用编号),媒体说明也在那里。
var found bool
for _, e := range a.context.Recent(10) {
if strings.Contains(e.Response, shortDigest(digest)) {
if strings.Contains(e.Response, shortID(b.ID)) {
found = true
}
}
@ -590,10 +558,9 @@ func TestToolDocQuery_ShowsMedia(t *testing.T) {
}
}
// 无媒体存储时三个工具的行为与本特性上线前完全一致。
func TestTools_NilMediaStoreDegrades(t *testing.T) {
a, _ := newToolTestAgent(t)
a.mediaStore = nil
// 引用了不存在的块时三个工具的行为与本特性上线前完全一致(不报错、不挂边)。
func TestTools_UnknownRefDegrades(t *testing.T) {
a := newToolTestAgent(t)
out := a.executeMemoryTool(agentAPI.ToolCall{
Name: "memory_commit",
@ -607,17 +574,17 @@ func TestTools_NilMediaStoreDegrades(t *testing.T) {
},
}, nil)
if strings.Contains(out, "失败") {
t.Errorf("无媒体存储时提交不该失败: %q", out)
t.Errorf("引用未知块时提交不该失败: %q", out)
}
out = a.executeDocTool(agentAPI.ToolCall{
Name: "doc_commit",
Arguments: map[string]interface{}{
"content": "无媒体存储的文档",
"content": "引用未知块的文档",
"media_digests": []interface{}{"aabbccddeeff"},
},
})
if strings.Contains(out, "失败") {
t.Errorf("无媒体存储时文档写入不该失败: %q", out)
t.Errorf("引用未知块时文档写入不该失败: %q", out)
}
}