| 1 | package memory |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | func TestRecallToolSearchesSavedMemories(t *testing.T) { |
| 11 | store := Store{Dir: t.TempDir()} |
| 12 | saveMemory(t, store, Memory{ |
| 13 | Name: "cache-first-history", |
| 14 | Title: "Cache first history", |
| 15 | Description: "History retrieval must preserve prompt cache stability", |
| 16 | Type: TypeProject, |
| 17 | Body: "Use a read-only BM25 retrieval tool instead of injecting dynamic history into the system prompt.", |
| 18 | }) |
| 19 | saveMemory(t, store, Memory{ |
| 20 | Name: "frontend-colors", |
| 21 | Description: "Dashboard color preference", |
| 22 | Type: TypeUser, |
| 23 | Body: "Avoid one-note palettes.", |
| 24 | }) |
| 25 | |
| 26 | tl := NewRecallTool(store) |
| 27 | if tl.Name() != "memory" || !tl.ReadOnly() { |
| 28 | t.Fatalf("unexpected tool identity: name=%q readonly=%v", tl.Name(), tl.ReadOnly()) |
| 29 | } |
| 30 | if !json.Valid(tl.Schema()) { |
| 31 | t.Fatal("memory schema is not valid JSON") |
| 32 | } |
| 33 | |
| 34 | out, err := tl.Execute(context.Background(), []byte(`{"operation":"search","query":"BM25 prompt cache","limit":5}`)) |
| 35 | if err != nil { |
| 36 | t.Fatalf("Execute search: %v", err) |
| 37 | } |
| 38 | if !strings.Contains(out, "cache-first-history") { |
| 39 | t.Fatalf("search output missing expected memory:\n%s", out) |
| 40 | } |
| 41 | if strings.Contains(out, "frontend-colors") { |
| 42 | t.Fatalf("unrelated memory should not match strongly enough:\n%s", out) |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | func TestRecallToolSchemaIsCacheStable(t *testing.T) { |
| 47 | tl := NewRecallTool(Store{Dir: t.TempDir()}) |
| 48 | if got, want := tl.Description(), "Search, list, and read saved background memories for this project, including explicitly global facts. Use this before saving a new memory to avoid duplicates, and when a saved memory from the index looks relevant but needs its full body. This tool is read-only; use remember to save or update a memory, and forget to archive one."; got != want { |
| 49 | t.Fatalf("memory description changed; this is provider-visible and affects prompt-cache shape.\nwant: %q\n got: %q", want, got) |
| 50 | } |
| 51 | const wantSchema = `{ |
| 52 | "type": "object", |
| 53 | "properties": { |
| 54 | "operation": {"type": "string", "enum": ["search", "read", "list"], "description": "search ranks saved memories; read returns one full memory by stable id or legacy name; list returns the saved-memory index."}, |
| 55 | "query": {"type": "string", "description": "Search query for operation=search."}, |
| 56 | "name": {"type": "string", "description": "Stable memory id, project/<name>.md or global/<name>.md reference, or legacy slug for operation=read."}, |
| 57 | "type": {"type": "string", "enum": ["user", "feedback", "project", "reference"], "description": "Optional memory type filter for search or list."}, |
| 58 | "scope": {"type": "string", "enum": ["project", "global"], "description": "Optional scope filter for search or list."}, |
| 59 | "limit": {"type": "integer", "description": "Maximum search/list results to return, default 8, max 20."} |
| 60 | }, |
| 61 | "required": ["operation"] |
| 62 | }` |
| 63 | if got := string(tl.Schema()); got != wantSchema { |
| 64 | t.Fatalf("memory schema changed; this is provider-visible and affects prompt-cache shape.\nwant:\n%s\n got:\n%s", wantSchema, got) |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | func TestRecallToolDropsCommonWordNoise(t *testing.T) { |
| 69 | store := Store{Dir: t.TempDir()} |
| 70 | saveMemory(t, store, Memory{ |
| 71 | Name: "rare-cache-rule", |
| 72 | Description: "Rare synthesis-cache rule", |
| 73 | Type: TypeProject, |
| 74 | Body: "rareterm common common common", |
| 75 | }) |
| 76 | for i := 0; i < 12; i++ { |
| 77 | saveMemory(t, store, Memory{ |
| 78 | Name: "common-note-" + string(rune('a'+i)), |
| 79 | Description: "Common note", |
| 80 | Type: TypeProject, |
| 81 | Body: "common", |
| 82 | }) |
| 83 | } |
| 84 | |
| 85 | out, err := NewRecallTool(store).Execute(context.Background(), []byte(`{"operation":"search","query":"rareterm common","limit":20}`)) |
| 86 | if err != nil { |
| 87 | t.Fatalf("Execute search: %v", err) |
| 88 | } |
| 89 | if !strings.Contains(out, "rare-cache-rule") { |
| 90 | t.Fatalf("top rare hit missing:\n%s", out) |
| 91 | } |
| 92 | if strings.Contains(out, "common-note-") { |
| 93 | t.Fatalf("common-word-only noise should be dropped:\n%s", out) |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | func TestRecallToolNoResultsGuidesFallbackSearches(t *testing.T) { |
| 98 | store := Store{Dir: t.TempDir()} |
| 99 | out, err := NewRecallTool(store).Execute(context.Background(), []byte(`{"operation":"search","query":"postgres://host:5433"}`)) |
| 100 | if err != nil { |
| 101 | t.Fatalf("Execute search: %v", err) |
| 102 | } |
| 103 | for _, want := range []string{"0 results does not prove", "Retry with 1-3 distinctive terms", "use the history tool"} { |
| 104 | if !strings.Contains(out, want) { |
| 105 | t.Fatalf("no-result output missing %q:\n%s", want, out) |
| 106 | } |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | func TestRecallToolExcludesArchivedMemories(t *testing.T) { |
| 111 | store := Store{Dir: t.TempDir()} |
| 112 | saveMemory(t, store, Memory{ |
| 113 | Name: "stale-synthesis-cache", |
| 114 | Description: "Stale synthesis-cache conclusion", |
| 115 | Type: TypeProject, |
| 116 | Body: "This archived conclusion should no longer affect agent recall.", |
| 117 | }) |
| 118 | if _, err := store.Archive("stale-synthesis-cache"); err != nil { |
| 119 | t.Fatalf("Archive: %v", err) |
| 120 | } |
| 121 | |
| 122 | tl := NewRecallTool(store) |
| 123 | for _, args := range []string{ |
| 124 | `{"operation":"search","query":"stale synthesis cache","limit":5}`, |
| 125 | `{"operation":"list"}`, |
| 126 | } { |
| 127 | out, err := tl.Execute(context.Background(), []byte(args)) |
| 128 | if err != nil { |
| 129 | t.Fatalf("Execute(%s): %v", args, err) |
| 130 | } |
| 131 | if strings.Contains(out, "stale-synthesis-cache") { |
| 132 | t.Fatalf("archived memory leaked into active recall for %s:\n%s", args, out) |
| 133 | } |
| 134 | } |
| 135 | if _, err := tl.Execute(context.Background(), []byte(`{"operation":"read","name":"stale-synthesis-cache"}`)); err == nil { |
| 136 | t.Fatal("read should not find archived memory as active memory") |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | func TestRecallToolReadsMemoryByName(t *testing.T) { |
| 141 | store := Store{Dir: t.TempDir()} |
| 142 | saveMemory(t, store, Memory{ |
| 143 | Name: "user-prefers-tabs", |
| 144 | Title: "Prefers tabs", |
| 145 | Description: "User prefers tabs for indentation", |
| 146 | Type: TypeUser, |
| 147 | Body: "Use tabs unless the repository style clearly says otherwise.", |
| 148 | }) |
| 149 | |
| 150 | out, err := NewRecallTool(store).Execute(context.Background(), []byte(`{"operation":"read","name":"user-prefers-tabs"}`)) |
| 151 | if err != nil { |
| 152 | t.Fatalf("Execute read: %v", err) |
| 153 | } |
| 154 | for _, want := range []string{"Memory user-prefers-tabs", "id: mem-", "revision: 1", "type: user", "Use tabs"} { |
| 155 | if !strings.Contains(out, want) { |
| 156 | t.Fatalf("read output missing %q:\n%s", want, out) |
| 157 | } |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | func TestRecallToolReadsMemoryByListedMarkdownName(t *testing.T) { |
| 162 | store := Store{Dir: t.TempDir()} |
| 163 | saveMemory(t, store, Memory{ |
| 164 | Name: "listed-memory", |
| 165 | Description: "Listed memory reference", |
| 166 | Type: TypeProject, |
| 167 | Body: "The listed Markdown target is a valid read reference.", |
| 168 | }) |
| 169 | |
| 170 | out, err := NewRecallTool(store).Execute(context.Background(), []byte(`{"operation":"read","name":"listed-memory.md"}`)) |
| 171 | if err != nil { |
| 172 | t.Fatalf("Execute read with listed Markdown name: %v", err) |
| 173 | } |
| 174 | if !strings.Contains(out, "Memory listed-memory") || !strings.Contains(out, "valid read reference") { |
| 175 | t.Fatalf("read by listed Markdown name = %s", out) |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | func TestRecallToolOutputsUseStableReferences(t *testing.T) { |
| 180 | root := t.TempDir() |
| 181 | store := Store{Dir: root + "/project", GlobalDir: root + "/global"} |
| 182 | saveMemory(t, store, Memory{ |
| 183 | Name: "private-store-path", |
| 184 | Description: "Distinctive privacy sentinel", |
| 185 | Type: TypeProject, |
| 186 | Scope: FactScopeProject, |
| 187 | Body: "Provider-visible results use stable memory references.", |
| 188 | }) |
| 189 | |
| 190 | tool := NewRecallTool(store) |
| 191 | for _, args := range []string{ |
| 192 | `{"operation":"search","query":"distinctive privacy sentinel"}`, |
| 193 | `{"operation":"read","name":"project/private-store-path.md"}`, |
| 194 | `{"operation":"list"}`, |
| 195 | } { |
| 196 | out, err := tool.Execute(context.Background(), []byte(args)) |
| 197 | if err != nil { |
| 198 | t.Fatalf("Execute(%s): %v", args, err) |
| 199 | } |
| 200 | if strings.Contains(out, root) || strings.Contains(out, store.Dir) { |
| 201 | t.Fatalf("provider-visible memory output exposed store path for %s:\n%s", args, out) |
| 202 | } |
| 203 | if !strings.Contains(out, "project/private-store-path.md") { |
| 204 | t.Fatalf("provider-visible memory output missing stable reference for %s:\n%s", args, out) |
| 205 | } |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | func TestRecallToolStableReferencesDisambiguateSameNameAcrossScopes(t *testing.T) { |
| 210 | root := t.TempDir() |
| 211 | store := Store{Dir: root + "/project", GlobalDir: root + "/global"} |
| 212 | for _, fixture := range []struct { |
| 213 | ref string |
| 214 | desc string |
| 215 | body string |
| 216 | }{ |
| 217 | {ref: "project/shared.md", desc: "shared round trip project", body: "project body"}, |
| 218 | {ref: "global/shared.md", desc: "shared round trip global", body: "global body"}, |
| 219 | } { |
| 220 | if _, err := store.SaveWithOptions(Memory{Name: fixture.ref, Description: fixture.desc, Body: fixture.body}, SaveOptions{}); err != nil { |
| 221 | t.Fatal(err) |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | tool := NewRecallTool(store) |
| 226 | list, err := tool.Execute(context.Background(), []byte(`{"operation":"list"}`)) |
| 227 | if err != nil { |
| 228 | t.Fatal(err) |
| 229 | } |
| 230 | for _, ref := range []string{"project/shared.md", "global/shared.md"} { |
| 231 | if !strings.Contains(list, "reference="+ref) { |
| 232 | t.Fatalf("list output missing %q:\n%s", ref, list) |
| 233 | } |
| 234 | } |
| 235 | search, err := tool.Execute(context.Background(), []byte(`{"operation":"search","query":"shared round trip","limit":5}`)) |
| 236 | if err != nil { |
| 237 | t.Fatal(err) |
| 238 | } |
| 239 | for _, ref := range []string{"project/shared.md", "global/shared.md"} { |
| 240 | if !strings.Contains(search, "reference: "+ref) { |
| 241 | t.Fatalf("search output missing %q:\n%s", ref, search) |
| 242 | } |
| 243 | } |
| 244 | for _, fixture := range []struct { |
| 245 | ref string |
| 246 | body string |
| 247 | }{ |
| 248 | {ref: "project/shared.md", body: "project body"}, |
| 249 | {ref: "global/shared.md", body: "global body"}, |
| 250 | } { |
| 251 | out, err := tool.Execute(context.Background(), []byte(`{"operation":"read","name":"`+fixture.ref+`"}`)) |
| 252 | if err != nil || !strings.Contains(out, fixture.body) || !strings.Contains(out, "reference: "+fixture.ref) { |
| 253 | t.Fatalf("read %s = %q, err=%v", fixture.ref, out, err) |
| 254 | } |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | func TestRecallToolListsAndFiltersByType(t *testing.T) { |
| 259 | store := Store{Dir: t.TempDir()} |
| 260 | saveMemory(t, store, Memory{Name: "one", Description: "project fact", Type: TypeProject, Body: "body"}) |
| 261 | saveMemory(t, store, Memory{Name: "two", Description: "user fact", Type: TypeUser, Body: "body"}) |
| 262 | |
| 263 | out, err := NewRecallTool(store).Execute(context.Background(), []byte(`{"operation":"list","type":"user"}`)) |
| 264 | if err != nil { |
| 265 | t.Fatalf("Execute list: %v", err) |
| 266 | } |
| 267 | if !strings.Contains(out, "two") || !strings.Contains(out, "id=mem-") || !strings.Contains(out, "revision=1") || strings.Contains(out, "one") { |
| 268 | t.Fatalf("type filter did not apply:\n%s", out) |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | func TestRecallToolReadsMemoryByStableID(t *testing.T) { |
| 273 | store := Store{Dir: t.TempDir()} |
| 274 | result, err := store.SaveWithOptions(Memory{Name: "rename-safe", Description: "stable identity", Body: "body"}, SaveOptions{}) |
| 275 | if err != nil { |
| 276 | t.Fatal(err) |
| 277 | } |
| 278 | out, err := NewRecallTool(store).Execute(context.Background(), []byte(`{"operation":"read","name":"`+result.Memory.ID+`"}`)) |
| 279 | if err != nil { |
| 280 | t.Fatal(err) |
| 281 | } |
| 282 | if !strings.Contains(out, "Memory rename-safe") || !strings.Contains(out, "id: "+result.Memory.ID) { |
| 283 | t.Fatalf("read by stable ID = %s", out) |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | func TestRecallToolListsAndFiltersByScope(t *testing.T) { |
| 288 | root := t.TempDir() |
| 289 | store := Store{Dir: root + "/project", GlobalDir: root + "/global"} |
| 290 | saveMemory(t, store, Memory{Name: "local-user", Description: "project user fact", Type: TypeUser, Scope: FactScopeProject, Body: "body"}) |
| 291 | saveMemory(t, store, Memory{Name: "global-user", Description: "global user fact", Type: TypeUser, Scope: FactScopeGlobal, Body: "body"}) |
| 292 | |
| 293 | out, err := NewRecallTool(store).Execute(context.Background(), []byte(`{"operation":"list","type":"user","scope":"project"}`)) |
| 294 | if err != nil { |
| 295 | t.Fatalf("Execute list: %v", err) |
| 296 | } |
| 297 | if !strings.Contains(out, "local-user") || strings.Contains(out, "global-user") { |
| 298 | t.Fatalf("scope filter did not apply:\n%s", out) |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | func TestRecallToolValidatesInputs(t *testing.T) { |
| 303 | store := Store{Dir: t.TempDir()} |
| 304 | tl := NewRecallTool(store) |
| 305 | if _, err := tl.Execute(context.Background(), []byte(`{"operation":"search"}`)); err == nil { |
| 306 | t.Fatal("search without query should fail") |
| 307 | } |
| 308 | if _, err := tl.Execute(context.Background(), []byte(`{"operation":"read"}`)); err == nil { |
| 309 | t.Fatal("read without name should fail") |
| 310 | } |
| 311 | if _, err := tl.Execute(context.Background(), []byte(`{"operation":"list","type":"unknown"}`)); err == nil { |
| 312 | t.Fatal("unknown type should fail") |
| 313 | } |
| 314 | if _, err := tl.Execute(context.Background(), []byte(`{"operation":"list","scope":"unknown"}`)); err == nil { |
| 315 | t.Fatal("unknown scope should fail") |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | func saveMemory(t *testing.T, store Store, m Memory) { |
| 320 | t.Helper() |
| 321 | if _, err := store.Save(m); err != nil { |
| 322 | t.Fatalf("Save(%s): %v", m.Name, err) |
| 323 | } |
| 324 | } |
| 325 |