diff --git a/internal/app/schema_agent_examples_dry_run_test.go b/internal/app/schema_agent_examples_dry_run_test.go index fa4eca28..b54d9a58 100644 --- a/internal/app/schema_agent_examples_dry_run_test.go +++ b/internal/app/schema_agent_examples_dry_run_test.go @@ -35,6 +35,7 @@ import ( var ( manualAgentExamplePlaceholderPattern = regexp.MustCompile(`<([^>]+)>`) manualAgentExampleDryRunJSONPattern = regexp.MustCompile(`(?i)"dry_run"\s*:\s*true`) + manualAgentExampleDryRunPlanPattern = regexp.MustCompile(`(?i)"preview_kind"\s*:\s*"plan"`) ) // TestManualAgentExamplesContract is the always-on gate. It validates every @@ -391,6 +392,9 @@ func manualAgentExampleDryRunObserved(capture manualAgentExampleCapture) bool { func manualAgentExampleDryRunEvidence(capture manualAgentExampleCapture) (string, bool) { normalized := strings.ToLower(capture.Output) + if manualAgentExampleDryRunJSONPattern.MatchString(capture.Output) && manualAgentExampleDryRunPlanPattern.MatchString(capture.Output) { + return cli.DryRunPreviewPlan, true + } if manualAgentExampleDryRunJSONPattern.MatchString(capture.Output) { return cli.DryRunPreviewRequest, true } @@ -428,6 +432,22 @@ func TestManualAgentExampleDryRunEvidenceAcceptsSharedAndCommandPlans(t *testing } } +func TestCrossPlatformCoverageManualAgentExampleDryRunEvidenceClassifiesStructuredPlan(t *testing.T) { + kind, observed := manualAgentExampleDryRunEvidence(manualAgentExampleCapture{ + Output: `{"dry_run":true,"executed":false,"preview_kind":"plan"}`, + DryRunChecks: 1, + }) + if !observed || kind != cli.DryRunPreviewPlan { + t.Fatalf("structured plan classified as kind=%q observed=%v", kind, observed) + } + if manualAgentExampleDryRunObserved(manualAgentExampleCapture{ + Output: `{"dry_run":false,"executed":false,"preview_kind":"plan"}`, + DryRunChecks: 1, + }) { + t.Fatal("non-dry-run structured plan was accepted as evidence") + } +} + func manualAgentExamplePromptObserved(output string) bool { normalized := strings.ToLower(output) for _, marker := range []string{ diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index 9d718822..2d75799d 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -249,6 +249,14 @@ func callMCPToolInternalOpts(explicitServerID, toolName string, args map[string] // DryRun 模式:仅预览工具名和参数,不实际调用 MCP Server if deps.Caller.DryRun() { + if deps.Caller.Format() == "json" { + return deps.Out.PrintJSON(map[string]any{ + "dry_run": true, + "executed": false, + "tool": toolName, + "arguments": args, + }) + } bold := color.New(color.FgYellow, color.Bold) bold.Println("[DRY-RUN] Preview only, not executed:") deps.Out.PrintKeyValue("Tool", toolName) diff --git a/internal/helpers/helpers_core_test.go b/internal/helpers/helpers_core_test.go index 8ebb89ee..f96b3dbd 100644 --- a/internal/helpers/helpers_core_test.go +++ b/internal/helpers/helpers_core_test.go @@ -3,9 +3,11 @@ package helpers import ( "bytes" "context" + "encoding/json" "errors" "os" "path/filepath" + "reflect" "strings" "testing" @@ -176,6 +178,30 @@ func TestCrossPlatformCoverageMCPOutputModesAndDevdocFormatting(t *testing.T) { } } +func TestCrossPlatformCoverageMCPDryRunJSONOutputIsSingleDocument(t *testing.T) { + caller := &helpersCoreCaller{format: "json", dry: true} + out, _ := installHelpersCoreDeps(t, caller) + + args := map[string]any{"name": "weekly", "enabled": true} + if err := callMCPToolInternalOpts("server", "create_workflow", args, false); err != nil { + t.Fatalf("dry run returned error: %v", err) + } + if caller.calls != 0 { + t.Fatalf("dry run called tool %d times, want 0", caller.calls) + } + + var payload map[string]any + if err := json.Unmarshal(out.Bytes(), &payload); err != nil { + t.Fatalf("dry-run stdout must be one JSON document: %v\n%s", err, out.String()) + } + if payload["dry_run"] != true || payload["executed"] != false || payload["tool"] != "create_workflow" { + t.Fatalf("unexpected dry-run payload: %#v", payload) + } + if !reflect.DeepEqual(payload["arguments"], map[string]any{"name": "weekly", "enabled": true}) { + t.Fatalf("arguments = %#v", payload["arguments"]) + } +} + func TestCrossPlatformCoverageCurrentUserResponseShapes(t *testing.T) { caller := &helpersCoreCaller{} installHelpersCoreDeps(t, caller) diff --git a/internal/helpers/import_flow.go b/internal/helpers/import_flow.go index cc32258b..3a8b8eb9 100644 --- a/internal/helpers/import_flow.go +++ b/internal/helpers/import_flow.go @@ -202,8 +202,21 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e if err != nil { return err } + jsonMode := deps.Caller.Format() == "json" if deps.Caller.DryRun() { + if jsonMode { + return deps.Out.PrintJSON(map[string]any{ + "dry_run": true, + "executed": false, + "preview_kind": "plan", + "operation": cfg.operation, + "file": file.path, + "name": file.name, + "format": file.extension, + "size": file.size, + }) + } deps.Out.PrintKeyValue("操作", cfg.operation) deps.Out.PrintKeyValue("文件", file.path) deps.Out.PrintKeyValue("名称", file.name) @@ -217,7 +230,9 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e ctx = context.Background() } - deps.Out.PrintInfo("[1/4] 创建导入会话...") + if !jsonMode { + deps.Out.PrintInfo("[1/4] 创建导入会话...") + } sessionArgs := map[string]any{ "fileName": file.name, "suffix": file.extension, @@ -241,18 +256,28 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e sessionID, _ := sessionResult["sessionId"].(string) uploadURL, _ := sessionResult["uploadUrl"].(string) if sessionID == "" || uploadURL == "" { - deps.Out.PrintRaw(sessionText) + if !jsonMode { + deps.Out.PrintRaw(sessionText) + } return fmt.Errorf("创建导入会话成功但缺少 sessionId 或 uploadUrl") } - deps.Out.PrintInfo(fmt.Sprintf(" 会话已创建,sessionId: %s", sessionID)) + if !jsonMode { + deps.Out.PrintInfo(fmt.Sprintf(" 会话已创建,sessionId: %s", sessionID)) + } - deps.Out.PrintInfo("[2/4] 上传文件...") + if !jsonMode { + deps.Out.PrintInfo("[2/4] 上传文件...") + } if err := httpPutFile(ctx, uploadURL, nil, file.path, file.size); err != nil { return fmt.Errorf("文件上传失败 (sessionId=%s): %w", sessionID, err) } - deps.Out.PrintInfo(" 文件上传完成") + if !jsonMode { + deps.Out.PrintInfo(" 文件上传完成") + } - deps.Out.PrintInfo("[3/4] 确认导入,启动格式转换...") + if !jsonMode { + deps.Out.PrintInfo("[3/4] 确认导入,启动格式转换...") + } confirmText, err := cfg.callTool(ctx, "confirm_import", map[string]any{"sessionId": sessionID}) if err != nil { return fmt.Errorf("确认导入失败 (sessionId=%s): %w", sessionID, err) @@ -263,12 +288,18 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e } taskID, _ := confirmResult["taskId"].(string) if taskID == "" { - deps.Out.PrintRaw(confirmText) + if !jsonMode { + deps.Out.PrintRaw(confirmText) + } return fmt.Errorf("确认导入成功但未返回 taskId") } - deps.Out.PrintInfo(fmt.Sprintf(" 转换任务已提交,taskId: %s", taskID)) + if !jsonMode { + deps.Out.PrintInfo(fmt.Sprintf(" 转换任务已提交,taskId: %s", taskID)) + } - deps.Out.PrintInfo("[4/4] 等待格式转换完成...") + if !jsonMode { + deps.Out.PrintInfo("[4/4] 等待格式转换完成...") + } result, err := pollImportTask(ctx, taskID, cfg) if err != nil { var timeoutErr *importPollTimeoutError @@ -276,15 +307,16 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e return err } if cfg.timeoutAsResult { - deps.Out.PrintInfo(timeoutErr.Error()) - _ = deps.Out.PrintJSON(map[string]any{ + if !jsonMode { + deps.Out.PrintInfo(timeoutErr.Error()) + } + return deps.Out.PrintJSON(map[string]any{ "success": false, "timed_out": true, "taskId": taskID, "status": "processing", "next_command": fmt.Sprintf(cfg.nextCommand, taskID), }) - return nil } return fmt.Errorf("%s,请稍后使用 %s 手动查询", timeoutErr.Error(), fmt.Sprintf(cfg.nextCommand, taskID)) } @@ -302,9 +334,10 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e if cfg.includeNodeID { finalResult["nodeId"] = extractNodeIDFromDocURL(documentURL) } - deps.Out.PrintInfo(fmt.Sprintf("导入完成: %s", documentURL)) - _ = deps.Out.PrintJSON(finalResult) - return nil + if !jsonMode { + deps.Out.PrintInfo(fmt.Sprintf("导入完成: %s", documentURL)) + } + return deps.Out.PrintJSON(finalResult) } func runImportGetCommand(cmd *cobra.Command, cfg importFlowConfig) error { @@ -313,6 +346,15 @@ func runImportGetCommand(cmd *cobra.Command, cfg importFlowConfig) error { return fmt.Errorf("flag --task-id is required") } if deps.Caller.DryRun() { + if deps.Caller.Format() == "json" { + return deps.Out.PrintJSON(map[string]any{ + "dry_run": true, + "executed": false, + "preview_kind": "plan", + "operation": cfg.queryOperation, + "taskId": taskID, + }) + } deps.Out.PrintKeyValue("操作", cfg.queryOperation) deps.Out.PrintKeyValue("任务ID", taskID) return nil @@ -361,7 +403,9 @@ func pollImportTask(ctx context.Context, taskID string, cfg importFlowConfig) (m } for attempt := 1; attempt <= poll.maxPolls; attempt++ { interval := poll.interval(attempt) - deps.Out.PrintInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, poll.maxPolls, interval)) + if deps.Caller.Format() != "json" { + deps.Out.PrintInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, poll.maxPolls, interval)) + } if err := poll.wait(ctx, interval); err != nil { return nil, fmt.Errorf("导入轮询被取消 (taskId=%s): %w", taskID, err) } diff --git a/internal/helpers/sheet_import_test.go b/internal/helpers/sheet_import_test.go index fab80a83..dfbe4af9 100644 --- a/internal/helpers/sheet_import_test.go +++ b/internal/helpers/sheet_import_test.go @@ -130,7 +130,7 @@ func TestSheetImportRejectsInvalidInputBeforeRemoteCall(t *testing.T) { } } -func TestSheetImportRunsSharedDocImportFlow(t *testing.T) { +func TestCrossPlatformCoverageSheetImportRunsSharedDocImportFlow(t *testing.T) { caller := &sheetImportCaller{responses: map[string][]string{ "create_import_session": {`{"sessionId":"session-1","uploadUrl":"https://upload.example.test/object"}`}, "confirm_import": {`{"taskId":"task-1"}`}, @@ -171,12 +171,16 @@ func TestSheetImportRunsSharedDocImportFlow(t *testing.T) { t.Fatalf("session args = %#v, want %#v", caller.calls[0].args, wantSession) } - if !strings.Contains(output, `"nodeId": "node-1"`) || !strings.Contains(output, `"success": true`) { - t.Fatalf("output missing success contract:\n%s", output) + var payload map[string]any + if err := json.Unmarshal([]byte(output), &payload); err != nil { + t.Fatalf("sheet import stdout must be one JSON document: %v\n%s", err, output) + } + if payload["nodeId"] != "node-1" || payload["success"] != true { + t.Fatalf("output missing success contract: %#v", payload) } } -func TestSheetImportCreateLeafMatchesLegacyDryRun(t *testing.T) { +func TestCrossPlatformCoverageSheetImportCreateLeafMatchesLegacyDryRun(t *testing.T) { filePath := writeImportFixture(t, "xlsx") legacy, err := executeSheetImportCommand(t, &sheetImportCaller{dryRun: true}, fastSheetImportConfig(), "--file", filePath, "--workspace", "workspace-1", "--name", "Sales") @@ -191,6 +195,13 @@ func TestSheetImportCreateLeafMatchesLegacyDryRun(t *testing.T) { if leaf != legacy { t.Fatalf("create leaf output differs from legacy entry:\nlegacy:\n%s\nleaf:\n%s", legacy, leaf) } + var payload map[string]any + if err := json.Unmarshal([]byte(legacy), &payload); err != nil { + t.Fatalf("sheet import dry-run stdout must be one JSON document: %v\n%s", err, legacy) + } + if payload["dry_run"] != true || payload["executed"] != false || payload["preview_kind"] != "plan" || payload["operation"] != "导入本地表格文件为在线电子表格" { + t.Fatalf("unexpected dry-run payload: %#v", payload) + } root := newSheetImportCmdWithConfig(fastSheetImportConfig()) create, _, err := root.Find([]string{"create"}) @@ -202,7 +213,7 @@ func TestSheetImportCreateLeafMatchesLegacyDryRun(t *testing.T) { } } -func TestSheetImportTimeoutIsStructuredSuccessExit(t *testing.T) { +func TestCrossPlatformCoverageSheetImportTimeoutIsStructuredSuccessExit(t *testing.T) { cfg := fastSheetImportConfig() cfg.poll.maxPolls = 2 caller := &sheetImportCaller{responses: map[string][]string{ @@ -220,15 +231,12 @@ func TestSheetImportTimeoutIsStructuredSuccessExit(t *testing.T) { if err != nil { t.Fatalf("timeout should exit successfully, got %v", err) } - for _, fragment := range []string{ - `"success": false`, - `"timed_out": true`, - `"status": "processing"`, - `"next_command": "dws sheet import get --task-id task-1"`, - } { - if !strings.Contains(output, fragment) { - t.Fatalf("output missing %s:\n%s", fragment, output) - } + var payload map[string]any + if err := json.Unmarshal([]byte(output), &payload); err != nil { + t.Fatalf("timeout stdout must be one JSON document: %v\n%s", err, output) + } + if payload["success"] != false || payload["timed_out"] != true || payload["status"] != "processing" || payload["next_command"] != "dws sheet import get --task-id task-1" { + t.Fatalf("unexpected timeout payload: %#v", payload) } } @@ -248,8 +256,7 @@ func TestSheetImportGetAddsNodeIDAndUsesDocServer(t *testing.T) { } var result map[string]any - jsonStart := strings.Index(output, "{") - if jsonStart < 0 || json.Unmarshal([]byte(output[jsonStart:]), &result) != nil { + if json.Unmarshal([]byte(output), &result) != nil { t.Fatalf("invalid JSON output:\n%s", output) } if result["nodeId"] != "node-2" { @@ -257,6 +264,25 @@ func TestSheetImportGetAddsNodeIDAndUsesDocServer(t *testing.T) { } } +func TestCrossPlatformCoverageSheetImportGetDryRunJSONIsSingleDocument(t *testing.T) { + caller := &sheetImportCaller{dryRun: true} + output, err := executeSheetImportCommand(t, caller, fastSheetImportConfig(), "get", "--task-id", "task-dry-run") + if err != nil { + t.Fatalf("sheet import get dry-run returned error: %v", err) + } + if len(caller.calls) != 0 { + t.Fatalf("dry-run remote calls = %#v, want none", caller.calls) + } + + var payload map[string]any + if err := json.Unmarshal([]byte(output), &payload); err != nil { + t.Fatalf("sheet import get dry-run stdout must be one JSON document: %v\n%s", err, output) + } + if payload["dry_run"] != true || payload["executed"] != false || payload["preview_kind"] != "plan" || payload["taskId"] != "task-dry-run" || payload["operation"] != "查询表格导入任务结果" { + t.Fatalf("unexpected sheet import get dry-run payload: %#v", payload) + } +} + func TestDocImportConfigPreservesExistingContract(t *testing.T) { cfg := docImportFlowConfig() for _, ext := range []string{"docx", "doc", "xlsx", "xls", "md", "txt", "xmind", "mark"} { @@ -269,7 +295,7 @@ func TestDocImportConfigPreservesExistingContract(t *testing.T) { } } -func TestDocImportDryRunStillAcceptsMarkdownWithoutTarget(t *testing.T) { +func TestCrossPlatformCoverageDocImportDryRunStillAcceptsMarkdownWithoutTarget(t *testing.T) { previousDeps := deps previousArgs := os.Args t.Cleanup(func() { @@ -293,8 +319,12 @@ func TestDocImportDryRunStillAcceptsMarkdownWithoutTarget(t *testing.T) { if len(caller.calls) != 0 { t.Fatalf("dry-run made %d remote calls", len(caller.calls)) } - if !strings.Contains(output.String(), "导入本地文件为在线文档") || !strings.Contains(output.String(), "md") { - t.Fatalf("unexpected dry-run output:\n%s", output.String()) + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatalf("doc import dry-run stdout must be one JSON document: %v\n%s", err, output.String()) + } + if payload["operation"] != "导入本地文件为在线文档" || payload["format"] != "md" || payload["dry_run"] != true || payload["preview_kind"] != "plan" { + t.Fatalf("unexpected dry-run output: %#v", payload) } } diff --git a/internal/helpers/sheet_style.go b/internal/helpers/sheet_style.go index 1740ca4a..71745b63 100644 --- a/internal/helpers/sheet_style.go +++ b/internal/helpers/sheet_style.go @@ -462,11 +462,11 @@ func newRangeBatchSetStyleCmd() *cobra.Command { } fmt.Fprintf(os.Stderr, "[%d/%d] update_range sheet=%s range=%s\n", i+1, total, item.SheetID, item.Range) if deps.Caller.DryRun() { - // The batch command normally uses callMCPToolReturnText in - // JSON mode, which intentionally performs a real call. Keep - // the dry-run guard before both output branches so neither - // format can bypass the shared no-network preview path. - _ = callMCPTool("update_range", toolArgs) + // JSON mode emits one aggregate preview below. Human formats + // reuse the shared per-call preview printer. + if !jsonMode { + _ = callMCPTool("update_range", toolArgs) + } if jsonMode { jsonResults = append(jsonResults, map[string]any{ "index": i + 1, diff --git a/internal/helpers/sheet_style_test.go b/internal/helpers/sheet_style_test.go index e89c293f..13585e46 100644 --- a/internal/helpers/sheet_style_test.go +++ b/internal/helpers/sheet_style_test.go @@ -7,6 +7,7 @@ package helpers import ( "bytes" "context" + "encoding/json" "os" "path/filepath" "strings" @@ -30,7 +31,7 @@ func (*sheetStyleDryRunCaller) DryRun() bool { return true } func (*sheetStyleDryRunCaller) Fields() string { return "" } func (*sheetStyleDryRunCaller) JQ() string { return "" } -func TestRangeBatchSetStyleDryRunNeverCallsRemote(t *testing.T) { +func TestCrossPlatformCoverageRangeBatchSetStyleDryRunNeverCallsRemote(t *testing.T) { previousDeps := deps t.Cleanup(func() { deps = previousDeps }) @@ -56,13 +57,25 @@ func TestRangeBatchSetStyleDryRunNeverCallsRemote(t *testing.T) { t.Fatalf("remote CallTool count = %d, want 0", caller.calls) } preview := output.String() - for _, want := range []string{"Tool:", "update_range", "Arguments:"} { - if !strings.Contains(preview, want) { - t.Fatalf("dry-run preview missing %q:\n%s", want, preview) + if format == "json" { + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatalf("JSON dry-run stdout must be one document: %v\n%s", err, preview) + } + results, _ := payload["results"].([]any) + if len(results) != 1 { + t.Fatalf("JSON dry-run results = %#v", payload["results"]) + } + entry, _ := results[0].(map[string]any) + if entry["dryRun"] != true || entry["tool"] != "update_range" { + t.Fatalf("JSON dry-run result = %#v", entry) + } + } else { + for _, want := range []string{"Tool:", "update_range", "Arguments:"} { + if !strings.Contains(preview, want) { + t.Fatalf("dry-run preview missing %q:\n%s", want, preview) + } } - } - if format == "json" && !strings.Contains(preview, `"dryRun": true`) { - t.Fatalf("JSON dry-run summary missing typed dryRun evidence:\n%s", preview) } }) }