mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-10-12 04:54:27 +00:00
fix(search): emit pagination cursor on first truncated page
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.
This commit is contained in:
@@ -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) {
|
func TestFileSearchPaginationCursor(t *testing.T) {
|
||||||
srv, tmpDir := newFeaturesServer(t)
|
srv, tmpDir := newFeaturesServer(t)
|
||||||
if srv.searcher == nil {
|
if srv.searcher == nil {
|
||||||
|
|||||||
@@ -127,13 +127,23 @@ func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolReque
|
|||||||
|
|
||||||
// Apply in-process max_results cap and compute cursor footer.
|
// Apply in-process max_results cap and compute cursor footer.
|
||||||
var cursorLine string
|
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
|
remaining := len(results.Results) - maxResults
|
||||||
results.Results = results.Results[:maxResults]
|
results.Results = results.Results[:maxResults]
|
||||||
results.Truncated = true
|
results.Truncated = true
|
||||||
nextOffset := offset + maxResults
|
nextOffset := offset + maxResults
|
||||||
nextCursor := cursor.Encode(nextOffset, queryHash)
|
nextCursor := cursor.Encode(nextOffset, queryHash)
|
||||||
cursorLine = fmt.Sprintf("[cursor: %s, remaining: %d]", nextCursor, remaining)
|
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",
|
s.logger.Info("search completed",
|
||||||
|
|||||||
Reference in New Issue
Block a user