package mcpsrv_test
import (
"fmt"
"sort"
"testing"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sourcecraft.dev/bigbes/sr-ht-dolt/beads"
"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
)
// list_memories, the last beads-aware tool of docs/DESIGN.mcp.md §9.2: what
// `bd remember` wrote into a tracker's config table, each entry dated from the
// history by the revision walk of docs/DESIGN.views.md §2.1.
//
// The properties worth more than the rest, and each has a test below: a memory
// the walk could date and one it could not are told apart in the payload rather
// than blurred into a zero value; the staleness flag is published with the age
// and the threshold it was computed from; a tracker with no memories — with or
// without a config table — is an empty answer and not an error; and everything
// the chapter holds for every beads tool (the visibility matrix, the non-tracker
// refusal, the closed session) holds for this one too, which is why it is in
// beadsTools().
// The revision walk reads through a seam of its own — wider than the board's,
// because a date that is not in the row has to come out of the history — and the
// fake is held to it here rather than discovered to satisfy it at run time. The
// production side of the same fact is that mcpsrv.BrowseSession covers
// beads.MemorySession, which is why the tool hands its session straight over.
var _ beads.MemorySession = (*fakeSession)(nil)
// The two names bd's memory layout is made of. beads/ owns them; these are the
// fixture's copies, and a projection that disagreed with them would answer
// nothing at all here.
const (
memoryConfigTable = "config"
memoryKeyPrefix = "kv.memory."
)
// memoryWalkMax is the bound beads puts on the revision walk. It is unexported
// there, so this is the number the fixture below is built to exceed — and the
// answer's own walk_max is asserted against it, so a change in beads turns this
// suite red instead of quietly retiring the truncation case.
const memoryWalkMax = 500
// The memory texts of the fixture, spelled out so that the ?q= cases below can
// each name the one thing they match on: a slug that is not in any text
// ("ancient"), a word that is only in a text ("metered"), and a word that is in
// both ("calibrat").
const (
handoffText = "State after this round: the parser is landed and the CLI is next."
calibrationText = "The owner is often on a metered link; calibrate downloads before pulling."
ancientText = "The first note anyone left in this tracker."
)
// memoryStore is the tracker the revision walk is asserted against: three
// memories whose dates the walk reaches in three different ways, over a history
// longer than the walk's own budget.
//
// m-head (1 hour ago) handoff=v2 calibration=v2 ancient=x hash h-3 ← head
// m-fresh (3 hours ago) handoff=v2 calibration=v2 ancient=x hash h-3 wrote handoff
// m-old (119 days ago) handoff=v1 calibration=v2 ancient=x hash h-2
// m-stale (120 days ago) handoff=v1 calibration=v2 ancient=x hash h-2 wrote calibration
// m-000 … (121+ days ago) handoff=v1 calibration=v1 ancient=x hash h-1
//
// So handoff resolves to a commit three hours old, calibration to one 120 days
// old — past the staleness threshold — and ancient, whose value never changes
// anywhere the walk can see, resolves to nothing at all: the tail is one commit
// longer than the walk examines, so the history is still going when the budget
// runs out and no date the walk could support exists.
//
// The dates are relative to now rather than fixed, because staleness is measured
// against the server's clock: a fixture pinned to a calendar date would start
// answering differently as that date recedes.
func memoryStore() *fakeSession {
now := time.Now()
at := func(d time.Duration) time.Time { return now.Add(-d) }
days := func(n int) time.Duration { return time.Duration(n) * 24 * time.Hour }
newest := map[string]string{
memoryKeyPrefix + "handoff": handoffText,
memoryKeyPrefix + "calibration": calibrationText,
memoryKeyPrefix + "ancient": ancientText,
"issue_prefix": "mem", // not a memory: the prefix filter drops it
}
middle := map[string]string{
memoryKeyPrefix + "handoff": "the handoff of the round before",
memoryKeyPrefix + "calibration": calibrationText,
memoryKeyPrefix + "ancient": ancientText,
"issue_prefix": "mem",
}
oldest := map[string]string{
memoryKeyPrefix + "handoff": "the handoff of the round before",
memoryKeyPrefix + "calibration": "an earlier reading of the link",
memoryKeyPrefix + "ancient": ancientText,
"issue_prefix": "mem",
}
// One commit of the fixture history: its hash and date, the config table's
// contents there, and that table's content hash — which is what the walk
// compares to decide whether the commit above it wrote anything at all.
type step struct {
hash string
date time.Time
config map[string]string
tableHash string
}
steps := []step{
{"m-head", at(time.Hour), newest, "h-3"},
{"m-fresh", at(3 * time.Hour), newest, "h-3"},
{"m-old", at(days(119)), middle, "h-2"},
{"m-stale", at(days(120)), middle, "h-2"},
}
// One commit more than the walk examines, none of which touched config: the
// budget runs out with the history still going, which is the only way a memory
// can carry no revision at all.
for i := 0; i <= memoryWalkMax; i++ {
steps = append(steps, step{fmt.Sprintf("m-%03d", i), at(days(121 + i)), oldest, "h-1"})
}
sess := &fakeSession{
branches: []browse.Branch{{Name: "main", Head: steps[0].hash}},
tables: plainTables("mem", "a tracker that remembers"),
configAt: map[string]map[string]string{"main": newest},
configHash: map[string]string{"main": steps[0].tableHash},
}
for _, s := range steps {
sess.commits = append(sess.commits, browse.CommitInfo{Hash: s.hash, Author: "bigbes", Date: s.date})
sess.configAt[s.hash] = s.config
sess.configHash[s.hash] = s.tableHash
}
sess.tables = append(sess.tables, configTable(newest))
return sess
}
// configTable is a config table as browse reports it, built from the key/value
// map a fixture declares — so the schema a tool fingerprints on and the rows it
// then reads cannot disagree.
func configTable(config map[string]string) fakeTable {
keys := make([]string, 0, len(config))
for k := range config {
keys = append(keys, k)
}
sort.Strings(keys) // a store returns rows in key order; so does this
rows := make([][]string, 0, len(keys))
for _, k := range keys {
rows = append(rows, []string{k, config[k]})
}
return fakeTable{
name: memoryConfigTable,
cols: []browse.ColumnInfo{
{Name: "key", Type: "text", PrimaryKey: true},
{Name: "value", Type: "text", Nullable: true},
},
rows: rows,
}
}
// withMemories gives a fixture store a config table with the same contents at
// every ref it has, and one content hash for all of them — a history in which
// nothing ever changed, so the walk reaches the root commit and attributes every
// memory to it. That is the cheap fixture, for the tests that are about who may
// read a memory rather than about when it was written.
func withMemories(sess *fakeSession, config map[string]string) *fakeSession {
withConfig(sess, config)
sess.configHash = map[string]string{}
for ref := range sess.configAt {
sess.configHash[ref] = "h-unchanging"
}
return sess
}
// withConfig gives a fixture store a config table and declares no table hashes
// for it, which is deliberate: a store whose config carries no memory has
// nothing to date, so a walk that ran over it at all would be work nobody asked
// for — and TableHash's panic is what says so, loudly, instead of the walk
// quietly succeeding over a fixture that never described one.
func withConfig(sess *fakeSession, config map[string]string) *fakeSession {
sess.configAt = map[string]map[string]string{}
for _, b := range sess.branches {
sess.configAt[b.Name] = config
}
for _, c := range sess.commits {
sess.configAt[c.Hash] = config
}
sess.tables = append(sess.tables, configTable(config))
return sess
}
// --- the shapes a client decodes ---------------------------------------------
type (
memoryRevisionResult struct {
Commit string `json:"commit"`
Date time.Time `json:"date"`
Author string `json:"author"`
}
memoryResult struct {
Slug string `json:"slug"`
Text string `json:"text"`
Revision *memoryRevisionResult `json:"revision"`
AgeDays *int `json:"age_days"`
Stale bool `json:"stale"`
}
listMemoriesResult struct {
Ref string `json:"ref"`
Memories []memoryResult `json:"memories"`
Total int `json:"total"`
WalkTruncated bool `json:"walk_truncated"`
WalkMax int `json:"walk_max"`
StaleAfterDays int `json:"stale_after_days"`
}
)
func listMemories(t *testing.T, s *mcp.ClientSession, a map[string]any) listMemoriesResult {
t.Helper()
var out listMemoriesResult
decode(t, call(t, s, "list_memories", a), &out)
return out
}
func memorySlugs(res listMemoriesResult) []string {
out := make([]string, 0, len(res.Memories))
for _, m := range res.Memories {
out = append(out, m.Slug)
}
return out
}
func memoriesBySlug(res listMemoriesResult) map[string]memoryResult {
out := map[string]memoryResult{}
for _, m := range res.Memories {
out[m.Slug] = m
}
return out
}
// --- what list_memories answers ----------------------------------------------
// The whole answer over the fixture history: the slug and text of every memory,
// the revision walk's three outcomes, and the two numbers that make the
// staleness flag checkable rather than merely believable.
func TestListMemoriesAnswersWhatTheTrackerRemembers(t *testing.T) {
got := listMemories(t, beadsServer(t), args("memories"))
assert.Equal(t, "main", got.Ref, "the default branch, named back")
assert.Equal(t, 3, got.Total, "issue_prefix is a setting and not a memory")
assert.Equal(t, []string{"ancient", "calibration", "handoff"}, memorySlugs(got), "ordered by slug")
assert.Equal(t, memoryWalkMax, got.WalkMax)
assert.Equal(t, 60, got.StaleAfterDays, "beads.MemoryStaleAfter, in the unit age_days is in")
bySlug := memoriesBySlug(got)
// Written three hours ago: the walk names the commit that changed the value,
// not the head that merely carries it.
handoff := bySlug["handoff"]
assert.Equal(t, handoffText, handoff.Text)
require.NotNil(t, handoff.Revision)
assert.Equal(t, "m-fresh", handoff.Revision.Commit)
assert.Equal(t, "bigbes", handoff.Revision.Author)
require.NotNil(t, handoff.AgeDays)
assert.Equal(t, 0, *handoff.AgeDays)
assert.False(t, handoff.Stale)
// Written 120 days ago: past the threshold, and the age says by how much.
calibration := bySlug["calibration"]
assert.Equal(t, calibrationText, calibration.Text)
require.NotNil(t, calibration.Revision)
assert.Equal(t, "m-stale", calibration.Revision.Commit)
require.NotNil(t, calibration.AgeDays)
assert.Equal(t, 120, *calibration.AgeDays)
assert.True(t, calibration.Stale, "120 days is past the 60 the answer publishes")
assert.Greater(t, *calibration.AgeDays, got.StaleAfterDays,
"the flag is derivable from the two numbers beside it")
// Never written inside the walk's budget: no revision, no age — and still
// stale, because the oldest commit the walk examined is itself past the
// threshold, which is a floor on the memory's age rather than a date.
ancient := bySlug["ancient"]
assert.Equal(t, ancientText, ancient.Text)
assert.Nil(t, ancient.Revision, "no date the walk cannot support")
assert.Nil(t, ancient.AgeDays, "and no age computed from one")
assert.True(t, ancient.Stale)
assert.True(t, got.WalkTruncated, "which is the only way a memory here carries no revision")
}
// The walk's honesty has to survive serialisation: a memory it could not date
// carries an explicit null, never a zero-valued revision an agent would read as
// "written by commit \"\" at the zero time". This is the one assertion made over
// the bytes rather than over the decoded struct, because it is about what the
// wire says and not about what Go's zero values look like.
func TestListMemoriesSpellsAnUnresolvedRevisionAsNull(t *testing.T) {
payload := resultJSON(t, call(t, beadsServer(t), "list_memories", args("memories")))
assert.Contains(t, payload, `"revision":null`, "the memory the walk could not reach")
assert.Contains(t, payload, `"age_days":null`, "and no age invented beside it")
assert.NotContains(t, payload, `"commit":""`, "never a zero-valued revision")
assert.NotContains(t, payload, `"0001-01-01T00:00:00Z"`, "and never the zero time")
assert.Contains(t, payload, `"walk_truncated":true`, "the reason it is null, in the same answer")
assert.Contains(t, payload, `"m-fresh"`, "beside a revision that did resolve")
}
// ?q= is the projection's own substring rule over slug and text, handed over
// rather than re-applied here — these are the cases that would catch a second
// implementation drifting from it.
func TestListMemoriesFiltersOnSlugAndText(t *testing.T) {
session := beadsServer(t)
for _, tc := range []struct {
name string
q string
want []string
}{
{"a slug that is in no text", "ancient", []string{"ancient"}},
{"a word that is only in a text", "metered", []string{"calibration"}},
{"a word in both a slug and a text", "calibrat", []string{"calibration"}},
{"case-insensitively", "METERED", []string{"calibration"}},
{"a substring several memories share", "the", []string{"ancient", "calibration", "handoff"}},
{"a query nothing matches", "zzz", nil},
} {
t.Run(tc.name, func(t *testing.T) {
got := listMemories(t, session, args("memories", "q", tc.q))
assert.Equal(t, tc.want, emptyToNil(memorySlugs(got)))
assert.Equal(t, 3, got.Total,
"total is what the tracker holds, so an empty search is distinguishable from an empty tracker")
})
}
}
func emptyToNil(s []string) []string {
if len(s) == 0 {
return nil
}
return s
}
// A tracker with nothing to remember is an empty answer and not an error, in
// both of the two shapes that reach it: a config table holding only the
// tracker's settings, and no config table at all. Neither costs a revision walk
// — the fixtures declare no table hashes, so one that ran would panic.
func TestListMemoriesOnATrackerWithNothingToRemember(t *testing.T) {
repos, opener := beadsFakes()
session := connect(t, newServer(t, repos, opener), nil)
for _, tc := range []struct{ name, db string }{
{"a config table with no memory in it", "settings"},
{"no config table at all", "backlog"},
} {
t.Run(tc.name, func(t *testing.T) {
res := call(t, session, "list_memories", args(tc.db))
require.False(t, res.IsError, "%s", errorText(res))
var got listMemoriesResult
decode(t, res, &got)
assert.Empty(t, got.Memories)
assert.Equal(t, 0, got.Total)
assert.False(t, got.WalkTruncated, "there was nothing to walk for")
})
}
}
// A grantee reads a private tracker's memories whole: the matrix proves the tool
// answers, and this proves it answers with the tracker's actual contents.
func TestAGranteeReadsAPrivateTrackersMemories(t *testing.T) {
repos, opener := beadsFakes()
got := listMemories(t, connect(t, newServer(t, repos, opener), bob()), args("roadmap"))
require.Len(t, got.Memories, 1)
assert.Equal(t, "escrow", got.Memories[0].Slug)
assert.Contains(t, got.Memories[0].Text, "SECRETROADMAP")
// Nothing in this history ever changed the config table, so the walk runs off
// the end of it — and a history that *ended* is a date the walk supports: the
// root commit is what wrote the value.
require.NotNil(t, got.Memories[0].Revision)
assert.False(t, got.WalkTruncated)
}
// The memory text is normalised by the projection and not re-normalised here:
// the same tracker holds values typed into a shell string, with literal "\n"
// escapes, and values written from a file with real newlines.
func TestListMemoriesNormalisesTheStoredText(t *testing.T) {
repos, opener := beadsFakes()
opener.sessions[storePath("alice", "memories")] = withMemories(
&fakeSession{
branches: []browse.Branch{{Name: "main", Head: "only"}},
commits: []browse.CommitInfo{{Hash: "only", Author: "bigbes", Date: time.Now()}},
tables: plainTables("mem", "a tracker that remembers"),
},
map[string]string{memoryKeyPrefix + "typed": `first line\nsecond line`},
)
got := listMemories(t, connect(t, newServer(t, repos, opener), nil), args("memories"))
require.Len(t, got.Memories, 1)
assert.Equal(t, "first line\nsecond line", got.Memories[0].Text,
"the escape a shell string carries is a newline, and beads is what decides that")
}