From 2fb9d3e37960c10d429f637448070b8c0b4015b2 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sat, 20 Jun 2026 00:44:41 +0100 Subject: [PATCH] fix(search): emit pagination cursor on first truncated page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the first page (offset==0) ripgrep capped results at exactly max_results and set Truncated=true, but the cursor-footer guard (len>max_results) was false, so the response said "(truncated...)" with no cursor — the caller was told more matches exist but given no way to fetch them. Pagination only worked from page 2. Emit a cursor on the first page using rg's total match count for the remaining estimate. Adds a strict regression test (asserts, not skips); the existing pagination test was silently skipping its round-trip because page 1 never had a cursor. --- internal/server/features_test.go | 33 ++++++++++++++++++++++++++++++++ internal/server/handlers_file.go | 12 +++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/internal/server/features_test.go b/internal/server/features_test.go index d1c35e3..5c9b07a 100644 --- a/internal/server/features_test.go +++ b/internal/server/features_test.go @@ -292,6 +292,39 @@ func TestFileSearchCursorStaleHash(t *testing.T) { } } +// TestFileSearchFirstPageCursor is a strict regression guard: when the first +// page is capped by max_results but more matches exist, the response MUST carry +// a cursor so the caller can page. Previously offset==0 emitted "(truncated...)" +// with no cursor, silently breaking pagination on the common path. +func TestFileSearchFirstPageCursor(t *testing.T) { + srv, tmpDir := newFeaturesServer(t) + if srv.searcher == nil { + t.Skip("rg not available") + } + ctx := context.Background() + + // Corpus has 5 lines containing "func"; cap at 1 forces truncation on page 1. + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "pattern": "func", + "paths": []interface{}{tmpDir}, + "max_results": float64(1), + "context_lines": float64(0), + } + res, err := srv.handleFileSearch(ctx, req) + if err != nil { + t.Fatalf("file_search error: %v", err) + } + text := res.Content[0].(mcp.TextContent).Text + if !strings.Contains(text, "[cursor:") { + t.Errorf("first page truncated at max_results=1 with 5 matches must emit a cursor, got:\n%s", text) + } + // The dead-end prose must not be used when a cursor is available. + if strings.Contains(text, "(truncated, showing subset") { + t.Errorf("dead-end truncation prose should be replaced by a cursor footer, got:\n%s", text) + } +} + func TestFileSearchPaginationCursor(t *testing.T) { srv, tmpDir := newFeaturesServer(t) if srv.searcher == nil { diff --git a/internal/server/handlers_file.go b/internal/server/handlers_file.go index 4d18952..ed2a4e1 100644 --- a/internal/server/handlers_file.go +++ b/internal/server/handlers_file.go @@ -127,13 +127,23 @@ func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolReque // Apply in-process max_results cap and compute cursor footer. var cursorLine string - if maxResults > 0 && len(results.Results) > maxResults { + switch { + case maxResults > 0 && len(results.Results) > maxResults: + // Cursor pages (offset>0) fetch all results, so cap here and emit a cursor. remaining := len(results.Results) - maxResults results.Results = results.Results[:maxResults] results.Truncated = true nextOffset := offset + maxResults nextCursor := cursor.Encode(nextOffset, queryHash) cursorLine = fmt.Sprintf("[cursor: %s, remaining: %d]", nextCursor, remaining) + case offset == 0 && maxResults > 0 && results.Truncated: + // First page: ripgrep capped us at exactly maxResults in parseOutput, but + // more matches exist. Emit a cursor so the caller can page instead of being + // told "truncated" with no way to continue. remaining derives from rg's + // total match count (a submatch count — an upper-bound proxy for rows). + nextCursor := cursor.Encode(maxResults, queryHash) + remaining := max(results.Total-maxResults, 1) + cursorLine = fmt.Sprintf("[cursor: %s, remaining: %d]", nextCursor, remaining) } s.logger.Info("search completed",