diff --git a/internal/shortcut/smart/chat_messages.go b/internal/shortcut/smart/chat_messages.go index 22490df8..3348c822 100644 --- a/internal/shortcut/smart/chat_messages.go +++ b/internal/shortcut/smart/chat_messages.go @@ -240,6 +240,14 @@ func executeChatMessages(rt *shortcut.RuntimeContext) error { payload, rawItems, err = collectOneChatMessagesPage(rt, request) } if err != nil { + // Full-page collection returns its failure ledger together with a + // non-zero error. Publish that ledger for diagnosis, but stop before + // resource downloads or a requested export can look successful. + if payload != nil { + if outputErr := rt.Output(payload); outputErr != nil { + return outputErr + } + } return err } if rt.Bool("download-resources") { @@ -418,6 +426,29 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq if hasMore && nextPage != nil { payload["nextPage"] = nextPage } + if len(failures) > 0 { + failureStage := "pagination" + if stopReason == "read_failure" { + failureStage = "read" + } + return payload, allItems, apperrors.NewAPI( + fmt.Sprintf("全量消息读取未完成:%d 页成功,%d 个页面失败", pagesFetched, len(failures)), + apperrors.WithOperation("chat/"+request.tool), + apperrors.WithReason("chat_messages_incomplete"), + apperrors.WithOrigin("mcp_gateway"), + apperrors.WithFailureStage(failureStage), + apperrors.WithExecutionStarted(true), + apperrors.WithRetryable(true), + apperrors.WithHint("请根据 failures 和 nextPage 重试;失败 ledger 不会写入 --output 文件"), + apperrors.WithDetails(map[string]any{ + "pagesFetched": pagesFetched, + "failedCount": len(failures), + "failures": failures, + "partial": len(results) > 0, + "stopReason": stopReason, + }), + ) + } return payload, allItems, nil } diff --git a/internal/shortcut/smart/chat_messages_test.go b/internal/shortcut/smart/chat_messages_test.go index d6bb5afe..3200faca 100644 --- a/internal/shortcut/smart/chat_messages_test.go +++ b/internal/shortcut/smart/chat_messages_test.go @@ -17,9 +17,11 @@ import ( "bytes" "context" "encoding/json" + stderrors "errors" "os" "testing" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -99,6 +101,13 @@ func TestCrossPlatformCoverageProjectChatMessageExpandsForwarded(t *testing.T) { type chatMessagesPagingCaller struct { responses []string args []map[string]any + failAt int +} + +type chatMessagesFailWriter struct{} + +func (chatMessagesFailWriter) Write([]byte) (int, error) { + return 0, stderrors.New("fixture output failure") } func (c *chatMessagesPagingCaller) CallTool( @@ -112,6 +121,9 @@ func (c *chatMessagesPagingCaller) CallTool( } c.args = append(c.args, copied) index := len(c.args) - 1 + if c.failAt > 0 && len(c.args) == c.failAt { + return nil, stderrors.New("fixture read failure") + } if index >= len(c.responses) { index = len(c.responses) - 1 } @@ -200,8 +212,12 @@ func TestCrossPlatformCoverageChatMessagesPageAllFailsClosedOnStalledBoundary(t var output bytes.Buffer root.SetOut(&output) root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--page-all"}) - if err := root.Execute(); err != nil { - t.Fatal(err) + err := root.Execute() + var typed *apperrors.Error + if !stderrors.As(err, &typed) || typed.Category != apperrors.CategoryAPI || + typed.Reason != "chat_messages_incomplete" || !typed.Retryable || + typed.ExecutionStarted == nil || !*typed.ExecutionStarted { + t.Fatalf("error = %#v", err) } var payload map[string]any if err := json.Unmarshal(output.Bytes(), &payload); err != nil { @@ -213,6 +229,52 @@ func TestCrossPlatformCoverageChatMessagesPageAllFailsClosedOnStalledBoundary(t } } +func TestCrossPlatformCoverageChatMessagesFailedPageDoesNotExportPartialLedger(t *testing.T) { + t.Chdir(t.TempDir()) + caller := &chatMessagesPagingCaller{ + responses: []string{ + `{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`, + }, + failAt: 2, + } + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{ + "chat", "+chat-messages", "--conversation-id", "cid", "--page-all", + "--output", "exports/partial.json", + }) + err := root.Execute() + var typed *apperrors.Error + if !stderrors.As(err, &typed) || typed.Reason != "chat_messages_incomplete" { + t.Fatalf("error = %#v", err) + } + if _, statErr := os.Lstat("exports/partial.json"); !os.IsNotExist(statErr) { + t.Fatalf("partial export exists: %v", statErr) + } + var ledger map[string]any + if err := json.Unmarshal(output.Bytes(), &ledger); err != nil { + t.Fatal(err) + } + if ledger["partial"] != true || ledger["failedCount"] != float64(1) || ledger["count"] != float64(1) { + t.Fatalf("failure ledger = %#v", ledger) + } +} + +func TestCrossPlatformCoverageChatMessagesFailureLedgerOutputErrorIsNonZero(t *testing.T) { + caller := &chatMessagesPagingCaller{responses: []string{ + `{"result":{"hasMore":true,"messages":[{"openMessageId":"m1"}]}}`, + }} + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + root.SetOut(chatMessagesFailWriter{}) + root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--page-all"}) + if err := root.Execute(); err == nil || err.Error() != "fixture output failure" { + t.Fatalf("error = %v", err) + } +} + func TestCrossPlatformCoverageChatMessagesExportIsAtomicAndNoClobber(t *testing.T) { t.Chdir(t.TempDir()) newCaller := func() *chatMessagesPagingCaller { diff --git a/internal/shortcut/smart/compatibility_coverage_test.go b/internal/shortcut/smart/compatibility_coverage_test.go index 21f14839..175eab7f 100644 --- a/internal/shortcut/smart/compatibility_coverage_test.go +++ b/internal/shortcut/smart/compatibility_coverage_test.go @@ -17,6 +17,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" "reflect" "strings" @@ -40,10 +41,14 @@ type platformCoverageCaller struct { dry bool contactSearchResult string aisearchResult string + failTool string } func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) { f.calls = append(f.calls, platformCoverageCall{product: product, tool: tool, args: args}) + if product+"/"+tool == f.failTool { + return nil, errors.New("fixture failure") + } text := `{"result":[]}` switch product + "/" + tool { case "contact/search_contact_by_key_word": @@ -323,6 +328,49 @@ func TestCrossPlatformCoverageBroadcastUsesEnterpriseAliasAndUserIDFallback(t *t } } +func TestCrossPlatformCoverageBroadcastRecipientFallbackAndSendFailure(t *testing.T) { + t.Run("missing display name falls back to query", func(t *testing.T) { + fake := &platformCoverageCaller{ + dry: true, + aisearchResult: `{"result":[{"userId":"user-no-name"}]}`, + } + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{ + "chat", "+broadcast", "--to", "无名用户", "--text", "你好", "--dry-run", "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + actions, _ := payload["actions"].([]any) + action, _ := actions[0].(map[string]any) + if action["recipient"] != "无名用户" { + t.Fatalf("action = %#v", action) + } + }) + + t.Run("send failure remains non-zero", func(t *testing.T) { + fake := &platformCoverageCaller{failTool: "chat/send_personal_message"} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+broadcast", "--to", "张三", "--text", "你好", "--yes", + }) + if err := root.Execute(); err == nil { + t.Fatal("send failure unexpectedly succeeded") + } + if len(fake.calls) != 2 || fake.calls[1].tool != "send_personal_message" { + t.Fatalf("calls = %#v", fake.calls) + } + }) +} + func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) { tests := []struct { name string diff --git a/internal/shortcut/smart/coverage_completion_test.go b/internal/shortcut/smart/coverage_completion_test.go index 42732d95..fc522911 100644 --- a/internal/shortcut/smart/coverage_completion_test.go +++ b/internal/shortcut/smart/coverage_completion_test.go @@ -452,11 +452,11 @@ func TestCrossPlatformCoverageChatMessagesValidationAndFailureBoundaries(t *test wantError bool }{ {name: "single read failure", failAt: 1, args: []string{"--group", "cid123456789"}, wantError: true}, - {name: "all first read failure", failAt: 1, args: []string{"--group", "cid123456789", "--page-all"}}, - {name: "all later read failure", responses: []string{`{"result":{"messages":[{"openMessageId":"m1","createTime":"2"}],"hasMore":true}}`}, failAt: 2, args: []string{"--group", "cid123456789", "--page-all"}}, - {name: "missing pagination", responses: []string{`{"result":{"messages":[]}}`}, args: []string{"--group", "cid123456789", "--page-all"}}, - {name: "empty page with continuation", responses: []string{`{"result":{"messages":[],"hasMore":true}}`}, args: []string{"--group", "cid123456789", "--page-all"}}, - {name: "result limit without boundary", responses: []string{`{"result":{"messages":[{"openMessageId":"m1"},{"openMessageId":"m2"}],"hasMore":true}}`}, args: []string{"--group", "cid123456789", "--page-all", "--max-results", "1"}}, + {name: "all first read failure", failAt: 1, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true}, + {name: "all later read failure", responses: []string{`{"result":{"messages":[{"openMessageId":"m1","createTime":"2"}],"hasMore":true}}`}, failAt: 2, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true}, + {name: "missing pagination", responses: []string{`{"result":{"messages":[]}}`}, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true}, + {name: "empty page with continuation", responses: []string{`{"result":{"messages":[],"hasMore":true}}`}, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true}, + {name: "result limit without boundary", responses: []string{`{"result":{"messages":[{"openMessageId":"m1"},{"openMessageId":"m2"}],"hasMore":true}}`}, args: []string{"--group", "cid123456789", "--page-all", "--max-results", "1"}, wantError: true}, {name: "result limit with boundary", responses: []string{`{"result":{"messages":[{"openMessageId":"m1","createTime":"2"},{"openMessageId":"m2","createTime":"1"}],"hasMore":true}}`}, args: []string{"--group", "cid123456789", "--page-all", "--max-results", "1"}}, } { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/shortcut/targetresolver/resolver.go b/internal/shortcut/targetresolver/resolver.go index f338ebda..a4c6bd21 100644 --- a/internal/shortcut/targetresolver/resolver.go +++ b/internal/shortcut/targetresolver/resolver.go @@ -724,6 +724,14 @@ func resolutionDetails(err error) (map[string]any, bool) { if !stderrors.As(err, &typed) || typed.Details["type"] != "resolution" { return nil, false } + switch typed.Reason { + case "resolution_not_found", "resolution_ambiguous": + // These are client-side disambiguation outcomes and may be combined + // into one batch response. Incomplete/API/auth failures must retain + // their original category and retryability and stop immediately. + default: + return nil, false + } return typed.Details, true } diff --git a/internal/shortcut/targetresolver/resolver_test.go b/internal/shortcut/targetresolver/resolver_test.go index f635f4d8..a975a7ae 100644 --- a/internal/shortcut/targetresolver/resolver_test.go +++ b/internal/shortcut/targetresolver/resolver_test.go @@ -537,6 +537,35 @@ func TestCrossPlatformCoverageResolverCompletionBranches(t *testing.T) { if _, err := ResolveChats(reader, []string{"upstream"}); err == nil { t.Fatal("batch chat transport error missing") } + + assertIncomplete := func(label string, err error) { + t.Helper() + var typed *apperrors.Error + if !stderrors.As(err, &typed) || typed.Category != apperrors.CategoryAPI || + typed.Reason != "resolution_incomplete" || !typed.Retryable { + t.Fatalf("%s error = %#v", label, err) + } + } + fullUsers := make([]any, userSearchDefaultResultLimit) + for i := range fullUsers { + fullUsers[i] = map[string]any{"userId": fmt.Sprintf("u%d", i), "name": "张三"} + } + _, err := ResolveUsers(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { + return map[string]any{"result": fullUsers}, nil + }), []string{"张三", "李四"}, IdentityUserID) + assertIncomplete("batch user", err) + + fullChats := make([]any, chatResolutionPageSize) + for i := range fullChats { + fullChats[i] = map[string]any{ + "openConversationId": fmt.Sprintf("cid-%d", i), + "title": "项目群", + } + } + _, err = ResolveChats(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { + return map[string]any{"result": fullChats}, nil + }), []string{"项目群", "研发群"}) + assertIncomplete("batch chat", err) }) t.Run("public projections and internal helpers", func(t *testing.T) {