From 2db73a818565845fef0e5deedb5dd997fabcc7ac Mon Sep 17 00:00:00 2001 From: Dennis Date: Fri, 14 Aug 2026 10:55:00 +0800 Subject: [PATCH] fix(doc): distinguish identical pagination pages --- internal/helpers/doc.go | 51 +++++++++++++++++-- internal/helpers/doc_command_edges_test.go | 22 ++++++++ internal/shortcut/doc/content_shortcuts.go | 38 ++++++++++++-- .../doc/doc_readback_verification_test.go | 24 +++++++++ 4 files changed, 125 insertions(+), 10 deletions(-) diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go index 815a432d..ecad5e5a 100644 --- a/internal/helpers/doc.go +++ b/internal/helpers/doc.go @@ -755,7 +755,7 @@ func readAllDocBlocksForVerification(ctx context.Context, nodeID string) ([]any, const pageSize = 50 const maxItems = 5000 all := make([]any, 0, pageSize) - seen := map[string]bool{} + seenPageIdentities := map[string]bool{} for start := 0; start < maxItems; start += pageSize { text, err := callMCPToolReturnTextOnServer(ctx, "doc", "list_document_blocks", map[string]any{ "nodeId": nodeID, "format": "jsonml", "startIndex": start, "endIndex": start + pageSize - 1, @@ -772,12 +772,13 @@ func readAllDocBlocksForVerification(ctx context.Context, nodeID string) ([]any, if !ok { return nil, fmt.Errorf("list_document_blocks 回读缺少 blocks 数组") } - encoded, _ := json.Marshal(blocks) - key := string(encoded) - if key != "[]" && seen[key] { + pageIdentity := docBlockPageIdentity(blocks) + if pageIdentity != "" && seenPageIdentities[pageIdentity] { return nil, fmt.Errorf("list_document_blocks 分页停滞") } - seen[key] = true + if pageIdentity != "" { + seenPageIdentities[pageIdentity] = true + } all = append(all, blocks...) hasMore, hasMoreKnown := payload["hasMore"].(bool) if hasMoreKnown && !hasMore { @@ -796,6 +797,46 @@ func readAllDocBlocksForVerification(ctx context.Context, nodeID string) ([]any, return nil, fmt.Errorf("文档块超过安全回读上限") } +func docBlockPageIdentity(blocks []any) string { + if len(blocks) == 0 { + return "" + } + ids := make([]string, 0, len(blocks)) + for _, value := range blocks { + id := "" + switch block := value.(type) { + case map[string]any: + id = directDocBlockIdentity(block) + if id == "" { + if element, ok := block["element"].(map[string]any); ok { + id = directDocBlockIdentity(element) + } + } + case []any: + if len(block) > 1 { + if attributes, ok := block[1].(map[string]any); ok { + id = directDocBlockIdentity(attributes) + } + } + } + if id == "" { + return "" + } + ids = append(ids, id) + } + encoded, _ := json.Marshal(ids) + return string(encoded) +} + +func directDocBlockIdentity(block map[string]any) string { + for _, key := range []string{"blockId", "id", "uuid", "elementId"} { + if text, ok := block[key].(string); ok && strings.TrimSpace(text) != "" { + return strings.TrimSpace(text) + } + } + return "" +} + func nestedDocMap(data map[string]any) map[string]any { for _, key := range []string{"result", "data"} { if nested, ok := data[key].(map[string]any); ok { diff --git a/internal/helpers/doc_command_edges_test.go b/internal/helpers/doc_command_edges_test.go index ef894c38..bfd7aec2 100644 --- a/internal/helpers/doc_command_edges_test.go +++ b/internal/helpers/doc_command_edges_test.go @@ -3,6 +3,7 @@ package helpers import ( "bytes" "context" + "encoding/json" "errors" "fmt" "io" @@ -318,6 +319,27 @@ func TestCrossPlatformCoverageDocMediaReadbackDefensiveEdges(t *testing.T) { } }) + t.Run("identical adjacent pages advance by requested indexes", func(t *testing.T) { + blocks := make([]any, 50) + for index := range blocks { + blocks[index] = map[string]any{"blockType": "paragraph"} + } + first, err := json.Marshal(map[string]any{"blocks": blocks, "hasMore": true, "totalCount": 100}) + if err != nil { + t.Fatal(err) + } + second, err := json.Marshal(map[string]any{"blocks": blocks, "hasMore": false, "totalCount": 100}) + if err != nil { + t.Fatal(err) + } + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: string(first)}, {text: string(second)}}} + installScriptedCaller(t, caller) + got, err := readAllDocBlocksForVerification(ctx, "node") + if err != nil || len(got) != 100 || caller.calls != 2 { + t.Fatalf("blocks=%d calls=%d err=%v, want 100 blocks from two pages", len(got), caller.calls, err) + } + }) + t.Run("unknown pagination short page", func(t *testing.T) { installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"id":"a"}]}`}}}) if blocks, err := readAllDocBlocksForVerification(ctx, "node"); err != nil || len(blocks) != 1 { diff --git a/internal/shortcut/doc/content_shortcuts.go b/internal/shortcut/doc/content_shortcuts.go index 1232088e..74ae46bd 100644 --- a/internal/shortcut/doc/content_shortcuts.go +++ b/internal/shortcut/doc/content_shortcuts.go @@ -808,7 +808,7 @@ func readDocVerification(rt *shortcut.RuntimeContext, tool string, rawParams map func readAllDocumentBlocks(rt *shortcut.RuntimeContext, base map[string]any) (map[string]any, error) { all := make([]any, 0, docBlockReadPageSize) - seenPages := map[string]bool{} + seenPageIdentities := map[string]bool{} for start := 0; start < docBlockReadMaxItems; start += docBlockReadPageSize { params := cloneMap(base) params["startIndex"] = start @@ -821,12 +821,13 @@ func readAllDocumentBlocks(rt *shortcut.RuntimeContext, base map[string]any) (ma if !ok { return nil, fmt.Errorf("list_document_blocks 回读缺少 blocks 数组") } - encoded, _ := json.Marshal(blocks) - pageKey := string(encoded) - if pageKey != "[]" && seenPages[pageKey] { + pageIdentity := documentBlockPageIdentity(blocks) + if pageIdentity != "" && seenPageIdentities[pageIdentity] { return nil, fmt.Errorf("list_document_blocks 分页停滞,无法证明回读完整") } - seenPages[pageKey] = true + if pageIdentity != "" { + seenPageIdentities[pageIdentity] = true + } all = append(all, blocks...) hasMore, known, _ := docPageState(page) if known && !hasMore { @@ -845,6 +846,33 @@ func readAllDocumentBlocks(rt *shortcut.RuntimeContext, base map[string]any) (ma return nil, fmt.Errorf("list_document_blocks 超过 %d 个块,无法在安全上限内完成回读", docBlockReadMaxItems) } +func documentBlockPageIdentity(blocks []any) string { + if len(blocks) == 0 { + return "" + } + ids := make([]string, 0, len(blocks)) + for _, value := range blocks { + id := "" + switch block := value.(type) { + case map[string]any: + id = blockIdentity(block, "") + if id == "" { + if element, ok := block["element"].(map[string]any); ok { + id = blockIdentity(element, "") + } + } + case []any: + id = jsonMLBlockIdentity(block) + } + if id == "" { + return "" + } + ids = append(ids, id) + } + encoded, _ := json.Marshal(ids) + return string(encoded) +} + func documentBlockEntries(value any) ([]any, bool) { switch typed := value.(type) { case map[string]any: diff --git a/internal/shortcut/doc/doc_readback_verification_test.go b/internal/shortcut/doc/doc_readback_verification_test.go index 0c5dc10c..6f852b4d 100644 --- a/internal/shortcut/doc/doc_readback_verification_test.go +++ b/internal/shortcut/doc/doc_readback_verification_test.go @@ -211,6 +211,30 @@ func TestCrossPlatformCoverageDocReadbackDefensiveEdges(t *testing.T) { }) } + t.Run("identical adjacent pages advance by requested indexes", func(t *testing.T) { + testseam.Swap(t, &docVerifyDelays, []time.Duration{}) + blocks := make([]any, docBlockReadPageSize) + for index := range blocks { + blocks[index] = map[string]any{"blockType": "paragraph"} + } + caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": { + {"blocks": blocks, "hasMore": true, "totalCount": 2 * docBlockReadPageSize}, + {"blocks": blocks, "hasMore": false, "totalCount": 2 * docBlockReadPageSize}, + }}} + if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes"); err != nil { + t.Fatal(err) + } + starts := []int{} + for _, call := range caller.history { + if call.tool == "list_document_blocks" { + starts = append(starts, call.params["startIndex"].(int)) + } + } + if fmt.Sprint(starts) != "[0 50]" { + t.Fatalf("pagination starts = %v, want [0 50]", starts) + } + }) + t.Run("total count terminates pagination", func(t *testing.T) { testseam.Swap(t, &docVerifyDelays, []time.Duration{}) caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"blocks": []any{map[string]any{"id": "other"}}, "totalCount": 1}}}}