From f486105836fdbbc69163123ec9d6e8edfbf225d2 Mon Sep 17 00:00:00 2001 From: Dennis Date: Fri, 14 Aug 2026 16:39:46 +0800 Subject: [PATCH] fix: bound empty aitable pagination --- internal/shortcut/aitable/record_query_all.go | 17 +++++++++++++++- .../aitable/record_upsert_by_key_e2e_test.go | 20 +++++++++++++++++++ .../minutesdata/coverage_completion_test.go | 1 + 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/internal/shortcut/aitable/record_query_all.go b/internal/shortcut/aitable/record_query_all.go index fa2c2368..bdaffb41 100644 --- a/internal/shortcut/aitable/record_query_all.go +++ b/internal/shortcut/aitable/record_query_all.go @@ -15,7 +15,10 @@ import ( // drop the envelope fields used by the record projector, turning a successful // read into an empty collection. Keep every remote request on one service page // and aggregate only after each page has passed the CLI's shape validation. -const recordQueryServicePageSize = 20 +const ( + recordQueryServicePageSize = 20 + recordQueryMaxConsecutiveEmptyPages = 3 +) type recordQueryWindow struct { Records []map[string]any @@ -36,6 +39,7 @@ func queryRecordWindow(rt *shortcut.RuntimeContext, params map[string]any, limit seen[cursor] = true } window := recordQueryWindow{Records: make([]map[string]any, 0, limit)} + consecutiveEmptyPages := 0 for len(window.Records) < limit { pageSize := minInt(recordQueryServicePageSize, limit-len(window.Records)) @@ -67,6 +71,17 @@ func queryRecordWindow(rt *shortcut.RuntimeContext, params map[string]any, limit window.NextCursor = "" return window, nil } + if len(records) == 0 { + consecutiveEmptyPages++ + if consecutiveEmptyPages >= recordQueryMaxConsecutiveEmptyPages { + return recordQueryWindow{}, fmt.Errorf( + "query_records made no progress for %d consecutive pages", + consecutiveEmptyPages, + ) + } + } else { + consecutiveEmptyPages = 0 + } next := responseCursor(data) if next == "" { return recordQueryWindow{}, fmt.Errorf("query_records page %d reports more data but no next cursor", window.Pages) diff --git a/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go b/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go index 3a639b6e..d468901a 100644 --- a/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go +++ b/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go @@ -404,6 +404,26 @@ func TestCrossPlatformCoverageRecordQueryFailureAndContinuationBranches(t *testi } }) + t.Run("changing cursors cannot hide sustained empty pages", func(t *testing.T) { + caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, _ map[string]any) (string, error) { + if tool != "query_records" { + return "", fmt.Errorf("unexpected tool %s", tool) + } + return fmt.Sprintf( + `{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"empty-%d"}}`, + call, + ), nil + }} + helpers.InitDepsForTest(t, caller) + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery) + if _, err := queryRecordWindow(rt, map[string]any{}, 1); err == nil || !strings.Contains(err.Error(), "no progress") { + t.Fatalf("sustained empty pages error = %v", err) + } + if len(caller.calls) != recordQueryMaxConsecutiveEmptyPages { + t.Fatalf("query calls = %d, want %d", len(caller.calls), recordQueryMaxConsecutiveEmptyPages) + } + }) + t.Run("invalid shortcut limit", func(t *testing.T) { caller := &upsertByKeyCaller{} helpers.InitDepsForTest(t, caller) diff --git a/internal/shortcut/minutesdata/coverage_completion_test.go b/internal/shortcut/minutesdata/coverage_completion_test.go index aa2e3cb4..b0bbcfbc 100644 --- a/internal/shortcut/minutesdata/coverage_completion_test.go +++ b/internal/shortcut/minutesdata/coverage_completion_test.go @@ -232,6 +232,7 @@ func TestCrossPlatformCoverageMinutesWorkflowCompletion(t *testing.T) { } for _, data := range []map[string]any{ {"success": false, "errorMsg": "denied"}, + {"success": "yes"}, {"success": true, "result": "bad"}, {"success": true, "result": map[string]any{}}, {"success": true, "result": map[string]any{"resultMap": map[string]any{}}},