diff --git a/CHANGELOG.md b/CHANGELOG.md index b0198483..d9cd503b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ### Fixed +- **Streaming-card update acknowledgement** — accepts the pre-production + `success: true` response from `update_streaming_card` as affirmative write + evidence while preserving explicit negative, conflicting, and bizId-drift + failures, so Agents do not repeat an update that the service already applied. - **Machine-readable export and download receipts** — `dws doc export`, `dws drive download`, and `dws drive download --version` now keep progress logs on stderr under `--format json` and emit one JSON result on stdout after diff --git a/internal/helpers/chat_card_update_test.go b/internal/helpers/chat_card_update_test.go index 58644967..789cfbfb 100644 --- a/internal/helpers/chat_card_update_test.go +++ b/internal/helpers/chat_card_update_test.go @@ -77,7 +77,7 @@ func TestCrossPlatformCoverageNativeMessageUpdateCardVerifiesWrite(t *testing.T) } }) - t.Run("generic success is unverified", func(t *testing.T) { + t.Run("success acknowledgement is verified", func(t *testing.T) { caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true,"errorCode":null}`}}} err := runNativeCardUpdate(t, caller, "message", "update-card", @@ -85,9 +85,11 @@ func TestCrossPlatformCoverageNativeMessageUpdateCardVerifiesWrite(t *testing.T) "--content", "完成", "--flow-status", "3", ) - var typed *apperrors.Error - if !errors.As(err, &typed) || typed.Reason != "streaming_card_update_unverified" { - t.Fatalf("error = %#v, want streaming_card_update_unverified", err) + if err != nil { + t.Fatal(err) + } + if caller.calls != 1 || caller.tool != "update_streaming_card" { + t.Fatalf("call = count:%d tool:%q", caller.calls, caller.tool) } }) diff --git a/internal/shortcut/chat/gap_fill_test.go b/internal/shortcut/chat/gap_fill_test.go index e8040232..ad2d428f 100644 --- a/internal/shortcut/chat/gap_fill_test.go +++ b/internal/shortcut/chat/gap_fill_test.go @@ -1049,14 +1049,6 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test }, wantError: "biz-preserved", }, - { - name: "unverified update preserves id", - fake: &larkAlignmentCaller{responses: map[string]string{ - "im/create_and_send_card": `{"bizId":"biz-unverified"}`, - "im/update_streaming_card": `{"success":true,"errorCode":null}`, - }}, - wantError: "biz-unverified", - }, } { t.Run(tc.name, func(t *testing.T) { helpers.InitDeps(tc.fake) @@ -1074,6 +1066,27 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test }) } + t.Run("success acknowledgement completes composite update", func(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/create_and_send_card": `{"bizId":"biz-acknowledged"}`, + "im/update_streaming_card": `{"success":true,"errorCode":null}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+messages-send-card", + "--group", "cid", + "--content", "完成", + "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 2 || fake.calls[1].tool != "update_streaming_card" { + t.Fatalf("calls = %#v", fake.calls) + } + }) + for _, args := range [][]string{ {"--group", "cid", "--content", "x", "--flow-status", "6"}, {"--group", "cid", "--flow-status", "2"}, @@ -1091,7 +1104,7 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test } } -func TestCrossPlatformCoverageMessagesUpdateCardRejectsFalseSuccess(t *testing.T) { +func TestCrossPlatformCoverageMessagesUpdateCardVerifiesSuccess(t *testing.T) { t.Run("agent shortcut owns confirmation boundary", func(t *testing.T) { fake := &larkAlignmentCaller{responses: map[string]string{ "im/update_streaming_card": `{"result":{"bizId":"biz-confirm","updated":true}}`, @@ -1115,7 +1128,7 @@ func TestCrossPlatformCoverageMessagesUpdateCardRejectsFalseSuccess(t *testing.T } }) - t.Run("generic success is unverified", func(t *testing.T) { + t.Run("success acknowledgement is verified", func(t *testing.T) { fake := &larkAlignmentCaller{responses: map[string]string{ "im/update_streaming_card": `{"success":true,"errorCode":null}`, }} @@ -1128,10 +1141,8 @@ func TestCrossPlatformCoverageMessagesUpdateCardRejectsFalseSuccess(t *testing.T "--flow-status", "3", "--yes", }) - err := root.Execute() - var typed *apperrors.Error - if !errors.As(err, &typed) || typed.Reason != "streaming_card_update_unverified" { - t.Fatalf("error = %#v, want streaming_card_update_unverified", err) + if err := root.Execute(); err != nil { + t.Fatal(err) } if len(fake.calls) != 1 || fake.calls[0].tool != "update_streaming_card" { t.Fatalf("calls = %#v", fake.calls) diff --git a/internal/shortcut/chatmsg/card_update.go b/internal/shortcut/chatmsg/card_update.go index 5649c772..6aad864b 100644 --- a/internal/shortcut/chatmsg/card_update.go +++ b/internal/shortcut/chatmsg/card_update.go @@ -59,9 +59,8 @@ func isCardBizIDPlaceholder(value string) bool { } // VerifyStreamingCardUpdate requires affirmative evidence that the requested -// write took effect. A transport-level {success:true,errorCode:null} only says -// that the RPC returned normally and is deliberately not accepted as proof of -// a card update. +// write took effect. update_streaming_card may acknowledge an applied write +// with success=true without returning an updated flag or affected count. func VerifyStreamingCardUpdate(requestedBizID string, response map[string]any) (string, error) { requestedBizID = strings.TrimSpace(requestedBizID) observation := cardUpdateObservation{bizIDs: map[string]struct{}{}} @@ -132,6 +131,13 @@ func observeCardUpdateMap(value map[string]any, observation *cardUpdateObservati } } } + if success, ok := value["success"].(bool); ok { + if success { + setPositiveCardUpdateEvidence(observation, "success=true") + } else { + setNegativeCardUpdateEvidence(observation, "success=false") + } + } // Only documented response envelopes are traversed. This prevents an // unrelated extension field containing "updated":true from proving the diff --git a/internal/shortcut/chatmsg/card_update_test.go b/internal/shortcut/chatmsg/card_update_test.go index 27f48fb5..5b1f9d39 100644 --- a/internal/shortcut/chatmsg/card_update_test.go +++ b/internal/shortcut/chatmsg/card_update_test.go @@ -62,7 +62,8 @@ func TestCrossPlatformCoverageVerifyStreamingCardUpdate(t *testing.T) { {name: "matching id", response: map[string]any{"result": map[string]any{"bizId": "biz-1", "applied": true}}, wantProof: "applied=true"}, {name: "conflicting evidence", response: map[string]any{"updated": true, "applied": false}, wantErrIs: ErrCardUpdateUnverified}, {name: "zero affected", response: map[string]any{"affectedCount": 0}, wantErrIs: ErrCardUpdateNotApplied}, - {name: "false success has no write proof", response: map[string]any{"success": true, "errorCode": nil}, wantErrIs: ErrCardUpdateUnverified}, + {name: "success acknowledgement", response: map[string]any{"success": true, "errorCode": nil}, wantProof: "success=true"}, + {name: "failed acknowledgement", response: map[string]any{"success": false}, wantErrIs: ErrCardUpdateNotApplied}, {name: "explicitly not updated", response: map[string]any{"result": map[string]any{"updated": false}}, wantErrIs: ErrCardUpdateNotApplied}, {name: "mismatched id", response: map[string]any{"result": map[string]any{"bizId": "biz-2", "updated": true}}, wantErrIs: ErrCardUpdateBizIDDrift}, {name: "unrelated extension ignored", response: map[string]any{"extension": map[string]any{"updated": true}}, wantErrIs: ErrCardUpdateUnverified}, diff --git a/skills/mono/references/products/chat.md b/skills/mono/references/products/chat.md index d9259d6d..bf5ae486 100644 --- a/skills/mono/references/products/chat.md +++ b/skills/mono/references/products/chat.md @@ -2413,6 +2413,7 @@ Flags: flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成(FINISH),4=执行中(EXECUTING),5=错误(ERROR)。 **最后一次更新必须将 --flow-status 设为 3(finish),否则卡片会一直处于"生成中"的加载状态。** +更新结果不确定时不要再次执行更新;保留返回结果并告知用户。 ``` Usage: dws chat message update-card [flags] diff --git a/skills/multi/dingtalk-chat/references/card/create.md b/skills/multi/dingtalk-chat/references/card/create.md index e7a1fbed..eff31a67 100644 --- a/skills/multi/dingtalk-chat/references/card/create.md +++ b/skills/multi/dingtalk-chat/references/card/create.md @@ -13,8 +13,9 @@ Runtime 会唯一解析为 openDingTalkId;已有 openDingTalkId 时传 或艾特占位符。 - `--dry-run` 仍执行只读 userId 解析,只输出两步计划,不执行写入。 -创建成功但自动更新失败时,错误会保留真实 `bizId`。不要重复创建;使用该 `bizId` 继续 -`+messages-update-card` 或人工处理。 +自动更新结果不确定时,不要再次更新或重复创建;保留返回结果并告知用户。若结果中已经 +包含 `openTaskId`,可以按用户需要查询一次投递状态;该查询只确认消息投递,不代表卡片 +正文已经更新成功。 当前内容仅为 streaming text,不接受 Lark Card JSON、组件树或按钮 callback。