~bigbes/sr-ht-dolt

ref: 4038ec2b4469113613ecf777e40a097bd8f09da2 sr-ht-dolt/mcpsrv/memories_test.go -rw-r--r-- 16.6 KiB
4038ec2b — Eugene Blikh mcpsrv: answer what is ready across every tracker 5 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
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")
}