Files
2026-07-13 13:00:08 +08:00

238 lines
8.0 KiB
Go

package memory
import (
"os"
"path/filepath"
"strings"
"testing"
fileencoding "reasonix/internal/fileutil/encoding"
)
// TestComposeEmptyIsIdentity is the cache-first invariant: with no memory at
// all, Compose must return the base prompt byte-for-byte, so the cached system
// prefix is exactly what it was before memory existed.
func TestComposeEmptyIsIdentity(t *testing.T) {
base := "You are a helpful coding agent.\nBe concise."
got := Compose(base, &Set{})
if got != base {
t.Fatalf("empty memory changed the prompt:\n base=%q\n got =%q", base, got)
}
// A nil-ish set (no docs, blank index) must also be identity.
if got := Compose(base, &Set{Index: " \n"}); got != base {
t.Fatalf("blank index changed the prompt: got %q", got)
}
}
// TestComposeAppendsAfterBase verifies memory folds in *after* the base prompt,
// so the base stays a valid cache prefix even as memory changes between sessions.
func TestComposeAppendsAfterBase(t *testing.T) {
base := "BASE PROMPT"
set := &Set{Docs: []Source{{Path: "/p/REASONIX.md", Scope: ScopeProject, Body: "Use tabs."}}}
got := Compose(base, set)
if !strings.HasPrefix(got, base) {
t.Fatalf("base is not the prefix of the composed prompt:\n%q", got)
}
if !strings.Contains(got, "Use tabs.") {
t.Fatalf("doc body missing from composed prompt:\n%q", got)
}
}
// TestDiscoverPrecedenceOrder checks user → ancestor → project → local ordering,
// which puts the most specific guidance last.
func TestDiscoverPrecedenceOrder(t *testing.T) {
root := t.TempDir()
user := filepath.Join(root, "userconfig")
proj := filepath.Join(root, "proj")
mustMkdir(t, user)
mustMkdir(t, proj)
// Make proj a git root so discovery stops there.
mustMkdir(t, filepath.Join(proj, ".git"))
mustWrite(t, filepath.Join(user, "REASONIX.md"), "USER LEVEL")
mustWrite(t, filepath.Join(proj, "REASONIX.md"), "PROJECT LEVEL")
mustWrite(t, filepath.Join(proj, "REASONIX.local.md"), "LOCAL LEVEL")
set := Load(Options{CWD: proj, UserDir: user})
if len(set.Docs) != 3 {
t.Fatalf("want 3 docs, got %d: %+v", len(set.Docs), set.Docs)
}
wantScopes := []Scope{ScopeUser, ScopeProject, ScopeLocal}
for i, s := range wantScopes {
if set.Docs[i].Scope != s {
t.Fatalf("doc %d: want scope %q, got %q", i, s, set.Docs[i].Scope)
}
}
// In the composed block, local must appear after project must appear after user.
block := set.Block()
iu, ip, il := strings.Index(block, "USER LEVEL"), strings.Index(block, "PROJECT LEVEL"), strings.Index(block, "LOCAL LEVEL")
if !(iu >= 0 && iu < ip && ip < il) {
t.Fatalf("precedence order wrong in block: user=%d project=%d local=%d\n%s", iu, ip, il, block)
}
}
func TestDiscoverDecodesGB18030PrimaryDoc(t *testing.T) {
proj := t.TempDir()
mustMkdir(t, filepath.Join(proj, ".git"))
body := "# 项目约定\n\n始终使用中文回答。"
if err := os.WriteFile(filepath.Join(proj, "AGENTS.md"), fileencoding.Encode(body, fileencoding.GB18030), 0o644); err != nil {
t.Fatal(err)
}
set := Load(Options{CWD: proj})
if len(set.Docs) != 1 || !strings.Contains(set.Docs[0].Body, "始终使用中文回答") {
t.Fatalf("decoded docs = %+v", set.Docs)
}
}
// TestImportResolution checks "@path" inlining, including a relative import.
func TestImportResolution(t *testing.T) {
proj := t.TempDir()
mustMkdir(t, filepath.Join(proj, ".git"))
mustWrite(t, filepath.Join(proj, "shared.md"), "SHARED CONTENT")
mustWrite(t, filepath.Join(proj, "REASONIX.md"), "Top line\n@shared.md\nBottom line")
set := Load(Options{CWD: proj})
if len(set.Docs) != 1 {
t.Fatalf("want 1 doc, got %d", len(set.Docs))
}
body := set.Docs[0].Body
if !strings.Contains(body, "SHARED CONTENT") {
t.Fatalf("import not inlined: %q", body)
}
if strings.Contains(body, "@shared.md") {
t.Fatalf("import directive left in body: %q", body)
}
}
func TestImportResolutionRejectsEscapes(t *testing.T) {
proj := t.TempDir()
mustMkdir(t, filepath.Join(proj, ".git"))
outside := t.TempDir()
mustWrite(t, filepath.Join(outside, "secret.md"), "SECRET")
mustWrite(t, filepath.Join(proj, "REASONIX.md"), "Top\n@/abs/path.md\n@~/secret.md\n@../secret.md\nBottom")
set := Load(Options{CWD: proj})
if len(set.Docs) != 1 {
t.Fatalf("want 1 doc, got %d", len(set.Docs))
}
body := set.Docs[0].Body
if strings.Contains(body, "SECRET") {
t.Fatalf("unsafe import was inlined: %q", body)
}
for _, directive := range []string{"@/abs/path.md", "@~/secret.md", "@../secret.md"} {
if !strings.Contains(body, directive) {
t.Fatalf("unsafe directive %q should be left visible, body: %q", directive, body)
}
}
}
func TestImportResolutionRejectsSymlinkEscape(t *testing.T) {
proj := t.TempDir()
mustMkdir(t, filepath.Join(proj, ".git"))
outside := t.TempDir()
mustWrite(t, filepath.Join(outside, "secret.md"), "SECRET")
if err := os.Symlink(filepath.Join(outside, "secret.md"), filepath.Join(proj, "linked.md")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
mustWrite(t, filepath.Join(proj, "REASONIX.md"), "Top\n@linked.md\nBottom")
set := Load(Options{CWD: proj})
if len(set.Docs) != 1 {
t.Fatalf("want 1 doc, got %d", len(set.Docs))
}
body := set.Docs[0].Body
if strings.Contains(body, "SECRET") || !strings.Contains(body, "@linked.md") {
t.Fatalf("symlink escape should not be inlined, body: %q", body)
}
}
// TestImportCycleDoesNotHang verifies cycle detection terminates.
func TestImportCycleDoesNotHang(t *testing.T) {
proj := t.TempDir()
mustMkdir(t, filepath.Join(proj, ".git"))
mustWrite(t, filepath.Join(proj, "a.md"), "A\n@b.md")
mustWrite(t, filepath.Join(proj, "b.md"), "B\n@a.md")
mustWrite(t, filepath.Join(proj, "REASONIX.md"), "@a.md")
set := Load(Options{CWD: proj}) // must return, not loop forever
body := set.Docs[0].Body
if !strings.Contains(body, "A") || !strings.Contains(body, "B") {
t.Fatalf("cycle import dropped content: %q", body)
}
}
// TestImportTargetClassification guards the "@mention vs @import" heuristic.
func TestImportTargetClassification(t *testing.T) {
cases := []struct {
line string
want bool
}{
{"@docs/setup.md", true},
{"@./notes.txt", true},
{"@/abs/path.md", true},
{"@mention", false}, // prose-y, no separator/dot
{"@", false}, // bare
{"@a/b and more", false}, // not the only token
{"plain text", false},
}
for _, c := range cases {
if _, got := importTarget(c.line); got != c.want {
t.Errorf("importTarget(%q) = %v, want %v", c.line, got, c.want)
}
}
}
func mustMkdir(t *testing.T, dir string) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
func mustWrite(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func TestImportDiamondAndCycle(t *testing.T) {
proj := t.TempDir()
mustMkdir(t, filepath.Join(proj, ".git"))
mustWrite(t, filepath.Join(proj, "shared.md"), "SHARED CONTENT")
mustWrite(t, filepath.Join(proj, "a.md"), "A\n@shared.md")
mustWrite(t, filepath.Join(proj, "b.md"), "B\n@shared.md")
mustWrite(t, filepath.Join(proj, "REASONIX.md"), "@a.md\n@b.md")
set := Load(Options{CWD: proj})
if len(set.Docs) != 1 {
t.Fatalf("want 1 doc, got %d", len(set.Docs))
}
body := set.Docs[0].Body
count := strings.Count(body, "SHARED CONTENT")
if count != 2 {
t.Errorf("expected 'SHARED CONTENT' to appear twice, got %d times. Body:\n%s", count, body)
}
if strings.Contains(body, "skipped: import cycle") {
t.Errorf("body contains incorrect import cycle message:\n%s", body)
}
projCycle := t.TempDir()
mustMkdir(t, filepath.Join(projCycle, ".git"))
mustWrite(t, filepath.Join(projCycle, "cycle1.md"), "CYCLE1\n@cycle2.md")
mustWrite(t, filepath.Join(projCycle, "cycle2.md"), "CYCLE2\n@cycle1.md")
mustWrite(t, filepath.Join(projCycle, "REASONIX.md"), "@cycle1.md")
setCycle := Load(Options{CWD: projCycle})
if len(setCycle.Docs) != 1 {
t.Fatalf("want 1 doc, got %d", len(setCycle.Docs))
}
bodyCycle := setCycle.Docs[0].Body
if !strings.Contains(bodyCycle, "skipped: import cycle") {
t.Errorf("expected import cycle to be detected and reported. Body:\n%s", bodyCycle)
}
}