From 07c2a63f985ba138d2945288aff792b8c645b74e Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Thu, 13 Aug 2026 11:21:47 +0300 Subject: [PATCH] mcpsrv: list_issues names every clipped table it drew from Data.Truncated/ShownOf only ever meant the issues/dependencies read that decides list_issues' total and truncated, so a clipped labels table took label pills off every card and narrowed the label filter in silence, and a clipped custom_statuses table could put a card in the wrong lane with table_truncated still reading false. list_issues now carries beads.Data.Clipped as clipped: one entry per clipped table in read order, named, with the rows read against the rows that exist and what that specific clip costs this listing. table_truncated and table_total are unchanged; clipped is additive, an empty list on a complete read. --- mcpsrv/beads.go | 49 +++++++++++++++++++++++ mcpsrv/beads_test.go | 94 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 136 insertions(+), 7 deletions(-) diff --git a/mcpsrv/beads.go b/mcpsrv/beads.go index 1728e1c419d223406e30dbd7b89ab46ce49d55c0..81bfebc80ed52100e9f6c7d4c5da7b4984c99ab7 100644 --- a/mcpsrv/beads.go +++ b/mcpsrv/beads.go @@ -177,6 +177,47 @@ type listIssuesOutput struct { // TableTotal is the number of rows in the issues table at this ref, which is // what makes TableTruncated checkable rather than a bare warning. TableTotal int `json:"table_total"` + + // Clipped names every table this listing drew from that came back clipped — + // one entry per table, in read order, each with the rows read against the + // rows that exist and what that specific clip costs this listing. + // + // It exists because TableTruncated cannot say this: that flag is paired with + // Total and Truncated above, so it can only mean the issues/dependencies read + // that decides them. This listing also draws label pills and its whole label + // filter from a labels table, and every card's lane from custom_statuses, and + // a clip in either degrades the listing without moving Total or Truncated by + // one. Clipped is where those are named instead. A complete read carries an + // empty list, never a null one. + Clipped []clippedTableJSON `json:"clipped"` +} + +// clippedTableJSON is one table a listing read that came back clipped: its name, +// how many rows were read against how many exist, and the one line saying what +// this listing lost by the rest. It carries beads.ClippedTable across the wire +// unchanged, under the snake_case vocabulary this surface already uses. +type clippedTableJSON struct { + Table string `json:"table"` + Shown int `json:"shown"` + Total int `json:"total"` + Effect string `json:"effect"` +} + +// clippedTablesJSON carries a projection's per-table clip list into the wire +// shape, table by table. A nil or empty input answers an empty slice rather than +// a null one, so "nothing was clipped" and "the field is unset" are never the +// same JSON value. +func clippedTablesJSON(clipped []beads.ClippedTable) []clippedTableJSON { + out := make([]clippedTableJSON, 0, len(clipped)) + for _, c := range clipped { + out = append(out, clippedTableJSON{ + Table: c.Table, + Shown: c.Shown, + Total: c.Total, + Effect: c.Effect, + }) + } + return out } type getIssueInput struct { @@ -538,6 +579,13 @@ func (s *Server) registerBeadsTools() { "many issues matched before it, and `truncated` says matches were left behind. Separately, " + "`table_truncated` says the tracker has more than 2000 issues and only the first 2000 were " + "read, so the counts describe that prefix — `table_total` is the real number.\n\n" + + "`clipped` names every *other* table this listing drew from that also came back clipped — " + + "labels, custom_statuses — one entry per table with the rows read against the rows that exist " + + "and what that specific clip costs this listing: a clipped labels table takes the pills off " + + "every card and narrows what the `label` filter can match, and a clipped custom_statuses " + + "table can put a card in the wrong lane. `table_truncated`/`table_total` never move for " + + "either — they mean only the issues/dependencies read that decides `total` — so `clipped` is " + + "the only place those two clips are visible. It is an empty list on a complete read.\n\n" + "A database that is not a beads tracker says so and points at the generic tools; a database " + "you may not read is reported as not existing.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in listIssuesInput) (*mcp.CallToolResult, listIssuesOutput, error) { @@ -664,6 +712,7 @@ func (s *Server) listIssues(ctx context.Context, in listIssuesInput) (listIssues Limit: limit, TableTruncated: data.Truncated, TableTotal: data.ShownOf, + Clipped: clippedTablesJSON(data.Clipped), } for _, lane := range data.Lanes { category, ok := laneCategory(lane.Slug) diff --git a/mcpsrv/beads_test.go b/mcpsrv/beads_test.go index 691381f63a04b9631860a21c6100c49425cbc43b..64d438765790763a3bef8d4eb81a8f2a374908f9 100644 --- a/mcpsrv/beads_test.go +++ b/mcpsrv/beads_test.go @@ -231,6 +231,34 @@ func bulkTables(n int) []fakeTable { // is past the first beads.Max rows any projection here reads. func bulkTail(n int) string { return "bulk-" + itoa(n-1) } +// labelsClipCount is the labels table size of labelsClipTables: more than +// beads.Max (2000), so the labels read alone comes back clipped. +const labelsClipCount = 2001 + +// labelsClipTables is a tracker whose issues table stays far under the cap +// while its labels table alone exceeds it — the case list_issues' long-standing +// table_truncated/table_total cannot report at all, because that pair means +// only the issues/dependencies read that decides total and truncated. Here +// neither of those is short, so the two answer "nothing was clipped" while a +// card's pills and the label filter are quietly thinner than the tracker really +// holds; the per-table clip list is the only place that shows up. +func labelsClipTables() []fakeTable { + labels := make([][]string, 0, labelsClipCount) + for i := range labelsClipCount { + labels = append(labels, []string{"lc-1", "label-" + itoa(i)}) + } + return []fakeTable{ + beadsTable("issues", issueColumns, [][]string{ + row(issueColumns, map[string]string{ + "id": "lc-1", "title": "an issue with more labels than fit", "status": "open", + "issue_type": "task", "created_at": "2026-04-01 10:00:00", + }), + }), + beadsTable("dependencies", []string{"issue_id", "depends_on_issue_id", "type"}, nil), + beadsTable("labels", []string{"issue_id", "label"}, labels), + } +} + func itoa(n int) string { if n == 0 { return "0" @@ -287,6 +315,7 @@ func beadsFixtures() []fixture { ), }, {name: "bulk", visibility: core.VisibilityPublic, session: trackerStore(bulkTables(bulkIssues))}, + {name: "labelsclip", visibility: core.VisibilityPublic, session: trackerStore(labelsClipTables())}, {name: "memories", visibility: core.VisibilityPublic, session: memoryStore()}, { // A tracker whose config table holds settings and no memory at all: the @@ -368,13 +397,21 @@ type ( } listIssuesResult struct { - Ref string `json:"ref"` - Issues []issueCardResult `json:"issues"` - Total int `json:"total"` - Limit int `json:"limit"` - Truncated bool `json:"truncated"` - TableTruncated bool `json:"table_truncated"` - TableTotal int `json:"table_total"` + Ref string `json:"ref"` + Issues []issueCardResult `json:"issues"` + Total int `json:"total"` + Limit int `json:"limit"` + Truncated bool `json:"truncated"` + TableTruncated bool `json:"table_truncated"` + TableTotal int `json:"table_total"` + Clipped []clippedTableResult `json:"clipped"` + } + + clippedTableResult struct { + Table string `json:"table"` + Shown int `json:"shown"` + Total int `json:"total"` + Effect string `json:"effect"` } issueResult struct { @@ -755,6 +792,49 @@ func TestListIssuesReportsTheProjectionsOwnClip(t *testing.T) { assert.Equal(t, 2000, got.Total, "the board was computed over the rows that were read") assert.Len(t, got.Issues, 10) assert.True(t, got.Truncated, "the limit clipped the list too, and the two are told apart") + + require.Len(t, got.Clipped, 1, "the issues table is the only one clipped in this fixture") + assert.Equal(t, "issues", got.Clipped[0].Table) + assert.Equal(t, 2000, got.Clipped[0].Shown) + assert.Equal(t, bulkIssues, got.Clipped[0].Total) + assert.NotEmpty(t, got.Clipped[0].Effect, "each entry names what the clip costs this listing") +} + +// table_truncated/table_total have only ever meant the issues/dependencies read +// that decides total and truncated. A clipped labels table moves neither — the +// issues table here is read whole — so it would be invisible without a place of +// its own; clipped is that place, named per table with what was read against +// what exists and what the clip costs this listing. +func TestListIssuesCarriesThePerTableClipList(t *testing.T) { + session := beadsServer(t) + + t.Run("a complete read carries an empty list", func(t *testing.T) { + got := listIssues(t, session, args("board")) + assert.Empty(t, got.Clipped) + + payload := resultJSON(t, call(t, session, "list_issues", args("board"))) + assert.Contains(t, payload, `"clipped":[]`, + "asserted on the serialised payload: an empty slice, never a null field") + }) + + t.Run("a clipped labels table shows up with its effect", func(t *testing.T) { + got := listIssues(t, session, args("labelsclip")) + assert.False(t, got.TableTruncated, "the issues table itself was read whole") + assert.Equal(t, 1, got.TableTotal) + + require.Len(t, got.Clipped, 1) + clip := got.Clipped[0] + assert.Equal(t, "labels", clip.Table) + assert.Equal(t, 2000, clip.Shown, "the projection's own per-table cap") + assert.Equal(t, labelsClipCount, clip.Total) + assert.Contains(t, clip.Effect, "label", + "the effect line names what a reader loses, not just that something was clipped") + + payload := resultJSON(t, call(t, session, "list_issues", args("labelsclip"))) + assert.Contains(t, payload, `"table":"labels"`) + assert.Contains(t, payload, `"shown":2000`) + assert.Contains(t, payload, `"total":`+itoa(labelsClipCount)) + }) } // An omitted ref is the tracker's default branch and the answer names it; a