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 @@ // 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 @@ "`limit` defaults to 200, is capped at 500 and is applied after filtering; `total` is how " + "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 @@ Issues: []issueCardJSON{}, 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 @@ // bulkTail is the id of the last issue of a bulk tracker of n: it exists, and it // 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 @@ map[string]string{memoryKeyPrefix + "escrow": "SECRETROADMAP: the release key lives in the vault"}, ), }, {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 @@ Category string `json:"category"` } 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 @@ assert.Equal(t, bulkIssues, got.TableTotal, "and the true row count is reported beside it") 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