From effe7c829ecbd95b37e7f16ce17c0965b3c07fc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E8=B0=A8?= <2940480744@qq.com> Date: Fri, 7 Aug 2026 15:48:29 +0800 Subject: [PATCH] fix(chat): align message workflows and diagnostics --- internal/app/schema_safety_contract_test.go | 9 + internal/app/server_failure_classifier.go | 1 + .../app/server_failure_classifier_test.go | 3 + internal/errors/coverage_edges_test.go | 2 +- internal/errors/errors.go | 18 +- internal/errors/errors_test.go | 31 +++- internal/helpers/chat.go | 34 ++-- internal/helpers/chat_card_update_test.go | 5 + internal/helpers/chat_command_edges_test.go | 2 +- .../chat_confirmation_alignment_test.go | 66 +++++++ internal/helpers/chat_message_search_test.go | 5 +- internal/helpers/chat_user_settings_test.go | 4 +- internal/shortcut/chat/chat_message.go | 77 +++++++-- internal/shortcut/chat/gap_fill_test.go | 58 +++++++ internal/shortcut/chat/lark_alignment_test.go | 2 + internal/shortcut/chat/unified_send.go | 15 +- internal/shortcut/chatmsg/card_ref.go | 65 +++++++ internal/shortcut/chatmsg/card_ref_test.go | 42 +++++ internal/shortcut/chatmsg/send_status.go | 162 ++++++++++++++++++ internal/shortcut/chatmsg/send_status_test.go | 63 +++++++ internal/transport/client.go | 2 + internal/transport/coverage_edges_test.go | 4 +- internal/transport/diagnostics.go | 36 +++- internal/transport/diagnostics_test.go | 18 ++ 24 files changed, 677 insertions(+), 47 deletions(-) create mode 100644 internal/helpers/chat_confirmation_alignment_test.go create mode 100644 internal/shortcut/chatmsg/card_ref.go create mode 100644 internal/shortcut/chatmsg/card_ref_test.go create mode 100644 internal/shortcut/chatmsg/send_status.go create mode 100644 internal/shortcut/chatmsg/send_status_test.go diff --git a/internal/app/schema_safety_contract_test.go b/internal/app/schema_safety_contract_test.go index 43b0dbc4..f618ab89 100644 --- a/internal/app/schema_safety_contract_test.go +++ b/internal/app/schema_safety_contract_test.go @@ -25,7 +25,16 @@ func TestReviewedMutationSafetyReachesFinalSchema(t *testing.T) { const declared = "contract_final" wants := []finalSchemaSafetyWant{ {canonical: "aitable.form_field_hide", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "idempotent", provenance: declared}, + {canonical: "chat.create_and_send_card", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared}, {canonical: "chat.dismiss_group", effect: "destructive", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared}, + {canonical: "chat.send_message_by_custom_robot", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared}, + {canonical: "chat.send_personal_message", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared}, + {canonical: "chat.send_robot_message", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared}, + {canonical: "chat.shortcut_messages_send", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared}, + {canonical: "chat.shortcut_messages_send_by_webhook", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared}, + {canonical: "chat.shortcut_messages_send_card", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared}, + {canonical: "chat.shortcut_messages_update_card", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared}, + {canonical: "chat.update_streaming_card", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared}, {canonical: "drive.recycle_restore", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared}, {canonical: "minutes.create_speaker_summary", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared}, {canonical: "sheet.clear_range", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared}, diff --git a/internal/app/server_failure_classifier.go b/internal/app/server_failure_classifier.go index 6ab9d8ea..978a06c1 100644 --- a/internal/app/server_failure_classifier.go +++ b/internal/app/server_failure_classifier.go @@ -81,6 +81,7 @@ func newServerFailureAPIError( apperrors.WithReason(fallbackReason), apperrors.WithServerKey(serverKey), apperrors.WithHint(fallbackHint), + apperrors.WithActions("运行 dws doctor 检查登录态、网络和本地环境;持续失败时保留 Trace ID 和 Server Code"), apperrors.WithServerDiag(diag), } if classified, ok := classifyServerFailure(message, diag); ok { diff --git a/internal/app/server_failure_classifier_test.go b/internal/app/server_failure_classifier_test.go index 489ecad0..df1a104d 100644 --- a/internal/app/server_failure_classifier_test.go +++ b/internal/app/server_failure_classifier_test.go @@ -95,6 +95,9 @@ func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing if typed.Reason != "business_error" || typed.Origin != "" || typed.FailureStage != "" || typed.ExecutionStarted != nil { t.Fatalf("unexpected fallback classification: %#v", typed) } + if len(typed.Actions) == 0 || !strings.Contains(typed.Actions[0], "dws doctor") { + t.Fatalf("fallback error has no stable troubleshooting entry: %#v", typed.Actions) + } } func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *testing.T) { diff --git a/internal/errors/coverage_edges_test.go b/internal/errors/coverage_edges_test.go index dd3c425f..36c678b6 100644 --- a/internal/errors/coverage_edges_test.go +++ b/internal/errors/coverage_edges_test.go @@ -41,7 +41,7 @@ func TestCrossPlatformCoverageDiagnosticsAndErrorRenderingEdges(t *testing.T) { t.Fatalf("PrintJSON friendly diagnostics = %q, %v", out.String(), err) } out.Reset() - if err := PrintHumanAt(&out, err, VerbosityVerbose); err != nil || !strings.Contains(out.String(), "开启地址") { + if err := PrintHumanAt(&out, err, VerbosityVerbose); err != nil || !strings.Contains(out.String(), "处理入口") { t.Fatalf("PrintHuman friendly diagnostics = %q, %v", out.String(), err) } out.Reset() diff --git a/internal/errors/errors.go b/internal/errors/errors.go index cb24dd25..cf755dce 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -19,6 +19,7 @@ import ( stderrors "errors" "fmt" "io" + "net/url" "strings" "time" @@ -460,7 +461,7 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error { lines = append(lines, tui.Cyan("Hint: "+friendlyHint)) } if actionURL != "" { - lines = append(lines, tui.White("Action: 开启地址: "+actionURL)) + lines = append(lines, tui.White("Action: 处理入口: "+actionURL)) } } @@ -540,7 +541,7 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error { func serverGuidance(diag ServerDiagnostics) (string, string) { friendlyHint := strings.TrimSpace(diag.FriendlyHint) - actionURL := strings.TrimSpace(diag.ActionURL) + actionURL := safeServerActionURL(diag.ActionURL) if friendlyHint == "" || actionURL == "" { switch diag.ServerErrorCode { case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED": @@ -555,6 +556,19 @@ func serverGuidance(diag ServerDiagnostics) (string, string) { return friendlyHint, actionURL } +func safeServerActionURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + parsed, err := url.Parse(raw) + if err != nil || !strings.EqualFold(parsed.Scheme, "https") || + parsed.Hostname() == "" || parsed.User != nil { + return "" + } + return parsed.String() +} + func category(err error) string { var typed *Error if stderrors.As(err, &typed) { diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go index d25e223f..96fd4636 100644 --- a/internal/errors/errors_test.go +++ b/internal/errors/errors_test.go @@ -336,11 +336,40 @@ func TestCrossPlatformCoveragePrintHumanIncludesServerGuidance(t *testing.T) { if !strings.Contains(got, "Hint: 请联系管理员开通消息搜索权益") { t.Fatalf("expected server guidance in output, got %q", got) } - if !strings.Contains(got, "Action: 开启地址: https://example.test/enable-search") { + if !strings.Contains(got, "Action: 处理入口: https://example.test/enable-search") { t.Fatalf("expected server action URL in output, got %q", got) } } +func TestCrossPlatformCoverageServerGuidanceSuppressesUnsafeActionURL(t *testing.T) { + t.Parallel() + for _, actionURL := range []string{ + "http://example.test/help", + "javascript:alert(1)", + "https://user:secret@example.test/help", + "not a url", + } { + var human strings.Builder + err := NewAPI("server error", WithServerDiag(ServerDiagnostics{ + FriendlyHint: "保留 Trace ID 后排查", + ActionURL: actionURL, + })) + if printErr := PrintHuman(&human, err); printErr != nil { + t.Fatal(printErr) + } + if strings.Contains(human.String(), actionURL) || strings.Contains(human.String(), "处理入口") { + t.Fatalf("unsafe action URL %q leaked to human output: %q", actionURL, human.String()) + } + var jsonOutput strings.Builder + if printErr := PrintJSON(&jsonOutput, err); printErr != nil { + t.Fatal(printErr) + } + if strings.Contains(jsonOutput.String(), `"action_url"`) { + t.Fatalf("unsafe action URL %q leaked to JSON output: %q", actionURL, jsonOutput.String()) + } + } +} + func TestCrossPlatformCoveragePrintJSONIncludesRPCCodeAndData(t *testing.T) { t.Parallel() diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index de7d8984..27e9454e 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -2528,7 +2528,7 @@ func newChatCommand() *cobra.Command { DeclareLeafMetadata(chatMessageSendCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", - Confirmation: "not_required", Idempotency: "unknown", + Confirmation: "user_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ @@ -2546,7 +2546,7 @@ func newChatCommand() *cobra.Command { }, Selection: contract.SelectionSpec{ AgentSummary: "以当前用户身份发送群聊或单聊消息", - UseWhen: []string{"用户明确要以个人身份发送文本或媒体消息时"}, + UseWhen: []string{"用户明确要以个人身份发送文本或媒体消息时;响应返回 openTaskId 后用 chat message query-send-status 确认投递并取得后续操作所需的消息 ID"}, AvoidWhen: []string{"机器人身份或 Webhook 发送应使用对应命令"}, Examples: []string{"dws chat message send --group \"项目已更新\""}, }, @@ -2728,7 +2728,7 @@ func newChatCommand() *cobra.Command { DeclareLeafMetadata(chatMessageSendByBotCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", - Confirmation: "not_required", Idempotency: "unknown", + Confirmation: "user_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ @@ -2889,7 +2889,14 @@ func newChatCommand() *cobra.Command { DeclareLeafMetadata(chatMessageSendByWebhookCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", - Confirmation: "not_required", Idempotency: "unknown", + Confirmation: "user_required", Idempotency: "unknown", + }, + // The webhook path parses the raw response directly instead of dispatching + // through deps.Caller.CallTool. Supplying a local validator makes the + // framework confirm before RunE, so the HTTP write can never happen before + // the user_required gate. + Validate: func(cmd *cobra.Command, _ []string) error { + return validateRequiredFlags(cmd, "token", "title", "text") }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ @@ -3587,11 +3594,13 @@ func newChatCommand() *cobra.Command { // ── query-send-status:查询消息发送状态(走 IM MCP)────── chatMessageQuerySendStatusCmd := &cobra.Command{ - Use: "query-send-status", - Short: "查询消息发送状态", - Long: `查询以当前用户身份发送的消息的发送状态。需要传入发送消息时返回的 openTaskId。`, + Use: "query-send-status", + Aliases: []string{"send-status"}, + Short: "查询消息发送状态", + Long: `查询以当前用户身份发送的消息的发送状态。需要传入发送消息时返回的 openTaskId。 +投递成功后,响应中的 openMessageId 和 openConversationId 可继续用于 edit、recall、read-status 等消息操作;openTaskId 本身不是消息 ID。`, Example: ` dws chat message query-send-status --open-task-id - # openTaskId 由 dws chat message send 返回`, + dws chat message recall --conversation-id --msg-id `, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "open-task-id"); err != nil { return err @@ -3613,6 +3622,7 @@ func newChatCommand() *cobra.Command { CanonicalPath: "chat.query_message_send_status", CLIPath: "chat message query-send-status", PrimaryCLIPath: "chat message query-send-status", + Aliases: []string{"chat message send-status"}, }, Description: "查询异步消息发送任务的状态", Interface: &contract.InterfaceSpec{ @@ -3622,8 +3632,8 @@ func newChatCommand() *cobra.Command { }, Selection: contract.SelectionSpec{ AgentSummary: "查询异步消息发送任务的状态", - UseWhen: []string{"发送命令返回 openTaskId 后需要确认投递结果时"}, - AvoidWhen: []string{"没有 openTaskId 或只需查消息内容时不要使用"}, + UseWhen: []string{"发送命令返回 openTaskId 后需要确认投递结果,或后续 edit/recall/read-status 需要先取得 openMessageId 和 openConversationId 时"}, + AvoidWhen: []string{"没有 openTaskId、已经有消息 ID,或只需查历史消息内容时不要使用"}, Examples: []string{"dws chat message query-send-status --open-task-id "}, }, }, @@ -5424,7 +5434,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 DeclareLeafMetadata(chatMessageSendCardCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", - Confirmation: "not_required", Idempotency: "unknown", + Confirmation: "user_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ @@ -5518,7 +5528,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 DeclareLeafMetadata(chatMessageUpdateCardCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", - Confirmation: "not_required", Idempotency: "unknown", + Confirmation: "user_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ diff --git a/internal/helpers/chat_card_update_test.go b/internal/helpers/chat_card_update_test.go index 3c7bc472..583fc13f 100644 --- a/internal/helpers/chat_card_update_test.go +++ b/internal/helpers/chat_card_update_test.go @@ -29,6 +29,9 @@ func runNativeCardUpdate(t *testing.T, caller *scriptedToolCaller, args ...strin if root.PersistentFlags().Lookup("dry-run") == nil { root.PersistentFlags().Bool("dry-run", false, "preview without executing") } + if root.PersistentFlags().Lookup("yes") == nil { + root.PersistentFlags().Bool("yes", false, "skip confirmation") + } root.SetArgs(args) return root.Execute() } @@ -41,6 +44,7 @@ func TestCrossPlatformCoverageNativeMessageUpdateCardVerifiesWrite(t *testing.T) "--biz-id", "biz-1", "--content", "完成", "--flow-status", "3", + "--yes", ) if err != nil { t.Fatal(err) @@ -60,6 +64,7 @@ func TestCrossPlatformCoverageNativeMessageUpdateCardVerifiesWrite(t *testing.T) "--biz-id", "not-a-real-card", "--content", "完成", "--flow-status", "3", + "--yes", ) var typed *apperrors.Error if !errors.As(err, &typed) || typed.Reason != "streaming_card_update_unverified" { diff --git a/internal/helpers/chat_command_edges_test.go b/internal/helpers/chat_command_edges_test.go index c9c04d76..21f67753 100644 --- a/internal/helpers/chat_command_edges_test.go +++ b/internal/helpers/chat_command_edges_test.go @@ -25,7 +25,7 @@ func runChatCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...str root.SilenceUsage = true root.SetOut(io.Discard) root.SetErr(io.Discard) - root.SetArgs(args) + root.SetArgs(append(append([]string(nil), args...), "--yes")) return root.ExecuteContext(context.Background()) } diff --git a/internal/helpers/chat_confirmation_alignment_test.go b/internal/helpers/chat_confirmation_alignment_test.go new file mode 100644 index 00000000..57be5f22 --- /dev/null +++ b/internal/helpers/chat_confirmation_alignment_test.go @@ -0,0 +1,66 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package helpers + +import ( + "errors" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +func TestCrossPlatformCoverageChatMessageWritesRequireConfirmationBeforeDispatch(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {name: "current user send", args: []string{"message", "send", "--group", "cid-1", "--text", "hello"}}, + {name: "bot send", args: []string{"message", "send-by-bot", "--robot-code", "robot-1", "--group", "cid-1", "--title", "title", "--text", "hello"}}, + {name: "webhook send", args: []string{"message", "send-by-webhook", "--token", "token-1", "--title", "title", "--text", "hello"}}, + {name: "card send", args: []string{"message", "send-card", "--group", "cid-1"}}, + {name: "card update", args: []string{"message", "update-card", "--biz-id", "biz-1", "--content", "done", "--flow-status", "3"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + InitDepsForTest(t, caller) + cmd := newChatCommand() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetIn(strings.NewReader("")) + cmd.SetArgs(test.args) + err := cmd.Execute() + + var appErr *apperrors.Error + if !errors.As(err, &appErr) || appErr.Reason != "confirmation_required" { + t.Fatalf("Execute() error = %#v, want confirmation_required", err) + } + if caller.calls != 0 { + t.Fatalf("downstream calls = %d, want 0 before confirmation", caller.calls) + } + }) + } +} + +func TestCrossPlatformCoverageChatMessageWriteLeavesInstallRuntimeConfirmation(t *testing.T) { + chat := newChatCommand() + for _, path := range [][]string{ + {"message", "send"}, + {"message", "send-by-bot"}, + {"message", "send-by-webhook"}, + {"message", "send-card"}, + {"message", "update-card"}, + } { + leaf, _, err := chat.Find(path) + if err != nil || leaf == nil { + t.Fatalf("Find(%v) = %v, %v", path, leaf, err) + } + if !HasContractConfirmSafety(leaf) { + t.Errorf("%s has no contract confirmation runtime guard", strings.Join(path, " ")) + } + } +} diff --git a/internal/helpers/chat_message_search_test.go b/internal/helpers/chat_message_search_test.go index 6000bb5f..72acc4e2 100644 --- a/internal/helpers/chat_message_search_test.go +++ b/internal/helpers/chat_message_search_test.go @@ -409,9 +409,12 @@ func executeChatChangedContract(t *testing.T, caller *chatChangedContractCaller, InitDeps(caller) deps.Out.w = io.Discard cmd := newChatCommand() + if cmd.PersistentFlags().Lookup("yes") == nil { + cmd.PersistentFlags().Bool("yes", false, "skip confirmation") + } cmd.SilenceErrors = true cmd.SilenceUsage = true - cmd.SetArgs(args) + cmd.SetArgs(append(append([]string(nil), args...), "--yes")) return cmd.Execute() } diff --git a/internal/helpers/chat_user_settings_test.go b/internal/helpers/chat_user_settings_test.go index 69107d78..cab0db84 100644 --- a/internal/helpers/chat_user_settings_test.go +++ b/internal/helpers/chat_user_settings_test.go @@ -101,7 +101,7 @@ func TestCrossPlatformCoverageChatMessageSendLocation(t *testing.T) { caller = &guardedMutationCaller{} err = executeGuardedMutationCommand(t, caller, newChatCommand, "message", "send", "--group", "cid1", "--msg-type", "location", - "--latitude", "39.9", "--longitude", "116.4", "--location-name", "国贸", "--map-thumbnail-url", "@media1") + "--latitude", "39.9", "--longitude", "116.4", "--location-name", "国贸", "--map-thumbnail-url", "@media1", "--yes") if err != nil { t.Fatal(err) } @@ -128,7 +128,7 @@ func TestCrossPlatformCoverageChatMessageSendProfile(t *testing.T) { caller = &guardedMutationCaller{} err = executeGuardedMutationCommand(t, caller, newChatCommand, - "message", "send", "--group", "cid1", "--msg-type", "profile", "--contact-id", "od123") + "message", "send", "--group", "cid1", "--msg-type", "profile", "--contact-id", "od123", "--yes") if err != nil { t.Fatal(err) } diff --git a/internal/shortcut/chat/chat_message.go b/internal/shortcut/chat/chat_message.go index 92891d0b..b4a9f9f1 100644 --- a/internal/shortcut/chat/chat_message.go +++ b/internal/shortcut/chat/chat_message.go @@ -1003,9 +1003,10 @@ func uniqueShortcutStrings(values []string) []string { var MessagesQuerySendStatus = shortcut.Shortcut{ Service: "chat", Command: "+messages-query-send-status", + Aliases: []string{"+messages-send-status"}, Product: "im", - Description: "查询消息发送状态", - Intent: "当你发消息后拿到 openTaskId、想确认这条消息是否发送成功时使用;只读返回发送状态,需传 --open-task-id。", + Description: "查询消息投递状态并衔接后续消息操作", + Intent: "当你发消息后拿到 openTaskId、想确认投递结果,或后续 edit/recall/read-status 需要取得 openMessageId 和 openConversationId 时使用;openTaskId 不是消息 ID。结果会保留下层响应,并追加版本化 messageRef 与结构化 nextActions。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", @@ -1018,17 +1019,18 @@ var MessagesQuerySendStatus = shortcut.Shortcut{ CanonicalPath: "chat.shortcut_messages_query_send_status", CLIPath: "chat +messages-query-send-status", PrimaryCLIPath: "chat +messages-query-send-status", + Aliases: []string{"chat +messages-send-status"}, }, - Description: "查询消息发送状态", + Description: "查询消息投递状态并衔接后续消息操作", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", }, Selection: contract.SelectionSpec{ - AgentSummary: "查询消息发送状态", - UseWhen: []string{"当你发消息后拿到 openTaskId、想确认这条消息是否发送成功时使用;只读返回发送状态,需传 --open-task-id。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, + AgentSummary: "查询消息投递状态并衔接后续消息操作", + UseWhen: []string{"当你发消息后拿到 openTaskId、想确认投递结果,或后续 edit/recall/read-status 需要取得 openMessageId 和 openConversationId 时使用;openTaskId 不是消息 ID。结果会保留下层响应,并追加版本化 messageRef 与结构化 nextActions。"}, + AvoidWhen: []string{"没有 openTaskId、已经有消息 ID,或只需查历史消息内容时不要使用"}, Examples: []string{"dws chat +messages-query-send-status --open-task-id "}, }, }, @@ -1037,7 +1039,12 @@ var MessagesQuerySendStatus = shortcut.Shortcut{ }, Tips: []string{`dws chat +messages-query-send-status --open-task-id `}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("query_message_send_status", map[string]any{"openTaskId": rt.Str("open-task-id")}) + taskID := rt.Str("open-task-id") + data, err := rt.CallMCPData("im", "query_message_send_status", map[string]any{"openTaskId": taskID}) + if err != nil { + return err + } + return rt.Output(chatmsg.ProjectMessageSendStatus(data, taskID)) }, } @@ -1320,7 +1327,28 @@ var MessagesSendCard = shortcut.Shortcut{ } content := rt.Str("content") if content == "" { - return rt.CallMCP("create_and_send_card", params) + if rt.DryRun() { + return rt.Output(map[string]any{ + "contractVersion": chatmsg.StreamingCardContractVersion, + "dry_run": true, + "executed": false, + "preview_kind": "plan", + "actionCount": 1, + "actions": []map[string]any{{ + "tool": "create_and_send_card", + "arguments": params, + }}, + }) + } + created, err := rt.CallMCPWriteData("im", "create_and_send_card", params) + if err != nil { + return err + } + bizID := findCardBizID(created) + if bizID == "" { + return cardCreateMissingBizIDError(created) + } + return rt.Output(chatmsg.ProjectStreamingCardReceipt(created, bizID)) } status := rt.Int("flow-status") if rt.DryRun() { @@ -1366,14 +1394,11 @@ var MessagesSendCard = shortcut.Shortcut{ if _, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated); err != nil { return fmt.Errorf("卡片已创建(bizId=%s),但自动更新结果不可信: %w", bizID, cardUpdateVerificationError(bizID, err)) } - return rt.Output(map[string]any{ - "contractVersion": currentCardWorkflowContract.Version, - "ok": true, - "bizId": bizID, - "flowStatus": status, - "created": created, - "updated": updated, - }) + payload := chatmsg.ProjectStreamingCardReceipt(created, bizID) + payload["bizId"] = bizID + payload["flowStatus"] = status + payload["updated"] = updated + return rt.Output(payload) }, } @@ -1510,13 +1535,29 @@ var MessagesUpdateCard = shortcut.Shortcut{ if err != nil { return err } - if _, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated); err != nil { + proof, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated) + if err != nil { return cardUpdateVerificationError(bizID, err) } - return rt.Output(updated) + return rt.Output(chatmsg.ProjectStreamingCardUpdate(updated, bizID, proof)) }, } +func cardCreateMissingBizIDError(created map[string]any) error { + return apperrors.NewAPI( + "卡片可能已经创建,但服务端未返回后续更新所需的 bizId;CLI 无法确认卡片工作流可继续", + apperrors.WithOperation("create_and_send_card"), + apperrors.WithServerKey("im"), + apperrors.WithOrigin("client_postcondition"), + apperrors.WithFailureStage("verify_card_reference"), + apperrors.WithExecutionStarted(true), + apperrors.WithRetryable(false), + apperrors.WithReason("streaming_card_reference_missing"), + apperrors.WithHint("不要盲目重试创建;请保留 trace_id 并推动服务端返回 bizId、openMessageId 和 openConversationId"), + apperrors.WithDetails(map[string]any{"created": created}), + ) +} + func cardUpdateVerificationError(bizID string, verifyErr error) error { reason := "streaming_card_update_unverified" message := "服务端未返回卡片实际更新的证据;为避免假成功,CLI 已将本次操作判为失败" diff --git a/internal/shortcut/chat/gap_fill_test.go b/internal/shortcut/chat/gap_fill_test.go index 55b12f11..91761846 100644 --- a/internal/shortcut/chat/gap_fill_test.go +++ b/internal/shortcut/chat/gap_fill_test.go @@ -20,6 +20,7 @@ import ( 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/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" ) type chatOutputErrorWriter struct { @@ -124,6 +125,63 @@ func TestCrossPlatformCoverageIMWorkflowContractsPublishRealPositiveAndNegativeB } } +func TestCrossPlatformCoverageMessagesSendStatusAliasPublishesWorkflowReceipt(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/query_message_send_status": `{"result":{"status":"SUCCESS","openTaskId":"task-1","openMessageId":"msg-1","openConversationId":"cid-1"}}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{"chat", "+messages-send-status", "--open-task-id", "task-1"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 1 || fake.calls[0].tool != "query_message_send_status" || fake.calls[0].args["openTaskId"] != "task-1" { + t.Fatalf("calls = %#v", fake.calls) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["contractVersion"] != chatmsg.MessageSendStatusContractVersion || payload["readyForMessageActions"] != true { + t.Fatalf("payload = %#v", payload) + } + ref, _ := payload["messageRef"].(map[string]any) + if ref["openMessageId"] != "msg-1" || ref["openConversationId"] != "cid-1" { + t.Fatalf("messageRef = %#v", ref) + } +} + +func TestCrossPlatformCoverageMessagesSendPublishesStatusQueryReceipt(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "chat/send_personal_message": `{"result":{"openTaskId":"task-send-1"}}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{ + "chat", "+messages-send", "--as", "user", "--chat-id", "cid-1", + "--text", "hello", "--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) + } + receipt, _ := payload["sendReceipt"].(map[string]any) + if receipt["contractVersion"] != chatmsg.MessageSendReceiptContractVersion || receipt["openTaskId"] != "task-send-1" { + t.Fatalf("sendReceipt = %#v", receipt) + } + actions, _ := receipt["nextActions"].([]any) + if len(actions) != 1 { + t.Fatalf("nextActions = %#v", actions) + } +} + func TestCrossPlatformCoverageMessagesSendBotMultiGroupPublishesPerTargetLedger(t *testing.T) { fake := &larkAlignmentCaller{} helpers.InitDeps(fake) diff --git a/internal/shortcut/chat/lark_alignment_test.go b/internal/shortcut/chat/lark_alignment_test.go index f6cb0eed..23cdeb58 100644 --- a/internal/shortcut/chat/lark_alignment_test.go +++ b/internal/shortcut/chat/lark_alignment_test.go @@ -74,6 +74,8 @@ func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string, text = string(payload) case "im/create_group_conversation": text = `{"result":{"cid":"internal-cid","openCid":"open-cid"}}` + case "im/create_and_send_card": + text = `{"result":{"bizId":"biz-created"}}` case "im/list_messages_by_ids": text = `{"result":[{"openMessageId":"msg","openConversationId":"cid","senderOpenDingTalkId":"D-inferred","content":"{\"mediaId\":\"@image\"}"}]}` case "im/list_conversations_by_category": diff --git a/internal/shortcut/chat/unified_send.go b/internal/shortcut/chat/unified_send.go index 6c52f203..9991d147 100644 --- a/internal/shortcut/chat/unified_send.go +++ b/internal/shortcut/chat/unified_send.go @@ -19,6 +19,7 @@ import ( 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/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" ) @@ -372,12 +373,16 @@ func executeUnifiedMessageWrite(rt *shortcut.RuntimeContext, product, tool strin if err != nil { return err } - return rt.Output(map[string]any{ + payload := map[string]any{ "ok": true, "identity": messagesSendIdentity(rt), "tool": tool, "result": data, - }) + } + if messagesSendIdentity(rt) == "user" && tool == "send_personal_message" { + payload["sendReceipt"] = chatmsg.ProjectMessageSendReceipt(data) + } + return rt.Output(payload) } func messagesSendIdentity(rt *shortcut.RuntimeContext) string { @@ -612,7 +617,7 @@ func executeMessagesSendUserFile( if err != nil { return err } - return rt.Output(map[string]any{ + payload := map[string]any{ "ok": true, "identity": "user", "tool": "send_personal_message", @@ -624,7 +629,9 @@ func executeMessagesSendUserFile( "sizeBytes": meta.FileSize, }, "result": data, - }) + } + payload["sendReceipt"] = chatmsg.ProjectMessageSendReceipt(data) + return rt.Output(payload) } func addMessagesSendUserTarget(params map[string]any, group, openID string) { diff --git a/internal/shortcut/chatmsg/card_ref.go b/internal/shortcut/chatmsg/card_ref.go new file mode 100644 index 00000000..03dcbfba --- /dev/null +++ b/internal/shortcut/chatmsg/card_ref.go @@ -0,0 +1,65 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package chatmsg + +import "strings" + +// StreamingCardContractVersion identifies the additive card receipt emitted by +// high-level card shortcuts. +const StreamingCardContractVersion = "im.streaming-card.v1" + +// ProjectStreamingCardReceipt publishes every server-returned identifier in a +// single cardRef. referencePairAvailable means this response contained both +// the update identifier and the visible message identifiers; it does not claim +// that older messages can be resolved without server-side mapping support. +func ProjectStreamingCardReceipt(created map[string]any, bizID string) map[string]any { + bizID = strings.TrimSpace(bizID) + messageID := firstSendStatusString(created, "openMessageId", "messageId", "msgId") + conversationID := firstSendStatusString(created, "openConversationId", "conversationId", "openCid") + cardRef := map[string]any{} + if bizID != "" { + cardRef["bizId"] = bizID + } + if messageID != "" { + cardRef["openMessageId"] = messageID + } + if conversationID != "" { + cardRef["openConversationId"] = conversationID + } + pairAvailable := bizID != "" && messageID != "" && conversationID != "" + payload := map[string]any{ + "contractVersion": StreamingCardContractVersion, + "ok": true, + "cardRef": cardRef, + "referencePairAvailable": pairAvailable, + "created": created, + "nextActions": []map[string]any{}, + } + if bizID != "" { + payload["nextActions"] = []map[string]any{{ + "cliPath": "chat +messages-update-card", + "arguments": map[string]any{ + "biz-id": bizID, + }, + "requiredArguments": []string{"content", "flow-status"}, + "ready": false, + }} + } + if !pairAvailable { + payload["capabilityGap"] = "服务端尚未同时返回 bizId、openMessageId 和 openConversationId;CLI 只能保留本次响应,不能据此承诺从历史消息反向恢复 bizId" + } + return payload +} + +// ProjectStreamingCardUpdate preserves the lower response while making the +// verified target explicit for downstream consumers. +func ProjectStreamingCardUpdate(updated map[string]any, bizID, proof string) map[string]any { + payload := cloneSendStatusMap(updated) + payload["contractVersion"] = StreamingCardContractVersion + payload["cardRef"] = map[string]any{"bizId": strings.TrimSpace(bizID)} + payload["verified"] = true + payload["verificationEvidence"] = proof + return payload +} diff --git a/internal/shortcut/chatmsg/card_ref_test.go b/internal/shortcut/chatmsg/card_ref_test.go new file mode 100644 index 00000000..baa65687 --- /dev/null +++ b/internal/shortcut/chatmsg/card_ref_test.go @@ -0,0 +1,42 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package chatmsg + +import "testing" + +func TestCrossPlatformCoverageProjectStreamingCardReceipt(t *testing.T) { + complete := ProjectStreamingCardReceipt(map[string]any{ + "result": map[string]any{ + "bizId": "biz-1", + "openMessageId": "msg-1", + "openConversationId": "cid-1", + }, + }, "biz-1") + if complete["contractVersion"] != StreamingCardContractVersion || complete["referencePairAvailable"] != true { + t.Fatalf("complete receipt = %#v", complete) + } + ref, _ := complete["cardRef"].(map[string]any) + if ref["bizId"] != "biz-1" || ref["openMessageId"] != "msg-1" || ref["openConversationId"] != "cid-1" { + t.Fatalf("cardRef = %#v", ref) + } + if _, exists := complete["capabilityGap"]; exists { + t.Fatalf("complete receipt has capability gap: %#v", complete) + } + + partial := ProjectStreamingCardReceipt(map[string]any{"result": map[string]any{"bizId": "biz-2"}}, "biz-2") + if partial["referencePairAvailable"] != false || partial["capabilityGap"] == "" { + t.Fatalf("partial receipt = %#v", partial) + } +} + +func TestCrossPlatformCoverageProjectStreamingCardUpdate(t *testing.T) { + payload := ProjectStreamingCardUpdate(map[string]any{"result": map[string]any{"updated": true}}, "biz-1", "updated=true") + if payload["contractVersion"] != StreamingCardContractVersion || payload["verified"] != true || payload["verificationEvidence"] != "updated=true" { + t.Fatalf("payload = %#v", payload) + } + if _, exists := payload["result"]; !exists { + t.Fatal("lower response was not preserved") + } +} diff --git a/internal/shortcut/chatmsg/send_status.go b/internal/shortcut/chatmsg/send_status.go new file mode 100644 index 00000000..2c6aea5d --- /dev/null +++ b/internal/shortcut/chatmsg/send_status.go @@ -0,0 +1,162 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package chatmsg + +import ( + "strings" +) + +// MessageSendStatusContractVersion identifies the additive workflow fields +// projected by the high-level send-status shortcut. The lower response fields +// remain at their original locations for compatibility. +const MessageSendStatusContractVersion = "im.message-send-status.v1" + +// MessageSendReceiptContractVersion identifies the additive receipt attached +// to high-level current-user send results. +const MessageSendReceiptContractVersion = "im.message-send-receipt.v1" + +// ProjectMessageSendReceipt connects a send result to its asynchronous status +// query without treating openTaskId as a message identifier. +func ProjectMessageSendReceipt(raw map[string]any) map[string]any { + taskID := firstSendStatusString(raw, "openTaskId", "taskId") + messageID := firstSendStatusString(raw, "openMessageId", "messageId", "msgId") + conversationID := firstSendStatusString(raw, "openConversationId", "conversationId", "openCid") + ready := messageID != "" && conversationID != "" + receipt := map[string]any{ + "contractVersion": MessageSendReceiptContractVersion, + "openTaskId": taskID, + "readyForMessageActions": ready, + "nextActions": []map[string]any{}, + } + if messageID != "" || conversationID != "" { + messageRef := map[string]any{} + if messageID != "" { + messageRef["openMessageId"] = messageID + } + if conversationID != "" { + messageRef["openConversationId"] = conversationID + } + receipt["messageRef"] = messageRef + } + switch { + case ready: + receipt["nextActions"] = sendStatusNextActions(taskID, messageID, conversationID, true) + case taskID != "": + receipt["nextActions"] = []map[string]any{{ + "cliPath": "chat +messages-query-send-status", + "arguments": map[string]any{ + "open-task-id": taskID, + }, + "ready": true, + "when": "需要确认投递结果或取得真实消息 ID 时", + }} + default: + receipt["capabilityGap"] = "下层发送响应未返回 openTaskId 或完整 messageRef,CLI 无法生成后续状态查询" + } + return receipt +} + +// ProjectMessageSendStatus preserves the lower response and adds a stable +// receipt that connects openTaskId to the message identifiers required by +// edit, recall, and read-status. It never manufactures a message reference: +// downstream actions are marked ready only when both IDs are actually present. +func ProjectMessageSendStatus(raw map[string]any, requestedTaskID string) map[string]any { + payload := cloneSendStatusMap(raw) + taskID := firstSendStatusString(payload, "openTaskId", "taskId") + if taskID == "" { + taskID = strings.TrimSpace(requestedTaskID) + } + messageID := firstSendStatusString(payload, "openMessageId", "messageId", "msgId") + conversationID := firstSendStatusString(payload, "openConversationId", "conversationId", "openCid") + + payload["contractVersion"] = MessageSendStatusContractVersion + payload["openTaskId"] = taskID + messageRef := map[string]any{} + if messageID != "" { + messageRef["openMessageId"] = messageID + } + if conversationID != "" { + messageRef["openConversationId"] = conversationID + } + if len(messageRef) > 0 { + payload["messageRef"] = messageRef + } + ready := messageID != "" && conversationID != "" + payload["readyForMessageActions"] = ready + payload["nextActions"] = sendStatusNextActions(taskID, messageID, conversationID, ready) + return payload +} + +func sendStatusNextActions(taskID, messageID, conversationID string, ready bool) []map[string]any { + if !ready { + return []map[string]any{{ + "cliPath": "chat message query-send-status", + "arguments": map[string]any{ + "open-task-id": taskID, + }, + "ready": false, + "when": "投递任务尚未返回 openMessageId 和 openConversationId 时稍后重查", + }} + } + messageArgs := map[string]any{ + "conversation-id": conversationID, + "msg-id": messageID, + } + return []map[string]any{ + { + "cliPath": "chat message recall", + "arguments": cloneSendStatusMap(messageArgs), + "ready": true, + }, + { + "cliPath": "chat message edit", + "arguments": map[string]any{ + "conversation-id": conversationID, + "msg-id": messageID, + }, + "requiredArguments": []string{"text 或 content"}, + "ready": false, + }, + { + "cliPath": "chat message read-status", + "arguments": map[string]any{ + "conversation-id": conversationID, + "message-id": messageID, + }, + "ready": true, + }, + } +} + +func firstSendStatusString(value any, keys ...string) string { + switch typed := value.(type) { + case map[string]any: + for _, key := range keys { + if candidate, ok := typed[key].(string); ok && strings.TrimSpace(candidate) != "" { + return strings.TrimSpace(candidate) + } + } + for _, key := range []string{"result", "data", "response", "content", "message"} { + if candidate := firstSendStatusString(typed[key], keys...); candidate != "" { + return candidate + } + } + case []any: + for _, item := range typed { + if candidate := firstSendStatusString(item, keys...); candidate != "" { + return candidate + } + } + } + return "" +} + +func cloneSendStatusMap(source map[string]any) map[string]any { + clone := make(map[string]any, len(source)+5) + for key, value := range source { + clone[key] = value + } + return clone +} diff --git a/internal/shortcut/chatmsg/send_status_test.go b/internal/shortcut/chatmsg/send_status_test.go new file mode 100644 index 00000000..58e8194c --- /dev/null +++ b/internal/shortcut/chatmsg/send_status_test.go @@ -0,0 +1,63 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package chatmsg + +import "testing" + +func TestCrossPlatformCoverageProjectMessageSendReceiptLinksStatusQuery(t *testing.T) { + receipt := ProjectMessageSendReceipt(map[string]any{ + "result": map[string]any{"openTaskId": "task-1"}, + }) + if receipt["contractVersion"] != MessageSendReceiptContractVersion || receipt["openTaskId"] != "task-1" || receipt["readyForMessageActions"] != false { + t.Fatalf("receipt = %#v", receipt) + } + actions, _ := receipt["nextActions"].([]map[string]any) + if len(actions) != 1 || actions[0]["cliPath"] != "chat +messages-query-send-status" || actions[0]["ready"] != true { + t.Fatalf("nextActions = %#v", actions) + } +} + +func TestCrossPlatformCoverageProjectMessageSendStatusReadyWorkflow(t *testing.T) { + raw := map[string]any{ + "result": map[string]any{ + "openTaskId": "task-1", + "openMessageId": "msg-1", + "openConversationId": "cid-1", + "status": "SUCCESS", + }, + } + payload := ProjectMessageSendStatus(raw, "ignored") + if payload["contractVersion"] != MessageSendStatusContractVersion || + payload["openTaskId"] != "task-1" || payload["readyForMessageActions"] != true { + t.Fatalf("payload = %#v", payload) + } + ref, _ := payload["messageRef"].(map[string]any) + if ref["openMessageId"] != "msg-1" || ref["openConversationId"] != "cid-1" { + t.Fatalf("messageRef = %#v", ref) + } + actions, _ := payload["nextActions"].([]map[string]any) + if len(actions) != 3 || actions[0]["cliPath"] != "chat message recall" || actions[2]["cliPath"] != "chat message read-status" { + t.Fatalf("nextActions = %#v", actions) + } + if _, ok := payload["result"]; !ok { + t.Fatal("raw response field was not preserved") + } +} + +func TestCrossPlatformCoverageProjectMessageSendStatusPendingDoesNotInventMessageRef(t *testing.T) { + payload := ProjectMessageSendStatus(map[string]any{ + "result": map[string]any{"status": "PENDING"}, + }, "task-pending") + if payload["openTaskId"] != "task-pending" || payload["readyForMessageActions"] != false { + t.Fatalf("payload = %#v", payload) + } + if _, exists := payload["messageRef"]; exists { + t.Fatalf("pending payload invented messageRef: %#v", payload) + } + actions, _ := payload["nextActions"].([]map[string]any) + if len(actions) != 1 || actions[0]["ready"] != false { + t.Fatalf("nextActions = %#v", actions) + } +} diff --git a/internal/transport/client.go b/internal/transport/client.go index 5c2759ec..c9184a41 100644 --- a/internal/transport/client.go +++ b/internal/transport/client.go @@ -1047,6 +1047,7 @@ func authActions(snapshotPath string) []string { func runtimeActions(snapshotPath string) []string { actions := []string{ i18n.T("检查认证、权限和参数后重试原命令"), + i18n.T("运行 dws doctor 检查登录态、网络和本地环境;持续失败时保留 Trace ID 和 Server Code"), } _ = snapshotPath return actions @@ -1056,6 +1057,7 @@ func networkActions(snapshotPath string) []string { actions := []string{ i18n.T("检查网络、代理和 DNS 配置后重试原命令"), i18n.T("确认 MCP 服务可访问;若持续失败请稍后重试"), + i18n.T("运行 dws doctor 获取可共享的环境诊断结果"), } _ = snapshotPath return actions diff --git a/internal/transport/coverage_edges_test.go b/internal/transport/coverage_edges_test.go index 4b17e065..0ff98ed7 100644 --- a/internal/transport/coverage_edges_test.go +++ b/internal/transport/coverage_edges_test.go @@ -397,13 +397,13 @@ func TestCrossPlatformCoverageHTTPStatusAndDiagnosticsEdges(t *testing.T) { t.Fatalf("HTTP %d returned nil", tc.code) } } - if got := networkActions("snap"); len(got) != 2 { + if got := networkActions("snap"); len(got) != 3 { t.Fatalf("network actions = %#v", got) } if got := authActions("snap"); len(got) != 2 { t.Fatalf("auth actions = %#v", got) } - if got := runtimeActions("snap"); len(got) != 1 { + if got := runtimeActions("snap"); len(got) != 2 { t.Fatalf("runtime actions = %#v", got) } if got := actionsForMethod("tools/call", "snap"); len(got) == 0 { diff --git a/internal/transport/diagnostics.go b/internal/transport/diagnostics.go index ed8306b8..8a643f7f 100644 --- a/internal/transport/diagnostics.go +++ b/internal/transport/diagnostics.go @@ -42,18 +42,48 @@ func ExtractServerDiagnosticsFromMap(content map[string]any) apperrors.ServerDia return apperrors.ServerDiagnostics{} } diag := apperrors.ServerDiagnostics{ - TraceID: stringFromMap(content, "trace_id", "traceId"), + TraceID: stringFromMapRecursive(content, 0, "trace_id", "traceId", "request_id", "requestId"), ServerErrorCode: serverErrorCodeFromMap(content, 0), - TechnicalDetail: stringFromMap(content, "technical_detail"), + TechnicalDetail: stringFromMapRecursive(content, 0, "technical_detail", "technicalDetail"), FriendlyHint: stringFromMapRecursive(content, 0, "friendly_hint", "friendlyHint"), ActionURL: stringFromMapRecursive(content, 0, "action_url", "actionUrl"), } - if v, ok := content["retryable"].(bool); ok { + if v, ok := boolFromMapRecursive(content, 0, "retryable", "server_retryable", "serverRetryable"); ok { diag.ServerRetryable = &v } return diag } +func boolFromMapRecursive(content map[string]any, depth int, keys ...string) (bool, bool) { + if content == nil || depth > 8 { + return false, false + } + for _, key := range keys { + if value, ok := content[key].(bool); ok { + return value, true + } + } + for _, key := range []string{"content", "result", "data"} { + switch child := content[key].(type) { + case map[string]any: + if value, ok := boolFromMapRecursive(child, depth+1, keys...); ok { + return value, true + } + case []any: + for _, item := range child { + childMap, ok := item.(map[string]any) + if !ok { + continue + } + if value, ok := boolFromMapRecursive(childMap, depth+1, keys...); ok { + return value, true + } + } + } + } + return false, false +} + func stringFromMapRecursive(content map[string]any, depth int, keys ...string) string { if content == nil || depth > 8 { return "" diff --git a/internal/transport/diagnostics_test.go b/internal/transport/diagnostics_test.go index 35e21724..436f49ca 100644 --- a/internal/transport/diagnostics_test.go +++ b/internal/transport/diagnostics_test.go @@ -110,6 +110,24 @@ func TestExtractServerDiagnosticsFromMapNestedBusinessCode(t *testing.T) { } } +func TestExtractServerDiagnosticsFromMapNestedDiagnosticEnvelope(t *testing.T) { + t.Parallel() + content := map[string]any{ + "result": map[string]any{ + "data": map[string]any{ + "requestId": "request-nested", + "technicalDetail": "nested detail", + "serverRetryable": false, + }, + }, + } + diag := ExtractServerDiagnosticsFromMap(content) + if diag.TraceID != "request-nested" || diag.TechnicalDetail != "nested detail" || + diag.ServerRetryable == nil || *diag.ServerRetryable { + t.Fatalf("nested diagnostics = %#v", diag) + } +} + func TestExtractServerDiagnosticsFromMap_Empty(t *testing.T) { t.Parallel() diag := ExtractServerDiagnosticsFromMap(nil)