fix(doc): harden mutation readback verification

This commit is contained in:
Dennis
2026-08-17 14:47:51 +08:00
parent a0be395ccc
commit 3ee66d4373
8 changed files with 918 additions and 43 deletions
+1
View File
@@ -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
+2
View File
@@ -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=
+178 -6
View File
@@ -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
+115 -1
View File
@@ -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() {
+301 -33
View File
@@ -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 {
@@ -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")
}
}
+13 -2
View File
@@ -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)
}
})
@@ -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 := ""