fix(im): preserve incomplete read failures

This commit is contained in:
Dennis committed 2026-08-05 14:27:53 +08:00
1 parent b6325a4f8a
commit 9fdf0d2cb3
6 files changed
+185 -7

No files matched your search

+31
View File
@@ -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
}
+64 -2
View File
@@ -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 {
@@ -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
@@ -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) {
@@ -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
}
@@ -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) {