| 1 | package memory |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | fileencoding "reasonix/internal/fileutil/encoding" |
| 10 | ) |
| 11 | |
| 12 | // TestComposeEmptyIsIdentity is the cache-first invariant: with no memory at |
| 13 | // all, Compose must return the base prompt byte-for-byte, so the cached system |
| 14 | // prefix is exactly what it was before memory existed. |
| 15 | func TestComposeEmptyIsIdentity(t *testing.T) { |
| 16 | base := "You are a helpful coding agent.\nBe concise." |
| 17 | got := Compose(base, &Set{}) |
| 18 | if got != base { |
| 19 | t.Fatalf("empty memory changed the prompt:\n base=%q\n got =%q", base, got) |
| 20 | } |
| 21 | // A nil-ish set (no docs, blank index) must also be identity. |
| 22 | if got := Compose(base, &Set{Index: " \n"}); got != base { |
| 23 | t.Fatalf("blank index changed the prompt: got %q", got) |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | // TestComposeAppendsAfterBase verifies memory folds in *after* the base prompt, |
| 28 | // so the base stays a valid cache prefix even as memory changes between sessions. |
| 29 | func TestComposeAppendsAfterBase(t *testing.T) { |
| 30 | base := "BASE PROMPT" |
| 31 | set := &Set{Docs: []Source{{Path: "/p/REASONIX.md", Scope: ScopeProject, Body: "Use tabs."}}} |
| 32 | got := Compose(base, set) |
| 33 | if !strings.HasPrefix(got, base) { |
| 34 | t.Fatalf("base is not the prefix of the composed prompt:\n%q", got) |
| 35 | } |
| 36 | if !strings.Contains(got, "Use tabs.") { |
| 37 | t.Fatalf("doc body missing from composed prompt:\n%q", got) |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | func TestBlockSeparatesStandingInstructionsFromBackgroundMemory(t *testing.T) { |
| 42 | set := &Set{ |
| 43 | Docs: []Source{{Path: "/p/AGENTS.md", Scope: ScopeProject, Directory: "/p", Body: "Always run tests.", Depth: 0}}, |
| 44 | Index: "- [API decision](api-decision.md) — [project/project] Chosen in an earlier session", |
| 45 | Store: Store{Dir: "/memory/project"}, |
| 46 | } |
| 47 | block := set.Block() |
| 48 | for _, want := range []string{"# Instructions", "## workspace/AGENTS.md (project", "## Background memory index", "background, not standing instructions"} { |
| 49 | if !strings.Contains(block, want) { |
| 50 | t.Fatalf("Block() missing %q:\n%s", want, block) |
| 51 | } |
| 52 | } |
| 53 | for _, privatePath := range []string{"/p/AGENTS.md", "/memory/project"} { |
| 54 | if strings.Contains(block, privatePath) { |
| 55 | t.Fatalf("Block() exposed machine-local path %q:\n%s", privatePath, block) |
| 56 | } |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | func TestLoadIncludesStableGlobalPreferencesAndFeedback(t *testing.T) { |
| 61 | root := t.TempDir() |
| 62 | user := filepath.Join(root, "user") |
| 63 | proj := filepath.Join(root, "project") |
| 64 | mustMkdir(t, filepath.Join(proj, ".git")) |
| 65 | mustWrite(t, filepath.Join(proj, "AGENTS.md"), "STANDING INSTRUCTION BODY") |
| 66 | store := StoreFor(user, proj) |
| 67 | if _, err := store.Save(Memory{Name: "alpha-user", Description: "global preference", Type: TypeUser, Scope: FactScopeGlobal, Body: "GLOBAL USER BODY"}); err != nil { |
| 68 | t.Fatal(err) |
| 69 | } |
| 70 | legacyFeedback := "---\nname: zeta-feedback\ndescription: legacy global feedback\nmetadata:\n type: feedback\n---\n\nGLOBAL FEEDBACK BODY\n" |
| 71 | mustWrite(t, filepath.Join(store.GlobalDir, "zeta-feedback.md"), legacyFeedback) |
| 72 | if err := reindexIn(store.GlobalDir, "zeta-feedback", Memory{Name: "zeta-feedback", Description: "legacy global feedback", Type: TypeFeedback, Scope: FactScopeGlobal}); err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | if _, err := store.Save(Memory{Name: "global-reference", Description: "global reference", Type: TypeReference, Scope: FactScopeGlobal, Body: "GLOBAL REFERENCE BODY"}); err != nil { |
| 76 | t.Fatal(err) |
| 77 | } |
| 78 | if _, err := store.Save(Memory{Name: "project-feedback", Description: "project feedback", Type: TypeFeedback, Scope: FactScopeProject, Body: "PROJECT FEEDBACK BODY"}); err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | |
| 82 | set := Load(Options{CWD: proj, UserDir: user}) |
| 83 | if len(set.GlobalGuidance) != 2 { |
| 84 | t.Fatalf("global guidance = %+v, want user + feedback only", set.GlobalGuidance) |
| 85 | } |
| 86 | block := set.Block() |
| 87 | for _, want := range []string{"## Global preferences and feedback", "GLOBAL USER BODY", "GLOBAL FEEDBACK BODY"} { |
| 88 | if !strings.Contains(block, want) { |
| 89 | t.Fatalf("Block() missing %q:\n%s", want, block) |
| 90 | } |
| 91 | } |
| 92 | for _, excluded := range []string{"GLOBAL REFERENCE BODY", "PROJECT FEEDBACK BODY"} { |
| 93 | if strings.Contains(block, excluded) { |
| 94 | t.Fatalf("Block() promoted non-guidance body %q:\n%s", excluded, block) |
| 95 | } |
| 96 | } |
| 97 | if strings.Index(block, "GLOBAL USER BODY") > strings.Index(block, "GLOBAL FEEDBACK BODY") { |
| 98 | t.Fatalf("global guidance is not deterministically sorted by name:\n%s", block) |
| 99 | } |
| 100 | if strings.Index(block, "## Global preferences and feedback") > strings.Index(block, "# Instructions") && strings.Contains(block, "# Instructions") { |
| 101 | t.Fatalf("lower-priority global guidance must precede standing instructions:\n%s", block) |
| 102 | } |
| 103 | if again := set.Block(); again != block { |
| 104 | t.Fatal("unchanged memory snapshot produced unstable prompt bytes") |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | func TestLoadProjectFactSuppressesEquivalentGlobalGuidance(t *testing.T) { |
| 109 | root := t.TempDir() |
| 110 | user := filepath.Join(root, "user") |
| 111 | proj := filepath.Join(root, "project") |
| 112 | mustMkdir(t, filepath.Join(proj, ".git")) |
| 113 | store := StoreFor(user, proj) |
| 114 | if _, err := store.Save(Memory{ |
| 115 | Name: "response-style", Type: TypeFeedback, Scope: FactScopeGlobal, |
| 116 | Description: "global style", Body: "Always be verbose.", |
| 117 | }); err != nil { |
| 118 | t.Fatal(err) |
| 119 | } |
| 120 | if _, err := store.Save(Memory{ |
| 121 | Name: "response-style", Type: TypeFeedback, Scope: FactScopeProject, |
| 122 | Description: "project style", Body: "Be concise in this project.", |
| 123 | }); err != nil { |
| 124 | t.Fatal(err) |
| 125 | } |
| 126 | if _, err := store.Save(Memory{ |
| 127 | Name: "language", Type: TypeUser, Scope: FactScopeGlobal, |
| 128 | Description: "global language", Body: "Answer in Chinese.", |
| 129 | }); err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | |
| 133 | set := Load(Options{CWD: proj, UserDir: user}) |
| 134 | if len(set.GlobalGuidance) != 1 || set.GlobalGuidance[0].Name != "language" { |
| 135 | t.Fatalf("global guidance = %+v, want only unshadowed language preference", set.GlobalGuidance) |
| 136 | } |
| 137 | block := set.Block() |
| 138 | if strings.Contains(block, "Always be verbose.") { |
| 139 | t.Fatalf("shadowed global guidance leaked into stable prefix:\n%s", block) |
| 140 | } |
| 141 | if !strings.Contains(block, "Answer in Chinese.") { |
| 142 | t.Fatalf("unshadowed global guidance missing from stable prefix:\n%s", block) |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | // TestDiscoverPrecedenceOrder checks user → ancestor → project → local ordering, |
| 147 | // which puts the most specific guidance last. |
| 148 | func TestDiscoverPrecedenceOrder(t *testing.T) { |
| 149 | root := t.TempDir() |
| 150 | user := filepath.Join(root, "userconfig") |
| 151 | proj := filepath.Join(root, "proj") |
| 152 | mustMkdir(t, user) |
| 153 | mustMkdir(t, proj) |
| 154 | // Make proj a git root so discovery stops there. |
| 155 | mustMkdir(t, filepath.Join(proj, ".git")) |
| 156 | |
| 157 | mustWrite(t, filepath.Join(user, "REASONIX.md"), "USER LEVEL") |
| 158 | mustWrite(t, filepath.Join(proj, "REASONIX.md"), "PROJECT LEVEL") |
| 159 | mustWrite(t, filepath.Join(proj, "REASONIX.local.md"), "LOCAL LEVEL") |
| 160 | |
| 161 | set := Load(Options{CWD: proj, UserDir: user}) |
| 162 | if len(set.Docs) != 3 { |
| 163 | t.Fatalf("want 3 docs, got %d: %+v", len(set.Docs), set.Docs) |
| 164 | } |
| 165 | wantScopes := []Scope{ScopeUser, ScopeProject, ScopeLocal} |
| 166 | for i, s := range wantScopes { |
| 167 | if set.Docs[i].Scope != s { |
| 168 | t.Fatalf("doc %d: want scope %q, got %q", i, s, set.Docs[i].Scope) |
| 169 | } |
| 170 | } |
| 171 | // In the composed block, local must appear after project must appear after user. |
| 172 | block := set.Block() |
| 173 | iu, ip, il := strings.Index(block, "USER LEVEL"), strings.Index(block, "PROJECT LEVEL"), strings.Index(block, "LOCAL LEVEL") |
| 174 | if !(iu >= 0 && iu < ip && ip < il) { |
| 175 | t.Fatalf("precedence order wrong in block: user=%d project=%d local=%d\n%s", iu, ip, il, block) |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | func TestDiscoverDecodesGB18030PrimaryDoc(t *testing.T) { |
| 180 | proj := t.TempDir() |
| 181 | mustMkdir(t, filepath.Join(proj, ".git")) |
| 182 | body := "# 项目约定\n\n始终使用中文回答。" |
| 183 | if err := os.WriteFile(filepath.Join(proj, "AGENTS.md"), fileencoding.Encode(body, fileencoding.GB18030), 0o644); err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | |
| 187 | set := Load(Options{CWD: proj}) |
| 188 | if len(set.Docs) != 1 || !strings.Contains(set.Docs[0].Body, "始终使用中文回答") { |
| 189 | t.Fatalf("decoded docs = %+v", set.Docs) |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | // TestImportResolution checks "@path" inlining, including a relative import. |
| 194 | func TestImportResolution(t *testing.T) { |
| 195 | proj := t.TempDir() |
| 196 | mustMkdir(t, filepath.Join(proj, ".git")) |
| 197 | mustWrite(t, filepath.Join(proj, "shared.md"), "SHARED CONTENT") |
| 198 | mustWrite(t, filepath.Join(proj, "REASONIX.md"), "Top line\n@shared.md\nBottom line") |
| 199 | |
| 200 | set := Load(Options{CWD: proj}) |
| 201 | if len(set.Docs) != 1 { |
| 202 | t.Fatalf("want 1 doc, got %d", len(set.Docs)) |
| 203 | } |
| 204 | body := set.Docs[0].Body |
| 205 | if !strings.Contains(body, "SHARED CONTENT") { |
| 206 | t.Fatalf("import not inlined: %q", body) |
| 207 | } |
| 208 | if strings.Contains(body, "@shared.md") { |
| 209 | t.Fatalf("import directive left in body: %q", body) |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | func TestImportResolutionRejectsEscapes(t *testing.T) { |
| 214 | proj := t.TempDir() |
| 215 | mustMkdir(t, filepath.Join(proj, ".git")) |
| 216 | outside := t.TempDir() |
| 217 | mustWrite(t, filepath.Join(outside, "secret.md"), "SECRET") |
| 218 | mustWrite(t, filepath.Join(proj, "REASONIX.md"), "Top\n@/abs/path.md\n@~/secret.md\n@../secret.md\nBottom") |
| 219 | |
| 220 | set := Load(Options{CWD: proj}) |
| 221 | if len(set.Docs) != 1 { |
| 222 | t.Fatalf("want 1 doc, got %d", len(set.Docs)) |
| 223 | } |
| 224 | body := set.Docs[0].Body |
| 225 | if strings.Contains(body, "SECRET") { |
| 226 | t.Fatalf("unsafe import was inlined: %q", body) |
| 227 | } |
| 228 | for _, directive := range []string{"@/abs/path.md", "@~/secret.md", "@../secret.md"} { |
| 229 | if !strings.Contains(body, directive) { |
| 230 | t.Fatalf("unsafe directive %q should be left visible, body: %q", directive, body) |
| 231 | } |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | func TestImportResolutionRejectsSymlinkEscape(t *testing.T) { |
| 236 | proj := t.TempDir() |
| 237 | mustMkdir(t, filepath.Join(proj, ".git")) |
| 238 | outside := t.TempDir() |
| 239 | mustWrite(t, filepath.Join(outside, "secret.md"), "SECRET") |
| 240 | if err := os.Symlink(filepath.Join(outside, "secret.md"), filepath.Join(proj, "linked.md")); err != nil { |
| 241 | t.Skipf("symlink unavailable: %v", err) |
| 242 | } |
| 243 | mustWrite(t, filepath.Join(proj, "REASONIX.md"), "Top\n@linked.md\nBottom") |
| 244 | |
| 245 | set := Load(Options{CWD: proj}) |
| 246 | if len(set.Docs) != 1 { |
| 247 | t.Fatalf("want 1 doc, got %d", len(set.Docs)) |
| 248 | } |
| 249 | body := set.Docs[0].Body |
| 250 | if strings.Contains(body, "SECRET") || !strings.Contains(body, "@linked.md") { |
| 251 | t.Fatalf("symlink escape should not be inlined, body: %q", body) |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | // TestImportCycleDoesNotHang verifies cycle detection terminates. |
| 256 | func TestImportCycleDoesNotHang(t *testing.T) { |
| 257 | proj := t.TempDir() |
| 258 | mustMkdir(t, filepath.Join(proj, ".git")) |
| 259 | mustWrite(t, filepath.Join(proj, "a.md"), "A\n@b.md") |
| 260 | mustWrite(t, filepath.Join(proj, "b.md"), "B\n@a.md") |
| 261 | mustWrite(t, filepath.Join(proj, "REASONIX.md"), "@a.md") |
| 262 | |
| 263 | set := Load(Options{CWD: proj}) // must return, not loop forever |
| 264 | body := set.Docs[0].Body |
| 265 | if !strings.Contains(body, "A") || !strings.Contains(body, "B") { |
| 266 | t.Fatalf("cycle import dropped content: %q", body) |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | func mustMkdir(t *testing.T, dir string) { |
| 271 | t.Helper() |
| 272 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 273 | t.Fatal(err) |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | func mustWrite(t *testing.T, path, body string) { |
| 278 | t.Helper() |
| 279 | if err := os.WriteFile(path, []byte(body), 0o644); err != nil { |
| 280 | t.Fatal(err) |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | func TestImportDiamondAndCycle(t *testing.T) { |
| 285 | proj := t.TempDir() |
| 286 | mustMkdir(t, filepath.Join(proj, ".git")) |
| 287 | |
| 288 | mustWrite(t, filepath.Join(proj, "shared.md"), "SHARED CONTENT") |
| 289 | mustWrite(t, filepath.Join(proj, "a.md"), "A\n@shared.md") |
| 290 | mustWrite(t, filepath.Join(proj, "b.md"), "B\n@shared.md") |
| 291 | mustWrite(t, filepath.Join(proj, "REASONIX.md"), "@a.md\n@b.md") |
| 292 | |
| 293 | set := Load(Options{CWD: proj}) |
| 294 | if len(set.Docs) != 1 { |
| 295 | t.Fatalf("want 1 doc, got %d", len(set.Docs)) |
| 296 | } |
| 297 | body := set.Docs[0].Body |
| 298 | |
| 299 | count := strings.Count(body, "SHARED CONTENT") |
| 300 | if count != 1 { |
| 301 | t.Errorf("expected exact imported content to appear once, got %d times. Body:\n%s", count, body) |
| 302 | } |
| 303 | if strings.Contains(body, "skipped: import cycle") { |
| 304 | t.Errorf("body contains incorrect import cycle message:\n%s", body) |
| 305 | } |
| 306 | |
| 307 | projCycle := t.TempDir() |
| 308 | mustMkdir(t, filepath.Join(projCycle, ".git")) |
| 309 | mustWrite(t, filepath.Join(projCycle, "cycle1.md"), "CYCLE1\n@cycle2.md") |
| 310 | mustWrite(t, filepath.Join(projCycle, "cycle2.md"), "CYCLE2\n@cycle1.md") |
| 311 | mustWrite(t, filepath.Join(projCycle, "REASONIX.md"), "@cycle1.md") |
| 312 | |
| 313 | setCycle := Load(Options{CWD: projCycle}) |
| 314 | if len(setCycle.Docs) != 1 { |
| 315 | t.Fatalf("want 1 doc, got %d", len(setCycle.Docs)) |
| 316 | } |
| 317 | bodyCycle := setCycle.Docs[0].Body |
| 318 | if !strings.Contains(bodyCycle, "skipped: import cycle") { |
| 319 | t.Errorf("expected import cycle to be detected and reported. Body:\n%s", bodyCycle) |
| 320 | } |
| 321 | } |
| 322 |