fix(im): preserve incomplete read failures
This commit is contained in:
1 parent
b6325a4f8a
commit
9fdf0d2cb3
6 files changed
+185
-7
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user