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") }