diff --git a/go.mod b/go.mod index eb74fd55..62bec438 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/muesli/termenv v0.16.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1 github.com/spf13/cobra v1.10.2 + github.com/yuin/goldmark v1.8.5 github.com/zalando/go-keyring v0.2.8 gitlab.alibaba-inc.com/aes/aem-go-sdk v0.3.0 golang.org/x/crypto v0.49.0 diff --git a/go.sum b/go.sum index 99e029e0..06a5b621 100644 --- a/go.sum +++ b/go.sum @@ -105,6 +105,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= +github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go index 1897aa18..d1c9ce4c 100644 --- a/internal/helpers/doc.go +++ b/internal/helpers/doc.go @@ -492,10 +492,11 @@ func defaultHTTPGetFile(ctx context.Context, url string, headers map[string]stri return nil } -// runMediaInsert implements the three-step flow for inserting an attachment into a document: +// runMediaInsert implements the four-step flow for inserting an attachment into a document: // 1. get_doc_attachment_upload_info → obtain uploadUrl + resourceId // 2. HTTP PUT file content to OSS // 3. insert_document_block with attachment element +// 4. list_document_blocks → prove the uploaded resource is visible in the document func runMediaInsert(cmd *cobra.Command, _ []string) error { nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id") if err != nil { @@ -551,7 +552,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() // Step 1: get upload credentials (uploadUrl + resourceId) - deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize)) + deps.Out.PrintInfo(fmt.Sprintf("[1/4] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize)) credText, err := callMCPToolReturnText(ctx, "get_doc_attachment_upload_info", map[string]any{ "nodeId": nodeID, @@ -569,7 +570,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error { } // Step 2: HTTP PUT file to OSS - deps.Out.PrintInfo("[2/3] 上传文件到 OSS...") + deps.Out.PrintInfo("[2/4] 上传文件到 OSS...") ossHeaders := map[string]string{ "Content-Type": mimeType, @@ -592,7 +593,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error { } // Step 3: insert block into document - deps.Out.PrintInfo("[3/3] 插入块到文档...") + deps.Out.PrintInfo("[3/4] 插入块到文档...") const maxInlineImageSize = 20 * 1024 * 1024 // 20MB @@ -644,7 +645,8 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error { insertArgs["referenceBlockId"] = v } - if err := callMCPTool("insert_document_block", insertArgs); err != nil { + insertText, err := callMCPToolReturnText(ctx, "insert_document_block", insertArgs) + if err != nil { return apperrors.NewAPI( "附件已上传,但正文 block 插入结果未知;请先检查媒体列表,不要重复上传或插入", apperrors.WithOperation("doc.media_insert"), @@ -665,6 +667,23 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error { apperrors.WithCause(err), ) } + insertResult := map[string]any{} + if strings.TrimSpace(insertText) != "" { + if err := json.Unmarshal([]byte(insertText), &insertResult); err != nil { + return docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName, + fmt.Errorf("解析 insert_document_block 响应失败: %w", err)) + } + } + insertedBlockID := nestedDocString(insertResult, "blockId", "elementId", "id") + + deps.Out.PrintInfo("[4/4] 回读验证媒体块...") + verifiedBlockID, verifyErr := verifyInsertedDocMedia(ctx, nodeID, insertedBlockID, resourceID, resourceURL) + if verifyErr != nil { + return docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName, verifyErr) + } + if insertedBlockID == "" { + insertedBlockID = verifiedBlockID + } return deps.Out.PrintJSON(map[string]any{ "contractVersion": "doc.operation.v1", @@ -674,16 +693,169 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error { "operation": "doc.media_insert", "data": map[string]any{ "nodeId": nodeID, "resourceId": resourceID, "resourceUrl": resourceURL, - "fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize, "inserted": true, + "blockId": insertedBlockID, "fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize, + "inserted": true, "verified": true, }, "steps": []map[string]any{ {"name": "resolve_upload", "status": "success"}, {"name": "upload_oss", "status": "success"}, {"name": "insert_block", "status": "success"}, + {"name": "verify", "status": "success"}, }, }) } +func docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName string, cause error) error { + return apperrors.NewAPI( + "附件已上传且插块请求已执行,但回读未能证明媒体块落库;不要直接重试上传或插入", + apperrors.WithOperation("doc.media_insert"), + apperrors.WithReason("doc_media_insert_verification_failed"), + apperrors.WithFailureStage("verify"), + apperrors.WithExecutionStarted(true), + apperrors.WithRetryable(false), + apperrors.WithActions("运行 dws doc +media-list 检查 resourceId", "确认媒体不存在后再决定是否重新执行"), + apperrors.WithDetails(map[string]any{ + "contractVersion": "doc.operation.v1", "status": "partial_success", "nodeId": nodeID, + "resourceId": resourceID, "resourceUrl": resourceURL, "fileName": fileName, "verified": false, + "steps": []map[string]any{ + {"name": "resolve_upload", "status": "success"}, + {"name": "upload_oss", "status": "success"}, + {"name": "insert_block", "status": "success"}, + {"name": "verify", "status": "failed"}, + }, + }), + apperrors.WithCause(cause), + ) +} + +func verifyInsertedDocMedia(ctx context.Context, nodeID, blockID, resourceID, resourceURL string) (string, error) { + delays := []time.Duration{250 * time.Millisecond, 500 * time.Millisecond, time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second} + var lastErr error + for attempt := 0; attempt <= len(delays); attempt++ { + blocks, err := readAllDocBlocksForVerification(ctx, nodeID) + if err != nil { + lastErr = err + } else { + lastErr = nil + if found := findVerifiedMediaBlock(blocks, blockID, resourceID, resourceURL); found != "" { + return found, nil + } + } + if attempt < len(delays) { + helperSleep(delays[attempt]) + } + } + if lastErr != nil { + return "", fmt.Errorf("媒体资源在有界回读窗口内仍无法读取: %w", lastErr) + } + return "", fmt.Errorf("媒体资源在有界回读窗口内仍不可见") +} + +func readAllDocBlocksForVerification(ctx context.Context, nodeID string) ([]any, error) { + const pageSize = 50 + const maxItems = 5000 + all := make([]any, 0, pageSize) + seen := map[string]bool{} + for start := 0; start < maxItems; start += pageSize { + text, err := callMCPToolReturnTextOnServer(ctx, "doc", "list_document_blocks", map[string]any{ + "nodeId": nodeID, "format": "element", "startIndex": start, "endIndex": start + pageSize - 1, + }) + if err != nil { + return nil, err + } + var payload map[string]any + if err := json.Unmarshal([]byte(text), &payload); err != nil { + return nil, fmt.Errorf("解析 list_document_blocks 回读失败: %w", err) + } + payload = nestedDocMap(payload) + blocks, ok := payload["blocks"].([]any) + if !ok { + return nil, fmt.Errorf("list_document_blocks 回读缺少 blocks 数组") + } + encoded, _ := json.Marshal(blocks) + key := string(encoded) + if key != "[]" && seen[key] { + return nil, fmt.Errorf("list_document_blocks 分页停滞") + } + seen[key] = true + all = append(all, blocks...) + hasMore, hasMoreKnown := payload["hasMore"].(bool) + if hasMoreKnown && !hasMore { + return all, nil + } + if total, ok := docNumberAsInt(payload["totalCount"]); ok && len(all) >= total { + return all, nil + } + if !hasMoreKnown && len(blocks) < pageSize { + return all, nil + } + if len(blocks) == 0 { + return nil, fmt.Errorf("list_document_blocks 声明仍有下一页但当前页为空") + } + } + return nil, fmt.Errorf("文档块超过安全回读上限") +} + +func nestedDocMap(data map[string]any) map[string]any { + for _, key := range []string{"result", "data"} { + if nested, ok := data[key].(map[string]any); ok { + return nestedDocMap(nested) + } + } + return data +} + +func nestedDocString(value any, keys ...string) string { + switch typed := value.(type) { + case map[string]any: + for _, key := range keys { + if text, ok := typed[key].(string); ok && strings.TrimSpace(text) != "" { + return strings.TrimSpace(text) + } + } + for _, child := range typed { + if text := nestedDocString(child, keys...); text != "" { + return text + } + } + case []any: + for _, child := range typed { + if text := nestedDocString(child, keys...); text != "" { + return text + } + } + } + return "" +} + +func findVerifiedMediaBlock(blocks []any, blockID, resourceID, resourceURL string) string { + for _, value := range blocks { + candidateID := nestedDocString(value, "blockId", "id", "uuid") + if candidateID == "" || (blockID != "" && candidateID != blockID) { + continue + } + if resourceID != "" && nestedDocString(value, "resourceId") == resourceID { + return candidateID + } + if resourceID == "" && resourceURL != "" && nestedDocString(value, "resourceUrl") == resourceURL { + return candidateID + } + } + return "" +} + +func docNumberAsInt(value any) (int, bool) { + switch typed := value.(type) { + case float64: + if typed >= 0 && typed == float64(int(typed)) { + return int(typed), true + } + case int: + return typed, typed >= 0 + } + return 0, false +} + // parseAttachmentUploadInfo extracts uploadUrl, resourceId and resourceUrl from the MCP tool response. func parseAttachmentUploadInfo(text string) (uploadURL, resourceID, resourceURL string, err error) { var data map[string]any diff --git a/internal/helpers/doc_command_edges_test.go b/internal/helpers/doc_command_edges_test.go index d781efed..17ab60ca 100644 --- a/internal/helpers/doc_command_edges_test.go +++ b/internal/helpers/doc_command_edges_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "fmt" "io" "net/http" "net/http/httptest" @@ -13,6 +14,7 @@ import ( "testing" "time" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) @@ -74,6 +76,7 @@ func TestCrossPlatformCoverageDocUploadAndMediaErrorEdges(t *testing.T) { t.Cleanup(func() { os.Args = oldArgs }) oldPut, oldGet := httpPutFile, httpGetFile t.Cleanup(func() { httpPutFile, httpGetFile = oldPut, oldGet }) + testseam.Swap(t, &helperSleep, func(time.Duration) {}) file := filepath.Join(t.TempDir(), "file.txt") if err := os.WriteFile(file, []byte("content"), 0o600); err != nil { t.Fatal(err) @@ -193,6 +196,16 @@ func TestCrossPlatformCoverageDocUploadAndMediaErrorEdges(t *testing.T) { t.Fatal("media insert failure returned nil") } }) + t.Run("media insert response parse failure", func(t *testing.T) { + httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil } + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"uploadUrl":"https://upload","resourceId":"resource"}`}, + {text: `{`}, + }} + if err := mediaCommand(t, caller, file, "text/plain"); err == nil { + t.Fatal("invalid media insert response returned nil") + } + }) t.Run("large image becomes attachment", func(t *testing.T) { httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil } large := filepath.Join(t.TempDir(), "large.png") @@ -202,7 +215,11 @@ func TestCrossPlatformCoverageDocUploadAndMediaErrorEdges(t *testing.T) { if err := os.Truncate(large, 21*1024*1024); err != nil { t.Fatal(err) } - caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`}, {text: `{}`}}} + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`}, + {text: `{}`}, + {text: `{"blocks":[{"blockId":"media-block","element":{"attachment":{"resourceId":"resource"}}}],"hasMore":false}`}, + }} if err := mediaCommand(t, caller, large, "image/png"); err != nil { t.Fatal(err) } @@ -240,6 +257,103 @@ func TestCrossPlatformCoverageDefaultDocHTTPTransportEdges(t *testing.T) { } } +func TestCrossPlatformCoverageDocMediaReadbackDefensiveEdges(t *testing.T) { + testseam.Swap(t, &helperSleep, func(time.Duration) {}) + ctx := context.Background() + + for _, tc := range []struct { + name string + steps []scriptedToolStep + }{ + {"call failure", []scriptedToolStep{{err: errors.New("read")}}}, + {"invalid json", []scriptedToolStep{{text: `{`}}}, + {"missing blocks", []scriptedToolStep{{text: `{}`}}}, + {"stalled page", []scriptedToolStep{{text: `{"blocks":[{"id":"a"}],"hasMore":true}`}, {text: `{"blocks":[{"id":"a"}],"hasMore":true}`}}}, + {"empty continued page", []scriptedToolStep{{text: `{"blocks":[],"hasMore":true}`}}}, + } { + t.Run(tc.name, func(t *testing.T) { + installScriptedCaller(t, &scriptedToolCaller{steps: tc.steps}) + if _, err := readAllDocBlocksForVerification(ctx, "node"); err == nil { + t.Fatal("defensive readback returned nil") + } + }) + } + + t.Run("total count and nested payload", func(t *testing.T) { + installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"data":{"blocks":[{"id":"a"}],"totalCount":1}}`}}}) + blocks, err := readAllDocBlocksForVerification(ctx, "node") + if err != nil || len(blocks) != 1 { + t.Fatalf("blocks=%#v err=%v", blocks, 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 { + t.Fatalf("blocks=%#v err=%v", blocks, err) + } + }) + + t.Run("bounded retry failure", func(t *testing.T) { + installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[],"hasMore":false}`}}}) + if _, err := verifyInsertedDocMedia(ctx, "node", "", "missing", ""); err == nil { + t.Fatal("missing media unexpectedly verified") + } + }) + + t.Run("transient read failure retries", func(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {err: errors.New("temporary read failure")}, + {text: `{"blocks":[{"blockId":"media-block","element":{"attachment":{"resourceId":"resource"}}}],"hasMore":false}`}, + }} + installScriptedCaller(t, caller) + blockID, err := verifyInsertedDocMedia(ctx, "node", "media-block", "resource", "") + if err != nil || blockID != "media-block" || caller.calls != 2 { + t.Fatalf("blockID=%q calls=%d err=%v", blockID, caller.calls, err) + } + }) + + t.Run("block identity alone does not verify media", func(t *testing.T) { + installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"media-block","element":{"attachment":{"resourceId":"other-resource"}}}],"hasMore":false}`}}}) + if _, err := verifyInsertedDocMedia(ctx, "node", "media-block", "resource", ""); err == nil { + t.Fatal("matching block ID with a different resource unexpectedly verified") + } + }) + + t.Run("block read safety limit", func(t *testing.T) { + steps := make([]scriptedToolStep, 100) + for index := range steps { + steps[index] = scriptedToolStep{text: fmt.Sprintf(`{"blocks":[{"id":"block-%d"}],"hasMore":true}`, index)} + } + installScriptedCaller(t, &scriptedToolCaller{steps: steps}) + if _, err := readAllDocBlocksForVerification(ctx, "node"); err == nil { + t.Fatal("oversized block read returned nil") + } + }) + + if got := nestedDocMap(map[string]any{"result": map[string]any{"data": map[string]any{"ok": true}}}); got["ok"] != true { + t.Fatalf("nested map=%#v", got) + } + if nestedDocString(map[string]any{"x": []any{map[string]any{"id": " nested "}}}, "id") != "nested" || nestedDocString(3, "id") != "" { + t.Fatal("nested string traversal failed") + } + blocks := []any{map[string]any{"id": "block", "resourceId": "rid"}, map[string]any{"id": "url-block", "resourceUrl": "https://media"}} + if findVerifiedMediaBlock(blocks, "block", "rid", "") != "block" || findVerifiedMediaBlock(blocks, "", "rid", "") != "block" || findVerifiedMediaBlock(blocks, "", "", "https://media") != "url-block" || findVerifiedMediaBlock(blocks, "block", "wrong", "") != "" || findVerifiedMediaBlock(blocks, "", "missing", "") != "" { + t.Fatal("media block matching failed") + } + for _, tc := range []struct { + value any + want bool + }{ + {float64(3), true}, {float64(-1), false}, {1.5, false}, {3, true}, {-1, false}, {"3", false}, + } { + _, ok := docNumberAsInt(tc.value) + if ok != tc.want { + t.Fatalf("docNumberAsInt(%#v) ok=%v want=%v", tc.value, ok, tc.want) + } + } +} + func TestCrossPlatformCoverageDocCreateUpdateAndBlockCommandEdges(t *testing.T) { oldDeps, oldArgs, oldPut, oldGet := deps, os.Args, httpPutFile, httpGetFile t.Cleanup(func() { diff --git a/internal/shortcut/doc/content_shortcuts.go b/internal/shortcut/doc/content_shortcuts.go index 6d3d781d..1232088e 100644 --- a/internal/shortcut/doc/content_shortcuts.go +++ b/internal/shortcut/doc/content_shortcuts.go @@ -4,8 +4,10 @@ package doc import ( + "bytes" "encoding/json" "fmt" + "io" "os" "path/filepath" "strings" @@ -18,6 +20,9 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/docresolver" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/renderer/html" ) var ( @@ -28,6 +33,21 @@ var ( docMkdirTemp = os.MkdirTemp docRemoveAll = os.RemoveAll docDownload = localio.Download + docVerifySleep = time.Sleep + docVerifyDelays = []time.Duration{250 * time.Millisecond, 500 * time.Millisecond, time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second} + docMarkdown = goldmark.New( + goldmark.WithExtensions(extension.Table), + goldmark.WithRendererOptions(html.WithUnsafe()), + ) + docMarkdownConvert = func(source []byte, writer io.Writer) error { + return docMarkdown.Convert(source, writer) + } +) + +const ( + docBlockReadPageSize = 50 + docBlockReadMaxItems = 5000 + docMarkdownVerifyMax = 2 * 1024 * 1024 ) var Create = shortcut.Shortcut{ @@ -133,7 +153,9 @@ var Create = shortcut.Shortcut{ verifyTool = "get_document_content" verifyParams["format"] = format } - verification, err := rt.CallMCPData(productDoc, verifyTool, verifyParams) + verification, err := readDocVerification(rt, verifyTool, verifyParams, func(data map[string]any) bool { + return content == "" || verifyUpdatedDocumentContent(data, content, "overwrite", format) + }) if err != nil { return docVerificationError("doc.create", "verify", nodeID, err, append(steps, map[string]any{"name": "verify", "status": "failed"})) } @@ -422,7 +444,9 @@ var CheckpointUpdate = shortcut.Shortcut{ append(steps, map[string]any{"name": "update", "status": "failed"}, map[string]any{"name": "verify", "status": "not_started"})) } steps = append(steps, map[string]any{"name": "update", "status": "success"}) - verification, err := rt.CallMCPData(productDoc, "get_document_content", map[string]any{"nodeId": rt.Str("node"), "format": "markdown"}) + verification, err := readDocVerification(rt, "get_document_content", map[string]any{"nodeId": rt.Str("node"), "format": "markdown"}, func(data map[string]any) bool { + return verifyUpdatedDocumentContent(data, content, rt.Str("mode"), "markdown") + }) if err != nil { return checkpointPartialWriteError(rt.Str("node"), checkpoint, "verify", "doc_checkpoint_verification_failed", err, append(steps, map[string]any{"name": "verify", "status": "failed"})) @@ -539,7 +563,7 @@ func executeUpdate(rt *shortcut.RuntimeContext) error { } referenceBlockID := rt.Str("after-block-id") return executeVerifiedDocMutation(rt, "doc.update", "insert_document_block", params, node, - "list_document_blocks", map[string]any{"nodeId": node, "format": verificationFormat}, + "list_document_blocks", map[string]any{"nodeId": node, "format": verificationFormat, "__allBlocks": true}, func(result, data map[string]any) bool { return verifyInsertedBlock(result, data, referenceBlockID, content, rt.Str("doc-format")) }) @@ -553,14 +577,14 @@ func executeUpdate(rt *shortcut.RuntimeContext) error { params["element"] = map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": content}} } return executeVerifiedDocMutation(rt, "doc.update", "update_document_block", params, node, - "list_document_blocks", map[string]any{"nodeId": node, "blockId": blockID, "format": verificationFormat}, + "list_document_blocks", map[string]any{"nodeId": node, "format": verificationFormat, "__allBlocks": true}, func(_, data map[string]any) bool { return blockContentEquals(data, blockID, content, rt.Str("doc-format")) }) case "block_delete": blockID := rt.Str("block-id") return executeVerifiedDocMutation(rt, "doc.update", "delete_document_block", map[string]any{"nodeId": node, "blockId": blockID}, node, - "list_document_blocks", map[string]any{"nodeId": node, "format": "element"}, + "list_document_blocks", map[string]any{"nodeId": node, "format": "element", "__allBlocks": true}, func(_, data map[string]any) bool { return findBlock(data, blockID) == nil }) case "str_replace": return executePlainTextReplace(rt, node) @@ -609,7 +633,7 @@ func nestedRevision(value any) (int, bool) { } func executePlainTextReplace(rt *shortcut.RuntimeContext, nodeID string) error { - data, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element"}) + data, err := readAllDocumentBlocks(rt, map[string]any{"nodeId": nodeID, "format": "element"}) if err != nil { return err } @@ -643,12 +667,12 @@ func executePlainTextReplace(rt *shortcut.RuntimeContext, nodeID string) error { blockID := matches[0].blockID return executeVerifiedDocMutation(rt, "doc.update", "update_document_block", map[string]any{"nodeId": nodeID, "blockId": blockID, "element": map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": updated}}}, nodeID, - "list_document_blocks", map[string]any{"nodeId": nodeID, "blockId": blockID, "format": "element"}, + "list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element", "__allBlocks": true}, func(_, data map[string]any) bool { return blockContentEquals(data, blockID, updated, "markdown") }) } func executeBlockCopy(rt *shortcut.RuntimeContext, nodeID string) error { - data, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": nodeID, "blockId": rt.Str("block-id"), "format": "element"}) + data, err := readAllDocumentBlocks(rt, map[string]any{"nodeId": nodeID, "format": "element"}) if err != nil { return err } @@ -664,7 +688,7 @@ func executeBlockCopy(rt *shortcut.RuntimeContext, nodeID string) error { referenceBlockID := rt.Str("after-block-id") return executeVerifiedDocMutation(rt, "doc.update", "insert_document_block", map[string]any{"nodeId": nodeID, "referenceBlockId": referenceBlockID, "where": "after", "element": block}, nodeID, - "list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element"}, + "list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element", "__allBlocks": true}, func(result, data map[string]any) bool { return verifyInsertedCanonicalBlock(result, data, referenceBlockID, expectedContent, "markdown") }) @@ -684,7 +708,9 @@ func executeVerifiedDocMutation( return docUnknownWriteError(operation, tool, nodeID, err) } steps[0]["status"] = "success" - verification, err := rt.CallMCPData(productDoc, verifyTool, verifyParams) + verification, err := readDocVerification(rt, verifyTool, verifyParams, func(data map[string]any) bool { + return verify == nil || verify(result, data) + }) if err != nil { return docVerificationError(operation, "verify", nodeID, err, append(steps, map[string]any{"name": "verify", "status": "failed"})) } @@ -732,7 +758,9 @@ func executeVerifiedDocContentMutation(rt *shortcut.RuntimeContext, firstParams } steps = append(steps, map[string]any{"name": stepName, "status": "success"}) } - verification, err := rt.CallMCPData(productDoc, "get_document_content", map[string]any{"nodeId": nodeID, "format": format}) + verification, err := readDocVerification(rt, "get_document_content", map[string]any{"nodeId": nodeID, "format": format}, func(data map[string]any) bool { + return verifyUpdatedDocumentContent(data, content, mode, format) + }) if err != nil { return docVerificationError("doc.update", "verify", nodeID, err, append(steps, map[string]any{"name": "verify", "status": "failed"})) } @@ -745,6 +773,134 @@ func executeVerifiedDocContentMutation(rt *shortcut.RuntimeContext, firstParams }, steps...)) } +func readDocVerification(rt *shortcut.RuntimeContext, tool string, rawParams map[string]any, verify func(map[string]any) bool) (map[string]any, error) { + params := cloneMap(rawParams) + allBlocks, _ := params["__allBlocks"].(bool) + delete(params, "__allBlocks") + var last map[string]any + var lastErr error + for attempt := 0; attempt <= len(docVerifyDelays); attempt++ { + var data map[string]any + var err error + if allBlocks && tool == "list_document_blocks" { + data, err = readAllDocumentBlocks(rt, params) + } else { + data, err = rt.CallMCPData(productDoc, tool, params) + } + if err != nil { + lastErr = err + } else { + last = data + lastErr = nil + if verify == nil || verify(data) { + return data, nil + } + } + if attempt < len(docVerifyDelays) { + docVerifySleep(docVerifyDelays[attempt]) + } + } + if lastErr != nil { + return nil, lastErr + } + return last, nil +} + +func readAllDocumentBlocks(rt *shortcut.RuntimeContext, base map[string]any) (map[string]any, error) { + all := make([]any, 0, docBlockReadPageSize) + seenPages := map[string]bool{} + for start := 0; start < docBlockReadMaxItems; start += docBlockReadPageSize { + params := cloneMap(base) + params["startIndex"] = start + params["endIndex"] = start + docBlockReadPageSize - 1 + page, err := rt.CallMCPData(productDoc, "list_document_blocks", params) + if err != nil { + return nil, err + } + blocks, ok := documentBlockEntries(page) + if !ok { + return nil, fmt.Errorf("list_document_blocks 回读缺少 blocks 数组") + } + encoded, _ := json.Marshal(blocks) + pageKey := string(encoded) + if pageKey != "[]" && seenPages[pageKey] { + return nil, fmt.Errorf("list_document_blocks 分页停滞,无法证明回读完整") + } + seenPages[pageKey] = true + all = append(all, blocks...) + hasMore, known, _ := docPageState(page) + if known && !hasMore { + return map[string]any{"blocks": all, "hasMore": false, "totalCount": len(all)}, nil + } + if total, ok := nestedNonNegativeInt(page, "totalCount", "total_count"); ok && len(all) >= total { + return map[string]any{"blocks": all, "hasMore": false, "totalCount": total}, nil + } + if !known && len(blocks) < docBlockReadPageSize { + return map[string]any{"blocks": all, "hasMore": false, "totalCount": len(all)}, nil + } + if len(blocks) == 0 { + return nil, fmt.Errorf("list_document_blocks 声明仍有下一页但当前页为空,无法证明回读完整") + } + } + return nil, fmt.Errorf("list_document_blocks 超过 %d 个块,无法在安全上限内完成回读", docBlockReadMaxItems) +} + +func documentBlockEntries(value any) ([]any, bool) { + switch typed := value.(type) { + case map[string]any: + for _, key := range []string{"blocks", "items"} { + if blocks, ok := typed[key].([]any); ok { + return blocks, true + } + } + if encoded, ok := typed["jsonml"].(string); ok { + var decoded any + if json.Unmarshal([]byte(encoded), &decoded) == nil { + blocks := orderedJSONMLBlocks(decoded) + values := make([]any, len(blocks)) + for index := range blocks { + values[index] = blocks[index] + } + return values, true + } + } + for _, key := range []string{"result", "data"} { + if nested, ok := typed[key]; ok { + if blocks, found := documentBlockEntries(nested); found { + return blocks, true + } + } + } + } + return nil, false +} + +func nestedNonNegativeInt(value any, keys ...string) (int, bool) { + switch typed := value.(type) { + case map[string]any: + for _, key := range keys { + if raw, ok := typed[key]; ok { + switch number := raw.(type) { + case float64: + if number >= 0 && number == float64(int(number)) { + return int(number), true + } + case int: + if number >= 0 { + return number, true + } + } + } + } + for _, key := range []string{"result", "data"} { + if result, ok := nestedNonNegativeInt(typed[key], keys...); ok { + return result, true + } + } + } + return 0, false +} + func splitDocMarkdown(content string, maxRunes int) []string { if maxRunes <= 0 { return []string{content} @@ -796,11 +952,17 @@ func containsText(value any, needle string) bool { } func verifyUpdatedDocumentContent(value any, expected, mode, format string) bool { - expected = normalizeDocumentContentForVerification(expected, format) + expectedRaw := expected + expected = normalizeDocumentContentForVerification(expectedRaw, format) for _, candidate := range documentContentCandidates(value, format) { - actual := normalizeDocumentContentForVerification(candidate, format) + actualRaw := candidate + actual := normalizeDocumentContentForVerification(actualRaw, format) if mode == "overwrite" { - if actual == expected { + if actual == expected || (format == "markdown" && stripReadbackDocumentTitle(actual) == expected) { + return true + } + if format == "markdown" && (markdownSemanticallyEquivalent(actualRaw, expectedRaw) || + markdownSemanticallyEquivalent(stripReadbackDocumentTitle(actualRaw), expectedRaw)) { return true } continue @@ -808,10 +970,48 @@ func verifyUpdatedDocumentContent(value any, expected, mode, format string) bool if actual == expected || strings.HasSuffix(actual, "\n"+expected) { return true } + if format == "markdown" && markdownSemanticallyEndsWith(actualRaw, expectedRaw) { + return true + } } return false } +func markdownSemanticallyEquivalent(left, right string) bool { + leftFingerprint, leftOK := markdownSemanticFingerprint(left) + rightFingerprint, rightOK := markdownSemanticFingerprint(right) + return leftOK && rightOK && leftFingerprint == rightFingerprint +} + +func markdownSemanticallyEndsWith(content, suffix string) bool { + contentFingerprint, contentOK := markdownSemanticFingerprint(content) + suffixFingerprint, suffixOK := markdownSemanticFingerprint(suffix) + return contentOK && suffixOK && strings.HasSuffix(contentFingerprint, suffixFingerprint) +} + +func markdownSemanticFingerprint(source string) (string, bool) { + if len(source) > docMarkdownVerifyMax { + return "", false + } + var rendered bytes.Buffer + if err := docMarkdownConvert([]byte(source), &rendered); err != nil { + return "", false + } + return rendered.String(), true +} + +func stripReadbackDocumentTitle(content string) string { + lines := strings.Split(content, "\n") + if len(lines) == 0 || !strings.HasPrefix(strings.TrimSpace(lines[0]), "# ") { + return content + } + lines = lines[1:] + for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" { + lines = lines[1:] + } + return strings.Join(lines, "\n") +} + func verifyInsertedBlock(result, data map[string]any, referenceBlockID, expected, format string) bool { return verifyInsertedCanonicalBlock(result, data, referenceBlockID, normalizeDocumentContentForVerification(expected, format), format) } @@ -848,6 +1048,11 @@ func blockContentEquals(data map[string]any, blockID, expected, format string) b } func canonicalBlockContent(value any, format string) string { + if values, ok := value.(map[string]any); ok { + if element, ok := values["element"].(map[string]any); ok { + value = element + } + } if format == "jsonml" { if values, ok := value.(map[string]any); ok { if encoded, ok := values["jsonml"].(string); ok { @@ -871,7 +1076,7 @@ func canonicalBlockContent(value any, format string) string { return } for key, child := range typed { - if key == "id" || key == "blockId" || key == "uuid" { + if key == "id" || key == "blockId" || key == "uuid" || key == "blockType" { continue } walk(child) @@ -1005,6 +1210,10 @@ func orderedDocumentBlocks(value any) []map[string]any { walk = func(current any) { switch typed := current.(type) { case map[string]any: + if element, ok := typed["element"].(map[string]any); ok && blockIdentity(element, "") != "" { + blocks = append(blocks, element) + return + } if blockIdentity(typed, "") != "" { blocks = append(blocks, typed) return @@ -1065,9 +1274,23 @@ func normalizeDocumentContentForVerification(raw, format string) string { func normalizeMarkdownForVerification(raw string) string { raw = strings.ReplaceAll(strings.ReplaceAll(raw, "\r\n", "\n"), "\r", "\n") lines := make([]string, 0, strings.Count(raw, "\n")+1) + inFence := false for _, line := range strings.Split(raw, "\n") { - line = strings.TrimSpace(line) + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") { + inFence = !inFence + lines = append(lines, trimmed) + continue + } + if inFence { + lines = append(lines, strings.TrimRight(line, " \t")) + continue + } + line = trimmed if line == "" { + if len(lines) > 0 && lines[len(lines)-1] != "" { + lines = append(lines, "") + } continue } if strings.Contains(line, "|") { @@ -1081,6 +1304,9 @@ func normalizeMarkdownForVerification(raw string) string { } lines = append(lines, line) } + for len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } return strings.Join(lines, "\n") } @@ -1089,33 +1315,75 @@ func normalizeJSONMLForVerification(raw string) string { if err := json.Unmarshal([]byte(raw), &value); err != nil { return normalizeMarkdownForVerification(raw) } - var tokens []string - var walk func(any) - walk = func(current any) { + var normalize func(any) any + normalize = func(current any) any { switch typed := current.(type) { case []any: - start := 0 - if len(typed) > 0 { - if tag, ok := typed[0].(string); ok { - tokens = append(tokens, "<"+strings.ToLower(strings.TrimSpace(tag))+">") - start = 1 + if len(typed) == 0 { + return []any{} + } + tag, isElement := typed[0].(string) + if !isElement { + out := make([]any, 0, len(typed)) + for _, child := range typed { + out = append(out, normalize(child)) + } + return out + } + start := 1 + attrs := map[string]any{} + if len(typed) > 1 { + if declared, ok := typed[1].(map[string]any); ok { + attrs, _ = normalize(declared).(map[string]any) + start = 2 } } + children := make([]any, 0, len(typed)-start) for _, child := range typed[start:] { - walk(child) + normalized := normalize(child) + if normalized != nil { + children = append(children, normalized) + } } + if strings.EqualFold(tag, "span") && isGeneratedTextSpan(attrs) { + if len(children) == 1 { + return children[0] + } + return children + } + out := []any{strings.ToLower(tag), attrs} + out = append(out, children...) + return out case map[string]any: - // JSONML maps contain element attributes. Server-generated UUIDs and - // default attributes do not change the authored document content. - return - case string: - if text := normalizeMarkdownForVerification(typed); text != "" { - tokens = append(tokens, text) + out := make(map[string]any, len(typed)) + for key, child := range typed { + normalizedKey := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key)) + if normalizedKey == "uuid" || normalizedKey == "blockid" || normalizedKey == "elementid" || normalizedKey == "index" { + continue + } + out[key] = normalize(child) } + return out + case string: + return strings.ReplaceAll(strings.ReplaceAll(typed, "\r\n", "\n"), "\r", "\n") } + return current } - walk(value) - return strings.Join(tokens, "\n") + // normalize only receives values decoded by encoding/json, so the resulting + // tree is always JSON-marshalable. + encoded, _ := json.Marshal(normalize(value)) + return string(encoded) +} + +func isGeneratedTextSpan(attrs map[string]any) bool { + if len(attrs) == 0 { + return true + } + if len(attrs) != 1 { + return false + } + value, ok := attrs["data-type"].(string) + return ok && (value == "text" || value == "leaf") } func executeExport(rt *shortcut.RuntimeContext) error { diff --git a/internal/shortcut/doc/doc_readback_verification_test.go b/internal/shortcut/doc/doc_readback_verification_test.go new file mode 100644 index 00000000..0c5dc10c --- /dev/null +++ b/internal/shortcut/doc/doc_readback_verification_test.go @@ -0,0 +1,275 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package doc + +import ( + "errors" + "fmt" + "io" + "strings" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageDocReadbackRetriesStaleContent(t *testing.T) { + testseam.Swap(t, &docVerifySleep, func(time.Duration) {}) + testseam.Swap(t, &docVerifyDelays, []time.Duration{time.Millisecond}) + caller := &docCoverageCaller{responses: map[string][]map[string]any{ + "get_document_content": {{"markdown": "old"}, {"markdown": "old\nnew"}}, + }} + if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "append", "--content", "new", "--yes"); err != nil { + t.Fatal(err) + } + reads := 0 + for _, call := range caller.history { + if call.tool == "get_document_content" { + reads++ + } + } + if reads != 2 { + t.Fatalf("readback calls = %d, want 2; history=%#v", reads, caller.history) + } +} + +func TestCrossPlatformCoverageDocDeleteReadbackConsumesEveryPage(t *testing.T) { + testseam.Swap(t, &docVerifySleep, func(time.Duration) {}) + testseam.Swap(t, &docVerifyDelays, []time.Duration{time.Millisecond}) + firstPage := make([]any, 50) + for index := range firstPage { + firstPage[index] = map[string]any{"id": fmt.Sprintf("block-%d", index), "text": "body"} + } + caller := &docCoverageCaller{responses: map[string][]map[string]any{ + "list_document_blocks": { + {"blocks": firstPage, "hasMore": true, "totalCount": 51}, + {"blocks": []any{map[string]any{"id": "target", "text": "stale"}}, "hasMore": false, "totalCount": 51}, + {"blocks": firstPage, "hasMore": false, "totalCount": 50}, + }, + }} + 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 0]" { + t.Fatalf("pagination starts = %v, want [0 50 0]", starts) + } +} + +func TestCrossPlatformCoverageDocReplacePreflightIsGloballyUnique(t *testing.T) { + firstPage := make([]any, 50) + for index := range firstPage { + text := "body" + if index == 0 { + text = "unique needle" + } + firstPage[index] = map[string]any{"id": fmt.Sprintf("block-%d", index), "text": text} + } + caller := &docCoverageCaller{responses: map[string][]map[string]any{ + "list_document_blocks": { + {"blocks": firstPage, "hasMore": true, "totalCount": 51}, + {"blocks": []any{map[string]any{"id": "block-50", "text": "another needle"}}, "hasMore": false, "totalCount": 51}, + }, + }} + err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "str_replace", "--old", "needle", "--new", "changed", "--yes") + if err == nil { + t.Fatal("str_replace accepted a second match on a later page") + } + for _, call := range caller.history { + if call.tool == "update_document_block" { + t.Fatalf("ambiguous replace executed a write: %#v", caller.history) + } + } +} + +func TestCrossPlatformCoverageDocVerificationPreservesMeaning(t *testing.T) { + expected := "[\"p\",{},\"text\"]" + serverExpanded := "[\"p\",{\"uuid\":\"generated\"},[\"span\",{\"data-type\":\"text\"},[\"span\",{\"data-type\":\"leaf\"},\"text\"]]]" + if normalizeJSONMLForVerification(expected) != normalizeJSONMLForVerification(serverExpanded) { + t.Fatal("generated JSONML text wrappers should not change document meaning") + } + linkA := "[\"a\",{\"href\":\"https://example.com/a\"},\"text\"]" + linkB := "[\"a\",{\"href\":\"https://example.com/b\"},\"text\"]" + if normalizeJSONMLForVerification(linkA) == normalizeJSONMLForVerification(linkB) { + t.Fatal("semantic JSONML attributes were ignored") + } + codeA := "~~~go\n return nil\n~~~" + codeB := "~~~go\nreturn nil\n~~~" + if normalizeMarkdownForVerification(codeA) == normalizeMarkdownForVerification(codeB) { + t.Fatal("fenced code indentation was ignored") + } + if !verifyUpdatedDocumentContent(map[string]any{"markdown": "# Server title\n\nbody"}, "body", "overwrite", "markdown") { + t.Fatal("server-generated document title prevented body verification") + } +} + +func TestCrossPlatformCoverageMarkdownSemanticRoundTrip(t *testing.T) { + input := strings.Join([]string{ + "sales_data.xlsx", + "### 1.", + "+10.22%", + "| name | value |", + "| -------- | -------- |", + "| sales_data.xlsx | +10.22% |", + }, "\n") + server := strings.Join([]string{ + `sales\_data.xlsx`, + `### 1\.`, + `\+10.22%`, + "|name|value|", + "|---|---|", + `|sales\_data.xlsx|\+10.22%|`, + }, "\n") + if !markdownSemanticallyEquivalent(input, server) { + t.Fatal("server Markdown escaping and table delimiter normalization changed the semantic fingerprint") + } + if !verifyUpdatedDocumentContent(map[string]any{"markdown": server}, input, "overwrite", "markdown") { + t.Fatal("equivalent server Markdown failed overwrite verification") + } + if !verifyUpdatedDocumentContent(map[string]any{"markdown": "existing\n\n" + server}, input, "append", "markdown") { + t.Fatal("equivalent server Markdown failed append verification") + } +} + +func TestCrossPlatformCoverageMarkdownSemanticDifferencesRemainStrict(t *testing.T) { + for _, test := range []struct { + name string + left string + right string + }{ + {name: "emphasis", left: `*important*`, right: `\*important\*`}, + {name: "inline code", left: "`sales_data`", right: "`sales\\_data`"}, + {name: "fenced code", left: "```\nsales_data\n```", right: "```\nsales\\_data\n```"}, + {name: "table alignment", left: "|a|\n|---|\n|x|", right: "|a|\n|:---|\n|x|"}, + {name: "table columns", left: "|a|b|\n|---|---|\n|x|y|", right: "|a|\n|---|\n|x|"}, + {name: "table content", left: "|a|\n|---|\n|x|", right: "|a|\n|---|\n|y|"}, + } { + t.Run(test.name, func(t *testing.T) { + if markdownSemanticallyEquivalent(test.left, test.right) { + t.Fatal("meaningful Markdown difference was ignored") + } + }) + } + oversized := strings.Repeat("x", docMarkdownVerifyMax+1) + if _, ok := markdownSemanticFingerprint(oversized); ok { + t.Fatal("oversized Markdown entered semantic verification") + } + testseam.Swap(t, &docMarkdownConvert, func([]byte, io.Writer) error { return errors.New("render") }) + if _, ok := markdownSemanticFingerprint("body"); ok { + t.Fatal("failed Markdown render produced a semantic fingerprint") + } +} + +func TestCrossPlatformCoverageDocElementReadbackUsesNestedElement(t *testing.T) { + wrapper := map[string]any{ + "blockType": "paragraph", + "element": map[string]any{"id": "inserted", "blockType": "paragraph", "paragraph": map[string]any{"text": "body"}}, + } + if got := canonicalBlockContent(wrapper, "markdown"); got != "body" { + t.Fatalf("nested element content = %q, want body", got) + } + blocks := orderedDocumentBlocks(map[string]any{"blocks": []any{wrapper}}) + if len(blocks) != 1 || blockIdentity(blocks[0], "") != "inserted" { + t.Fatalf("nested element blocks = %#v", blocks) + } +} + +func TestCrossPlatformCoverageVersionRevertRequiresTargetEvidence(t *testing.T) { + if revertResultMatchesVersion(map[string]any{"ok": true}, 3) || currentDocumentMatchesRestoredVersion(map[string]any{"version": 99}, 3) { + t.Fatal("readability or an unrelated current version must not prove a revert") + } + if !revertResultMatchesVersion(map[string]any{"revertedToVersion": 3}, 3) { + t.Fatal("explicit target-version acknowledgement was not accepted") + } +} + +func TestCrossPlatformCoverageDocReadbackDefensiveEdges(t *testing.T) { + testseam.Swap(t, &docVerifySleep, func(time.Duration) {}) + for _, tc := range []struct { + name string + responses []map[string]any + failAt int + }{ + {"call failure", nil, 1}, + {"missing blocks", []map[string]any{{"ok": true}}, 0}, + {"stalled page", []map[string]any{{"blocks": []any{map[string]any{"id": "a"}}, "hasMore": true}, {"blocks": []any{map[string]any{"id": "a"}}, "hasMore": true}}, 0}, + {"empty continued page", []map[string]any{{"blocks": []any{}, "hasMore": true}}, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + testseam.Swap(t, &docVerifyDelays, []time.Duration{}) + caller := &docCoverageCaller{failAt: tc.failAt, responses: map[string][]map[string]any{"list_document_blocks": tc.responses}} + err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes") + if err == nil { + t.Fatal("defensive readback unexpectedly succeeded") + } + }) + } + + 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}}}} + if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes"); err != nil { + t.Fatal(err) + } + }) + + t.Run("block read safety limit", func(t *testing.T) { + testseam.Swap(t, &docVerifyDelays, []time.Duration{}) + pages := make([]map[string]any, docBlockReadMaxItems/docBlockReadPageSize) + for index := range pages { + pages[index] = map[string]any{"blocks": []any{map[string]any{"id": fmt.Sprintf("block-%d", index)}}, "hasMore": true} + } + caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": pages}} + if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes"); err == nil { + t.Fatal("oversized block read returned nil") + } + }) + + if blocks, ok := documentBlockEntries(map[string]any{"jsonml": `["root",{},["p",{"uuid":"a"},"x"]]`}); !ok || len(blocks) == 0 { + t.Fatalf("jsonml blocks=%#v ok=%v", blocks, ok) + } + if _, ok := documentBlockEntries(map[string]any{"jsonml": `{`}); ok { + t.Fatal("invalid jsonml produced blocks") + } + if blocks, ok := documentBlockEntries(map[string]any{"data": map[string]any{"items": []any{"x"}}}); !ok || len(blocks) != 1 { + t.Fatalf("nested items=%#v ok=%v", blocks, ok) + } + if _, ok := documentBlockEntries(nil); ok { + t.Fatal("nil produced blocks") + } + + for _, tc := range []struct { + value any + want bool + }{ + {map[string]any{"totalCount": float64(2)}, true}, + {map[string]any{"totalCount": float64(-1)}, false}, + {map[string]any{"totalCount": 2.5}, false}, + {map[string]any{"data": map[string]any{"total_count": 2}}, true}, + {map[string]any{"totalCount": -1}, false}, + {nil, false}, + } { + _, ok := nestedNonNegativeInt(tc.value, "totalCount", "total_count") + if ok != tc.want { + t.Fatalf("nestedNonNegativeInt(%#v) ok=%v want=%v", tc.value, ok, tc.want) + } + } + + for _, raw := range []string{ + `[]`, `[1,["p",{},"x"]]`, `["span",{},"a","b"]`, + `["p",{"block_id":"x","custom":true},"x"]`, `true`, + } { + if normalizeJSONMLForVerification(raw) == "" { + t.Fatalf("empty normalized JSONML for %s", raw) + } + } + if !isGeneratedTextSpan(nil) || isGeneratedTextSpan(map[string]any{"a": 1, "b": 2}) || isGeneratedTextSpan(map[string]any{"data-type": 3}) || isGeneratedTextSpan(map[string]any{"data-type": "other"}) { + t.Fatal("generated span classification failed") + } +} diff --git a/internal/shortcut/doc/doc_shortcuts_test.go b/internal/shortcut/doc/doc_shortcuts_test.go index 0ef06fab..81e2e238 100644 --- a/internal/shortcut/doc/doc_shortcuts_test.go +++ b/internal/shortcut/doc/doc_shortcuts_test.go @@ -15,6 +15,7 @@ import ( "strings" "sync" "testing" + "time" "unicode/utf8" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" @@ -112,6 +113,7 @@ func runDocCoverageInput(t *testing.T, declaration shortcut.Shortcut, caller *do func runDocCoveragePath(t *testing.T, declaration shortcut.Shortcut, caller *docCoverageCaller, input io.Reader, commandPath string, args ...string) error { t.Helper() + testseam.Swap(t, &docVerifySleep, func(time.Duration) {}) helpers.InitDeps(caller) root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} root.PersistentFlags().Bool("yes", false, "") @@ -491,6 +493,7 @@ func TestCrossPlatformCoverageDocUpdateAliasReachesNestedBranches(t *testing.T) } func TestCrossPlatformCoverageDocWritesStopOnUnknownCommitAndRequireVerification(t *testing.T) { + testseam.Swap(t, &docVerifyDelays, []time.Duration{}) unknown := &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}} err := runDocCoverage(t, Update, unknown, "--node", "n", "--command", "append", "--content", "x", "--yes") var typed *apperrors.Error @@ -512,6 +515,7 @@ func TestCrossPlatformCoverageDocWritesStopOnUnknownCommitAndRequireVerification } func TestCrossPlatformCoverageDocCreateRejectsSuccessfulMismatchedReadback(t *testing.T) { + testseam.Swap(t, &docVerifyDelays, []time.Duration{}) caller := &docCoverageCaller{responses: map[string][]map[string]any{ "get_document_content": {{"markdown": "truncated"}}, }} @@ -577,8 +581,15 @@ func TestCrossPlatformCoverageDocVersionRevertPaginationAndVerification(t *testi "revert_doc_version": {{}}, "get_document_info": {{"nodeId": "n", "revision": 99.0}}, }} - if err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes"); err != nil { - t.Fatal(err) + err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes") + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "doc_history_revert_target_unproven" || typed.FailureStage != "verify" || typed.Details["status"] != "partial_success" { + t.Fatalf("unproven revert error = %#v", err) + } + data, _ := typed.Details["data"].(map[string]any) + steps, _ := typed.Details["steps"].([]map[string]any) + if data["verified"] != false || len(steps) != 3 || steps[2]["status"] != "failed" { + t.Fatalf("unproven revert details = %#v", typed.Details) } }) diff --git a/internal/shortcut/doc/history_template_shortcuts.go b/internal/shortcut/doc/history_template_shortcuts.go index ea9ac94d..048ae9bd 100644 --- a/internal/shortcut/doc/history_template_shortcuts.go +++ b/internal/shortcut/doc/history_template_shortcuts.go @@ -178,15 +178,47 @@ func executeHistoryRevert(rt *shortcut.RuntimeContext) error { map[string]any{"available": false, "reason": "the requested revert completed; verify the current document before any further write"}, ) } + verified := revertResultMatchesVersion(reverted, target) || currentDocumentMatchesRestoredVersion(current, target) + if !verified { + return docPartialWriteError( + "doc.history_revert", "doc_history_revert_target_unproven", "verify", + fmt.Sprintf("版本 %d 的回滚请求已执行且文档可读,但响应没有提供目标版本证据;不要直接重试回滚", target), + fmt.Errorf("回读缺少目标版本 %d 的明确证据", target), + map[string]any{ + "nodeId": nodeID, "version": target, "reverted": true, "verified": false, + "revertResult": reverted, "current": current, + }, + []map[string]any{ + {"name": "preflight", "status": "success"}, + {"name": "revert", "status": "success"}, + {"name": "verify", "status": "failed"}, + }, + map[string]any{"available": false, "reason": "the revert may have completed; inspect version history before any further revert"}, + ) + } return rt.Output(docEnvelope("doc.history_revert", map[string]any{ "version": target, "revertResult": reverted, "current": current, "verified": true, - "verification": "revert_acknowledged_and_document_readable", + "verification": "target_version_proven", }, map[string]any{"name": "preflight", "status": "success"}, map[string]any{"name": "revert", "status": "success"}, map[string]any{"name": "verify", "status": "success"})) } +func revertResultMatchesVersion(value map[string]any, target int) bool { + return versionEvidenceMatches(value, target, map[string]bool{ + "version": true, "targetversion": true, "appliedversion": true, + "restoredversion": true, "revertedversion": true, "revertedtoversion": true, "sourceversion": true, + }) +} + +func currentDocumentMatchesRestoredVersion(value map[string]any, target int) bool { + return versionEvidenceMatches(value, target, map[string]bool{ + "restoredfromversion": true, "revertedfromversion": true, + "sourceversion": true, "appliedversion": true, "targetversion": true, + }) +} + func findHistoryVersion(rt *shortcut.RuntimeContext, nodeID string, target int) (bool, error) { const maxPages = 20 cursor := ""