Compare commits

...
Author SHA1 Message Date
chichuan 14c5bed4fc ci: split app-c race partition for runner headroom 2026-08-21 00:50:46 +08:00
赤川 c1bd6dcf64 Merge branch 'main' into codex/fix-stable-active-fragments 2026-08-21 00:06:27 +08:00
github-actions[bot] d4ff8a5f4f Merge pull request #1070 from DingTalk-Real-AI/codex/oa-ding-report-shortcuts
feat(shortcut): harden OA DING and Report workflows
2026-08-20 23:44:23 +08:00
Dennis 11a9ad5d49 fix(shortcut): align OA execution availability 2026-08-20 23:03:35 +08:00
Dennis 8cb0f64477 fix(shortcut): reject backward OA cursors 2026-08-20 22:15:27 +08:00
Dennis 2d38beb7be fix(shortcuts): separate compatibility visibility from availability 2026-08-20 21:51:21 +08:00
Dennis bda408d966 test(ding): cover compatibility reminder mappings 2026-08-20 21:10:45 +08:00
Dennis 33631502c9 fix(shortcuts): align unavailable writes and query validation 2026-08-20 21:02:52 +08:00
Dennis 5c9f738012 fix(shortcuts): preserve published schema bindings 2026-08-20 20:20:41 +08:00
Dennis 56c5fb35b8 chore(policy): retire completed flag migrations 2026-08-20 20:20:27 +08:00
Dennis b19c52f61b fix(oa): make approval keyword normalization explicit 2026-08-20 20:20:25 +08:00
Dennis c0641dbf64 fix(shortcut): preserve OA and DING CLI compatibility 2026-08-20 20:20:23 +08:00
Dennis 3b5cb3b0cb test(shortcut): close OA DING Report coverage gaps 2026-08-20 20:20:21 +08:00
Dennis fd2ed2174f chore(release): add OA DING Report fragment 2026-08-20 20:20:19 +08:00
Dennis 5bbaa304e3 docs(shortcut): refresh OA DING Report live evidence 2026-08-20 20:20:17 +08:00
Dennis db938778af chore(shortcut): sync OA DING Report with current main 2026-08-20 20:20:15 +08:00
Dennis 1155b9b5c0 test(shortcut): align OA reviewed input fixtures 2026-08-20 20:20:12 +08:00
Dennis 8fa93ef030 fix(shortcut): retire OA discovery aliases after downgrade 2026-08-20 20:20:10 +08:00
Dennis f4ad1a15f5 docs(shortcut): record Report double-layer release proof 2026-08-20 20:20:08 +08:00
Dennis d2cbd928e2 docs(shortcut): record DING double-layer release proof 2026-08-20 20:20:06 +08:00
Dennis 2346dfba4e fix(shortcut): require OA zero-page pagination evidence 2026-08-20 20:20:03 +08:00
Dennis f6ad2fa01a fix(shortcut): publish Report range constraints 2026-08-20 20:19:44 +08:00
Dennis 7bc56f3dea feat(shortcut): unlock Report outbox workflows 2026-08-20 20:19:41 +08:00
Dennis 03001eb4e0 fix(shortcut): harden DING write routing evidence 2026-08-20 20:19:39 +08:00
Dennis 91974ce981 docs(shortcut): close OA residual audit gaps 2026-08-20 20:19:37 +08:00
Dennis c6417e3527 feat(shortcut): harden Report reads and availability 2026-08-20 20:19:35 +08:00
Dennis 161687ae4c fix(shortcut): deliver OA validation evidence 2026-08-20 20:19:32 +08:00
Dennis 4da5a07d1d feat(shortcut): harden DING reads and availability 2026-08-20 20:19:30 +08:00
Dennis 2cb83d388b fix(shortcut): publish OA validation constraints 2026-08-20 20:19:25 +08:00
Dennis ba9c0f624e feat(shortcut): harden OA workflows and availability 2026-08-20 20:19:19 +08:00
Dennis ff65f80c98 refactor(oa): add strict shortcut response helpers 2026-08-20 20:19:09 +08:00
github-actions[bot] 42240f5e9e Merge pull request #1079 from DingTalk-Real-AI/codex/fix-stable-migration-receipts
fix(ci): keep completed migration receipts inert
2026-08-20 20:18:04 +08:00
chichuan 11cbc30a10 fix(ci): keep completed migration receipts inert 2026-08-20 19:16:07 +08:00
chichuan 74859b966b fix(release): consume active fragments in stable seals 2026-08-20 17:07:53 +08:00
github-actions[bot] 62d72ad84c chore: update formula for v1.0.59 [skip ci] 2026-08-20 08:45:55 +00:00
赤川 c0838e7e41 Merge pull request #1072 from DingTalk-Real-AI/codex/changelog-v1.0.59-stable
chore: prepare v1.0.59 changelog
2026-08-20 16:06:54 +08:00
chichuan 9c6ab99bf1 chore: prepare v1.0.59 changelog 2026-08-20 16:01:09 +08:00
github-actions[bot] 87ab311764 chore: update beta formula for v1.0.59-beta.5 [skip ci] 2026-08-20 07:55:40 +00:00
赤川 15c075e6a6 Merge pull request #1068 from DingTalk-Real-AI/codex/changelog-v1.0.59-beta.5
chore: prepare v1.0.59-beta.5 changelog
2026-08-20 14:56:06 +08:00
chichuan f6d1e685e0 chore: prepare v1.0.59-beta.5 changelog 2026-08-20 14:52:30 +08:00
github-actions[bot] 81108e150b Merge pull request #1046 from xlb1130/feat/85614588-chat-personal-emotion
feat(chat): add personal emotion commands
2026-08-20 06:27:50 +00:00
xlb1130 dad9aefefa Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 14:08:05 +08:00
github-actions[bot] 71d49cb12b Merge pull request #1033 from pengzhihan47-star/codex/dingtalk-doc-skill-opt-v1
docs(skill): optimize dingtalk-doc workflows
2026-08-20 14:04:45 +08:00
柏智 f7e2efaaa2 ci: retrigger checks 2026-08-20 13:50:18 +08:00
pengzhihan47-star 557208e16b Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 13:31:31 +08:00
xlb1130 53401dbb0c Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 13:25:41 +08:00
github-actions[bot] 6c52ac37dd Merge pull request #1066 from DingTalk-Real-AI/codex/minutes-todo-wiki-param-aliases
feat(cli): expand Minutes TODO Wiki parameter aliases
2026-08-20 13:21:38 +08:00
xlb1130 95a17a3ffc Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 13:21:07 +08:00
pengzhihan47-star 3318741508 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 13:01:04 +08:00
克谨 3d7ab2690c feat(cli): expand Minutes TODO Wiki parameter aliases 2026-08-20 12:45:19 +08:00
github-actions[bot] 17eefcd24b Merge pull request #1064 from DingTalk-Real-AI/codex/fix-1060-schema-lineage
fix(policy): preserve historical Schema migration lineage
2026-08-20 04:29:42 +00:00
xlb1130 6f62ce7997 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 12:17:24 +08:00
赤川 2228a32d1a Merge branch 'main' into codex/fix-1060-schema-lineage 2026-08-20 12:11:55 +08:00
github-actions[bot] a6f79e951b Merge pull request #1050 from DingTalk-Real-AI/codex/fix-cli-eval-functional
fix: harden shortcut functional workflows
2026-08-20 04:08:39 +00:00
长真 096dfd48f0 docs(chat): keep emotion skill route within budget 2026-08-20 11:57:55 +08:00
chichuan 3922970bfc fix(policy): preserve schema migration lineage 2026-08-20 11:52:00 +08:00
Dennis4477 9b0441ca56 Merge branch 'main' into codex/fix-cli-eval-functional 2026-08-20 11:47:08 +08:00
xlb1130 2ab0edd5c6 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 11:45:41 +08:00
pengzhihan47-star 4b3272bcd4 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 11:42:41 +08:00
github-actions[bot] b6eaf3c5af chore: update beta formula for v1.0.59-beta.4 [skip ci] 2026-08-20 03:42:00 +00:00
长真 80d5d24637 Revert "docs(chat): trim chat skill context budget"
This reverts commit c5951a10ff.
2026-08-20 11:39:50 +08:00
柏智 e40397e239 docs(skill): restore bounded doc guidance 2026-08-20 11:34:14 +08:00
xlb1130 15bc7fdc3f Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 11:27:40 +08:00
柏智 da049be58d docs(skill): require terminal evidence for doc writes 2026-08-20 11:21:58 +08:00
柏智 6ec64e8a03 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 11:09:24 +08:00
柏智 bca56cbba6 Merge remote-tracking branch 'origin/codex/dingtalk-doc-skill-opt-v1' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 11:09:19 +08:00
john bb48aa0cc8 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 10:37:44 +08:00
柏智 6ffb4bcb93 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 10:37:30 +08:00
pengzhihan47-star 08595594d7 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 10:15:26 +08:00
柏智 540bbac35b Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 09:57:23 +08:00
柏智 c4d5595ca9 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 02:09:49 +08:00
柏智 86d1eb8030 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 22:47:50 +08:00
柏智 ab529e5ee5 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 22:05:02 +08:00
xlb1130 15cb1f4311 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 22:01:44 +08:00
长真 97ca00868f test(chat): cover personal emotion user resolution 2026-08-19 21:51:49 +08:00
xlb1130 df8885c350 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 21:06:35 +08:00
柏智 4e27a3a84a Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 19:45:13 +08:00
xlb1130 8685464c53 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 19:28:52 +08:00
长真 26b5939f9f chore(ci): retrigger pr checks 2026-08-19 18:34:38 +08:00
柏智 95bcace6bd Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 18:10:22 +08:00
长真 ce57cdf260 chore(ci): retrigger pr checks 2026-08-19 16:26:32 +08:00
Dennis 17741851f5 test: satisfy native shortcut coverage gate 2026-08-19 16:20:04 +08:00
Dennis d10446bea7 ci: reuse preinstalled archive tooling 2026-08-19 15:50:03 +08:00
xlb1130 3ec138ba99 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 15:14:09 +08:00
Dennis 0ed05a2c5d fix: address shortcut review edge cases 2026-08-19 14:46:27 +08:00
Dennis ae1edefee6 test: cover shortcut hardening branches 2026-08-19 14:46:23 +08:00
Dennis 6dbc7ed82b fix: harden shortcut functional workflows 2026-08-19 14:46:19 +08:00
柏智 f419c0f96d Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 14:37:01 +08:00
xlb1130 17101a8901 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 13:41:20 +08:00
柏智 66aa00fb50 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 12:23:42 +08:00
长真 9d6e151a6f Merge remote-tracking branch 'upstream/main' into feat/85614588-chat-personal-emotion 2026-08-19 10:54:30 +08:00
柏智 0df41d3eff Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 10:40:45 +08:00
柏智 2a1ed8cc7a Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 10:09:48 +08:00
长真 c5951a10ff docs(chat): trim chat skill context budget 2026-08-19 00:55:27 +08:00
长真 3dbd29ab50 feat(chat): add personal emotion commands 2026-08-18 23:59:15 +08:00
柏智 1b50c7a5b4 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 23:25:57 +08:00
柏智 cbd70d1b88 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 21:03:52 +08:00
柏智 0ae8949d40 fix(doc): align skill contracts with runtime 2026-08-18 20:53:02 +08:00
pengzhihan47-star 0975d970d1 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 20:36:25 +08:00
柏智 7808673431 fix(doc): align media receipt contract 2026-08-18 20:31:07 +08:00
pengzhihan47-star 50ed921ca1 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 19:28:45 +08:00
pengzhihan47-star b34c29ec35 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 18:41:09 +08:00
pengzhihan47-star 97e5ded043 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 13:49:34 +08:00
pengzhihan47-star 7ceeafbae8 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 12:27:08 +08:00
pengzhihan47-star 54b4a24a14 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 11:35:30 +08:00
pengzhihan47-star e1bfb343f4 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 10:13:27 +08:00
柏智 7da423bf3c docs(skill): clarify import and recent document routes 2026-08-18 09:37:47 +08:00
柏智 fa83ee579c docs(skill): remove lark-specific wording 2026-08-18 08:25:06 +08:00
pengzhihan47-star 25c694aa2a Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 07:46:06 +08:00
柏智 e064d394ba docs(skill): optimize dingtalk doc workflows 2026-08-18 01:02:37 +08:00
126 changed files with 11410 additions and 3897 deletions
+3 -1
View File
@@ -25,7 +25,9 @@ category: Added
发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序,
将未归档 fragments 汇总为唯一的版本章节,并移动到
`.changes/released/<version>/`。因此 release-seal PR 是唯一会修改
`.changes/released/<version>/`。beta 发布后若有新 fragments 合入并直接准备 stable,
stable 封板会把它们追加到明确的 post-beta 小节,并归档到正式版本目录;没有新
fragments 时仍只生成原有 beta 晋级模板。因此 release-seal PR 是唯一会修改
`CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。
归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、
删除或重写已归档文件。
+5
View File
@@ -0,0 +1,5 @@
---
category: Changed
---
- **OA, DING, and Report shortcuts** — hardens response, identity, pagination, and confirmation contracts; publishes verified form search, receiver status, and report read workflows while withholding shortcuts that lack trustworthy downstream evidence.
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **Shortcut functional workflows** (#1050) — fixes truthful Drive push/sync previews, strict AITable write verification and deletion accounting, lossless Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Chat personal emotions** — adds `chat emotion list`, `chat emotion send`, and `chat emotion favorite` for current-user personal favorite emotion listing, sending, and favoriting.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage for the three products.
+5
View File
@@ -0,0 +1,5 @@
---
category: Changed
---
- **Stable release sealing** — directly preparing a stable release now renders and archives release fragments merged after its beta baseline, avoiding a forced extra beta solely to consume pending notes.
+47 -9
View File
@@ -534,7 +534,8 @@ jobs:
# where the Schema partition alone owned most of the wall clock. The
# app-<partition> names are pinned to the helper's partition set by
# TestCIAppRacePartitionMatrixMatchesHelper, so a partition can never lose
# its job silently.
# its job silently. The CrossPlatformCoverage-heavy C range is split again
# to retain headroom on runners reclaimed near the five-minute mark.
timeout-minutes: 20
strategy:
fail-fast: false
@@ -542,7 +543,10 @@ jobs:
shard:
- app-schema
- app-a-b
- app-c
- app-c-a-l
- app-c-m-r
- app-c-s-z
- app-c-other
- app-d-r
- app-s-z-example-fuzz
- generators
@@ -617,7 +621,13 @@ jobs:
- name: Install archive tooling
if: ${{ matrix.shard == 'release-scripts' && steps.select.outputs.affected == 'true' }}
run: sudo apt-get update && sudo apt-get install -y zip unzip
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Test shard with Race Detection
if: ${{ steps.select.outputs.affected == 'true' }}
@@ -672,7 +682,8 @@ jobs:
# partitions run concurrently and each releases its framework registries
# when the process exits; cli/smoke need headroom beyond go test -timeout for
# setup + assembly. The app-<partition> names are pinned to the helper's
# partition set by TestCIAppRacePartitionMatrixMatchesHelper.
# partition set by TestCIAppRacePartitionMatrixMatchesHelper. The
# CrossPlatformCoverage-heavy C range is split again for runner headroom.
timeout-minutes: 20
strategy:
fail-fast: false
@@ -680,7 +691,10 @@ jobs:
shard:
- app-schema
- app-a-b
- app-c
- app-c-a-l
- app-c-m-r
- app-c-s-z
- app-c-other
- app-d-r
- app-s-z-example-fuzz
- generators
@@ -757,7 +771,13 @@ jobs:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Test release scripts
shell: bash
@@ -1135,7 +1155,13 @@ jobs:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Build
run: make build
@@ -1186,7 +1212,13 @@ jobs:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Run policy and shortcut coverage
run: |
@@ -1267,7 +1299,13 @@ jobs:
- name: Install archive tooling
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
run: sudo apt-get update && sudo apt-get install -y zip unzip
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Run baseline unit tests with coverage
if: steps.baseline-cache.outputs.cache-hit != 'true'
+4 -2
View File
@@ -2698,9 +2698,11 @@ jobs:
;;
compatibility)
test -n "$PREVIOUS_STABLE"
./scripts/policy/check-command-compatibility.sh \
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/check-release-compatibility.sh" \
--repo-root "$GITHUB_WORKSPACE" \
--base-ref HEAD \
--stable-ref "$PREVIOUS_STABLE"
--stable-ref "$PREVIOUS_STABLE" \
--candidate-ref HEAD
;;
e2e)
bash scripts/dev/test-multi-profile-e2e.sh
+25
View File
@@ -6,6 +6,31 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.59] - 2026-08-20
This release promotes the sealed `v1.0.59-beta.5` contents to stable.
### Changed
- **Chat personal emotions** — adds commands to list, send, and favorite the current user's personal favorite emotions.
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage.
- **Shortcut functional workflows** — fixes Drive preview accuracy, AITable write verification and deletion accounting, Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
## [1.0.59-beta.5] - 2026-08-20
### Added
- **Chat personal emotions** — adds `chat emotion list`, `chat emotion send`, and `chat emotion favorite` for current-user personal favorite emotion listing, sending, and favoriting.
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage for the three products.
### Fixed
- **Shortcut functional workflows** (#1050) — fixes truthful Drive push/sync previews, strict AITable write verification and deletion accounting, lossless Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
## [1.0.59-beta.4] - 2026-08-20
### Added
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.59-beta.3"
version "1.0.59-beta.5"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-arm64.tar.gz"
sha256 "9c99adcefd9104368eb443f0a1b4af8e7aceaa1ffdd4462e486854c1692bb6ce"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-darwin-arm64.tar.gz"
sha256 "274d56599a8e33ccca86a139424cab95a54ba311d6b643bccb2d3e6608cd16b4"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-amd64.tar.gz"
sha256 "f5cc8efb1f982d68ae549190fd683292359c2ab542b532fa52bb35e6b5c049af"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-darwin-amd64.tar.gz"
sha256 "0a0a00a77ca24c102204cd6b7de3de58f406a7e0fad2dd965a4c1fe903c34f39"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-arm64.tar.gz"
sha256 "7a4efd04b417ce8013b1e431274b396179958da244164f59974358ba327ff093"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-linux-arm64.tar.gz"
sha256 "1e7af6393979c2fa433af9207722989749f11ea8e09ff9bcd5696e505d6d7f88"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-amd64.tar.gz"
sha256 "90181e8f2e9010c1943a5773c3d45d7d3ac85d6bc93e18a9aaa7c69909e553d7"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-linux-amd64.tar.gz"
sha256 "4896e71a1417acc3d8834fa99f0e81511e020ff952e57c0562cc255e90acec80"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-skills.zip"
sha256 "e7028914a4a826af9b18ed4922d68fa8f279473817fed4f305465bc8a7aad363"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-skills.zip"
sha256 "11000b9c3566e3b38e3037b6b3069d55c8f50725b3ed9cd67714a7ebb794cd47"
end
def install
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCli < Formula
desc "Automate DingTalk workspace tasks from the terminal"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.58"
version "1.0.59"
license "Apache-2.0"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-arm64.tar.gz"
sha256 "7d98599f90cae9d42b51ff2863efc87dbfb4a3176ff3c84fc2216110c0157a70"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-darwin-arm64.tar.gz"
sha256 "61135a2a9286204ce060847e653c63c1e9784a0fa631bb7e0563b90628762a35"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-amd64.tar.gz"
sha256 "4c12e35e5bf7e0905812cd42dc94a5345068a2c16e306bb50b13c5c78b5cb95d"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-darwin-amd64.tar.gz"
sha256 "fd14b0b1a1475891fb243bf6453857a1044ab5a40bcf7dc1c7c795f57e5b03ba"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-arm64.tar.gz"
sha256 "5ef6bde24bc3db6a11a0f1d0b3343a048956b2cbcf6cd3409a037fb6ba425489"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-linux-arm64.tar.gz"
sha256 "5bfe9ac7d1798b028f0fad579bbdffec5898e2fb16ee36f5766ab58e208abd50"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-amd64.tar.gz"
sha256 "3ccadcc6f070a39d2b2ba20429a4fcdc2f21639bf79f34361dc7d16f501bfda6"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-linux-amd64.tar.gz"
sha256 "be1eb9a1f8fc5048e578b5b0bde212fc90baca0f289236c7c333d824bd869cf3"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-skills.zip"
sha256 "2626debc21c3daadfd155b4c167b2219b97e801398fe4441a8b48138960ab264"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-skills.zip"
sha256 "7ce5c3ab6f6a367407f64971bc5ff96cfcdfade2c1a10d326144b17c7b25a57e"
end
def install
+14 -3
View File
@@ -68,6 +68,17 @@ optional bool legacy flag,不能隐藏仍由 Cobra hard-required 的参数。
`replacement_constant.value` 与 legacy `no_opt` 都必须是 `true`;negative flag、默认即
`true` 或固定 `false` 的语义不在本轮证明范围,必须另行设计,不能借本清单放行。
如果 `command_move` 的参数 `from` 在更早 stable 中仍使用另一历史名称,Schema adapter
只能把同一 legacy command 上、已经由 base-owned lifecycle 返回且
`state=consumed` 的 flag rename 回执作为前驱边。例如
`group → conversation-id` 与 `conversation-id → open-topic-id` 可以组合,但不能把
candidate 自增的 pending 记录、其他命令的同名参数、参数概念词典或 CLI alias 当作证据。
首次消费 pending command 回执时,merge-base 的 normalized Schema 必须真实发布中间参数,
并逐跳验证参数签名和 constraints;command 回执合入为 consumed 后,中间 Schema 已从 main
消失,此时保留的两份 consumed 回执可继续对 stable 做受限重放,直到 stable 也达到 after
并让回执转为惰性记录或由独立 PR 清理。两种阶段都拒绝残留 predecessor/intermediate、字段漂移、环、分叉、
target 碰撞或 primary path/tool identity 不唯一;positionals 不在该组合授权面内。
`replacement_constant` 不是清单自报即可成立的例外。after 阶段的 Interface Snapshot
必须从 replacement 命令的同一份框架运行时声明中捕获完全一致的 property/value,缺失、
值不符或额外常量都会使 lifecycle 落入 partial。对于 #1054,`dws chat topic create`
@@ -86,7 +97,7 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
两种迁移都要求旧 argv 继续可执行。删除旧命令、删除旧 flag、把 legacy 改成 non-runnable、改变未登记的历史参数、改变 interface / safety,或只完成部分 before → after 转换都会 fail closed。命令别名会先规范到 reference 的 canonical path,但清单本身仍只能记录精确 canonical 命令,不能用 alias 或前缀扩大授权。
跨命令清单复用下文同一套 `pending → consumed → cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。
跨命令清单复用下文同一套 `pending → consumed → inert/cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。
当前首批 pending 记录覆盖 `chat topic` 收口:`chat group create --thread` 拆到 `chat topic create`,以及 `chat message list-topic-replies` / `forward-topic` 迁到对应的 `chat topic` 命令。前一条完整登记 `name` / `type` / `users` 的同名承接,以及 `thread` → `convThreadEnabled=true` 的常量承接。产品 PR 消费这些记录时只能把三条 `state` 改为 `consumed`,不得改写其 before、after、Schema mapping、constant 或 reason。
@@ -99,9 +110,9 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 的 requiredness 与 legacy 迁移前完全一致 |
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
| 4. 惰性保留或清理 | 当 merge-base 和 stable 都已经是 `after`,该记录不再提供任何授权;后续 PR 可以原样保留或删除 | 两份参考快照均精确匹配 `after`;保留时仍必须是不可改写的 `consumed`,接口偏离 `after` 继续失败 |
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。stable 发布只会让已经追平的 `consumed` 回执变成无授权效果的审计记录,不会在没有代码变更时让后续业务 PR 失去合规性;清理仍可作为独立的账本压缩动作,但不再是下一个 PR 的强制前置条件。
下面只是清单结构示例,不代表已审批命令;实际字段必须从 Interface Snapshot 核对:
+6 -3
View File
@@ -3,7 +3,7 @@
Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration.
- **Products**: 13
- **Total commands**: 160
- **Total commands**: 163
- **Generated from**: `internal/plugin` command descriptors — the same code path the CLI uses at runtime.
> Auto-generated. Update plugin descriptors in `internal/plugin/`, not this file.
@@ -33,7 +33,7 @@ Every command inherits these flags (documented here once, not repeated per comma
- [`dws aitable` — AI Tables](#dws-aitable) · 41 commands
- [`dws attendance` — Attendance](#dws-attendance) · 4 commands
- [`dws calendar` — Calendar](#dws-calendar) · 14 commands
- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands
- [`dws chat` — Group Chat / IM](#dws-chat) · 26 commands
- [`dws contact` — Contact Directory](#dws-contact) · 6 commands
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 2 commands
- [`dws ding` — DING Messages](#dws-ding) · 2 commands
@@ -134,12 +134,15 @@ _Calendar events, participants, meeting rooms, and busy-status queries._
_Group chats, conversations, messages, and robot/webhook integrations._
**23 commands**
**26 commands**
| Command | Description | When to use |
|---|---|---|
| `dws chat bot search` | Search robots (bots) created by the current user by keyword. | When the agent needs to resolve one of its own bots by name to a robot code before sending bot messages. |
| `dws chat conversation-info` | Retrieve basic metadata for a conversation (single chat or group chat) by conversation ID. | When the agent needs context about a conversation (name, type, member count) before operating on it. |
| `dws chat emotion favorite` | Add a media ID to the current user's personal favorite emotions. | When the agent needs to save an available mediaId as a reusable personal emotion, optionally preserving source message context. |
| `dws chat emotion list` | List the current user's personal favorite emotions. | When the agent needs to inspect available personal emotions or resolve an emotionId/mediaId before sending. |
| `dws chat emotion send` | Send a personal favorite emotion to a group or direct chat as the authenticated user. | When the agent needs to send a known personal emotion mediaId to exactly one group, userId, or openDingTalkId target. |
| `dws chat group create` | Create a new internal group chat with a set of initial members. | When the agent needs to spin up a dedicated group for a new project, incident, or discussion thread. |
| `dws chat group members` | List members of a group chat; can also be used against the current user to enumerate their groups' members. | When the agent needs the roster of a group before mentioning, removing, or auditing members. |
| `dws chat group members add` | Add one or more users to an existing group chat. | When the agent expands a group to include additional participants. |
+10 -5
View File
@@ -23,7 +23,7 @@
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、命令兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、CLI 与 Schema 兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
OSS 镜像默认不参与发布 DAG,适用于尚未创建 Bucket 的仓库。云端封板会把当时的仓库变量 `ENABLE_OSS_MIRROR=true` 记录为不可变 tag 元数据 `OSS-Mirror: enabled`,否则记录为 `deferred`;后续发布和撤回只读取该 sealed policy,不读取变量的当前值。`enabled` 继续对缺失凭据、无效 Bucket、上传、pointer 和撤回失败保持 fail-closed;`deferred` 明确跳过不存在的渠道。为避免补发后撤回遗漏,deferred 版本暂不接受 `repair_oss_version`,启用 OSS 只影响后续新 tag,直到补齐可审计的不可变 repair 证明。
@@ -102,7 +102,7 @@ fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR
dws-release v1.2.3-beta.1
```
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
预检包含测试、策略检查、旧正式版 CLI 与 Schema 双基线兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
## 正式发布
@@ -140,14 +140,19 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1
`.changes/<unique-name>.md` 中增加一个独立 fragment;格式和允许的分类见
[`.changes/README.md`](../.changes/README.md)。预发封板时
`scripts/release/prepare-changelog.sh prerelease <version>` 会稳定排序并汇总所有未归档
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。因此并发 PR 不会争用
`CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与归档移动,供审计复核。
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。如果 beta 发布后又有
带 fragment 的 PR 合入,而维护者决定直接发布 stable,
`scripts/release/prepare-changelog.sh stable <version> --from-beta <tag>` 会保留 beta 晋级摘要
模板,并把这些 post-beta fragments 写到明确的 `Changes since <beta>` 边界之后,再移动到
`.changes/released/<stable-version>/`。没有 active fragment 时,stable 仍只生成原有晋级摘要
模板。因此并发 PR 不会争用 `CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与
归档移动,供审计复核。
## CI/CD 保证
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
- tag 必须由云端 seal job 创建为 annotated tag;封板提交必须已通过 PR 合入并包含在远端 `main` 历史中。流水线允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整 CLI 与 Schema 契约;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
- stable 发布到 npm `latest`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。
+42 -73
View File
@@ -1,6 +1,6 @@
{
"generated_at": "2026-08-19T10:35:58.304269",
"count": 423,
"generated_at": "2026-08-20T11:51:44.156046",
"count": 416,
"results": [
{
"suite": "semantic",
@@ -2494,32 +2494,14 @@
"status": "real-ok"
},
{
"suite": "read",
"service": "ding",
"command": "+list",
"risk": "read",
"status": "real-ok"
},
{
"suite": "write",
"service": "ding",
"command": "+recall-personal",
"risk": "high-risk-write",
"status": "real-ok"
},
{
"suite": "read",
"suite": "semantic",
"service": "ding",
"command": "+receiver-status",
"risk": "read",
"status": "real-ok"
},
{
"suite": "write",
"service": "ding",
"command": "+send-personal",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;current HEAD exact Shortcut 与 owning atomic/raw 的请求身份、1 项结果和完整接收行集合一致。",
"availability": "available"
},
{
"suite": "semantic",
@@ -3602,67 +3584,54 @@
"availability": "available"
},
{
"suite": "read",
"service": "oa",
"command": "+list-cc",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"service": "oa",
"command": "+list-executed",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"service": "oa",
"command": "+list-forms",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"service": "oa",
"command": "+list-pending",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"service": "oa",
"command": "+list-submitted",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"service": "oa",
"command": "+my-initiated",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"suite": "semantic",
"service": "oa",
"command": "+search-forms",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "report",
"command": "+inbox-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;current HEAD exact 与 owning atomic 同场景已知页均为 20 项、稳定身份集合和 next cursor 一致,独立未来范围均为 0 且明确终止。终止页回显 cursor 只作已验证收据且不发布 next_token。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "report",
"command": "+outbox-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证发件箱集合、稳定 reportId 和分页终止证据;current HEAD exact 与 owning atomic 同场景已知页均为 1 项且身份一致,独立未来范围均为 0 并明确终止。",
"availability": "available"
},
{
"suite": "semantic",
"service": "report",
"command": "+report-latest",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "完整验证默认最近 20 天或显式不超过 20 天的发件箱;current HEAD exact 所选稳定 reportId 与 owning atomic 候选和精确详情身份一致,严格详情字段计数双层均为 3。",
"availability": "available"
},
{
"suite": "semantic",
"service": "report",
"command": "+template-search",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;current HEAD exact 与 owning atomic 完整集合过滤的已知结果均为 1 且身份一致,随机 UUID 查询均为 0。",
"availability": "available"
},
{
"suite": "read",
@@ -372,7 +372,7 @@ func TestCrossPlatformCoverageReviewedAmbiguousCommandFallbackNeverDispatches(t
{path: "chat +conversation-category-list", candidates: []string{"chat +category-list", "chat +category-list-conversations"}},
{path: "chat +conversation-group-list", candidates: []string{"chat +category-list-conversations", "chat +conversation-list"}},
{path: "chat +list-my-groups", candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"}},
{path: "oa +list-processes", candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}},
{path: "oa +list-processes", candidates: []string{"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"}},
}
for _, test := range tests {
t.Run(test.path, func(t *testing.T) {
+35
View File
@@ -0,0 +1,35 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func TestOAFinalSchemaAvailabilityMatchesReviewedExecution(t *testing.T) {
snapshot := fullSchemaSnapshotForTest(t)
for _, canonical := range []string{
"oa.shortcut_approve_by",
"oa.shortcut_done_approvals",
"oa.shortcut_list_cc",
"oa.shortcut_list_executed",
"oa.shortcut_list_forms",
"oa.shortcut_list_pending",
"oa.shortcut_list_submitted",
"oa.shortcut_my_initiated",
"oa.shortcut_pending",
"oa.shortcut_search_forms",
} {
tool, ok := snapshot.Tools[canonical]
if !ok {
t.Errorf("final Schema lacks OA tool %s", canonical)
continue
}
if got := tool["availability"]; got != contract.InterfaceAvailable {
t.Errorf("%s final availability=%v, want %q", canonical, got, contract.InterfaceAvailable)
}
}
}
+28
View File
@@ -68,6 +68,34 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
return `{"success":true,"result":[]}`
case "list_suggested_event_times":
return `{"success":true,"result":{"recommendEventTimes":[]}}`
case "list_by_keyword_and_time_range":
return `{"success":true,"result":{"itemList":[{"taskUuid":"u1","startTime":1}]}}`
case "get_minutes_basic_info":
return `{"success":true,"result":{"taskUuid":"u1","title":"Fixture Minutes"}}`
case "get_minutes_transcription":
return `{"success":true,"result":{"paragraphList":[],"hasNext":false}}`
case "create_personal_todo":
return `{"success":true,"result":{"taskId":"task-1"}}`
case "get_todo_detail":
return `{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"Fixture Todo","isDone":false}}}`
case "get_user_todos_in_current_org":
return `{"success":true,"result":{"todoCards":[],"hasMore":false}}`
case "add_todo_reminder":
return `{"success":true}`
case "copy_document":
return `{"success":true,"nodeId":"copy-1"}`
case "move_document", "add_member", "update_member", "remove_member":
return `{"success":true}`
case "get_document_info":
if len(c.calls) > 1 {
switch c.calls[len(c.calls)-2].tool {
case "copy_document":
return `{"success":true,"nodeId":"copy-1","workspaceId":"workspace-1","folderId":"folder-1"}`
case "move_document":
return `{"success":true,"nodeId":"node-1","workspaceId":"drive-1","folderId":"folder-1"}`
}
}
return `{"success":true,"nodeId":"node-1","workspaceId":"source-1","folderId":"source-folder"}`
case "create_calendar_event":
return `{"success":true,"result":{"eventId":"event-1"}}`
case "update_calendar_event", "delete_calendar_event", "add_calendar_participant", "remove_calendar_participant":
@@ -226,12 +226,72 @@ var paramAliasCompleteCommands = map[string][]string{
"mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"},
"mail thread list": {"mail", "thread", "list", "--email", "fixture@example.com", "--folder", "folder-1", "--limit", "7"},
"mail user search": {"mail", "user", "search", "--keyword", "fixture"},
"oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "1"},
"oa +search-forms": {"oa", "+search-forms", "--query", "fixture"},
"oa approval search-forms": {"oa", "approval", "search-forms", "--query", "fixture"},
"report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"},
}
// paramAliasCandidateCompleteCommands contains complete invocations for the
// reviewed Minutes/TODO/Wiki joint draft. Keeping candidate-only commands in a
// separate map lets this test file land before the draft replaces the formal
// param_concepts.json: inactive candidate templates are ignored, while every
// command becomes mandatory as soon as one of its reviewed aliases is active.
var paramAliasCandidateCompleteCommands = map[string][]string{
"minutes +detail": {"minutes", "+detail", "--ids", "u1,u2"},
"minutes +latest": {"minutes", "+latest", "--keyword", "fixture"},
"minutes +list-all": {"minutes", "+list-all", "--limit", "7"},
"minutes +record-pause": {"minutes", "+record-pause", "--id", "u1", "--yes"},
"minutes +replace-batch": {"minutes", "+replace-batch", "--id", "u1", "--pair", "old=>new", "--yes"},
"minutes +search": {"minutes", "+search", "--query", "fixture", "--cursor", "cursor-1"},
"minutes +share": {"minutes", "+share", "--ids", "u1,u2", "--member-uids", "user-1,user-2", "--permission", "view", "--yes"},
"minutes +speaker-replace": {"minutes", "+speaker-replace", "--id", "u1", "--from", "old", "--to", "new", "--target-uid", "user-1", "--yes"},
"minutes +summary": {"minutes", "+summary", "--id", "u1", "--content", "fixture", "--yes"},
"minutes +transcript": {"minutes", "+transcript", "--keyword", "fixture"},
"minutes +upload-and-analyze": {"minutes", "+upload-and-analyze", "--resume-id", "u1", "--yes"},
"minutes audio-memo list": {"minutes", "audio-memo", "list", "--max", "7"},
"minutes get batch": {"minutes", "get", "batch", "--ids", "u1,u2"},
"minutes hot-word add": {"minutes", "hot-word", "add", "--words", "DWS,Minutes"},
"minutes list all": {"minutes", "list", "all", "--end", "2026-03-10T23:59:59+08:00"},
"minutes list mine": {"minutes", "list", "mine", "--start", "2026-03-10T00:00:00+08:00"},
"minutes replace-text": {"minutes", "replace-text", "--id", "u1", "--search", "old", "--replace", "new"},
"minutes tag query": {"minutes", "tag", "query", "--tag-id", "tag-1"},
"minutes update title": {"minutes", "update", "title", "--id", "u1", "--title", "Fixture Minutes"},
"minutes upload complete": {"minutes", "upload", "complete", "--session-id", "session-1"},
"todo +assign": {"todo", "+assign", "--task", "Fixture Todo", "--to", "Fixture User", "--yes"},
"todo +assign-multi": {"todo", "+assign-multi", "--task", "Fixture Todo", "--to", "Fixture User,User Two", "--yes"},
"todo +comment": {"todo", "+comment", "--task-id", "task-1", "--content", "fixture comment", "--yes"},
"todo +complete": {"todo", "+complete", "--task-id", "task-1", "--yes"},
"todo +create": {"todo", "+create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"},
"todo +due-today": {"todo", "+due-today", "--role-types", "executor"},
"todo +get-my-tasks": {"todo", "+get-my-tasks", "--role-types", "executor", "--priority", "40", "--page", "2", "--size", "7"},
"todo +get-related-tasks": {"todo", "+get-related-tasks", "--role-types", "creator,executor", "--status", "false"},
"todo +list-comment": {"todo", "+list-comment", "--task-id", "task-1", "--page", "2"},
"todo +remind": {"todo", "+remind", "--task", "Fixture Todo", "--at", "2026-03-10T18:00:00+08:00", "--yes"},
"todo +reminder": {"todo", "+reminder", "--task-id", "task-1", "--base-time", "customTime", "--at", "2026-03-10T18:00:00+08:00", "--yes"},
"todo +reopen": {"todo", "+reopen", "--task-id", "task-1", "--yes"},
"todo +search": {"todo", "+search", "--query", "fixture", "--status", "false"},
"todo +todo-done": {"todo", "+todo-done", "--task", "Fixture Todo", "--yes"},
"todo +update": {"todo", "+update", "--task-id", "task-1", "--title", "Fixture Updated Todo", "--yes"},
"todo comment add": {"todo", "comment", "add", "--task-id", "task-1", "--content", "fixture comment", "--yes"},
"todo comment list": {"todo", "comment", "list", "--task-id", "task-1", "--page", "2", "--size", "7"},
"todo task add-executor": {"todo", "task", "add-executor", "--task-id", "task-1", "--executors", "user-1,user-2", "--yes"},
"todo task add-participant": {"todo", "task", "add-participant", "--task-id", "task-1", "--participants", "user-1,user-2", "--yes"},
"todo task add-reminder": {"todo", "task", "add-reminder", "--task-id", "task-1", "--base-time", "customTime", "--reminder-time-stamp", "2026-03-10T18:00:00+08:00", "--yes"},
"todo task create": {"todo", "task", "create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"},
"todo task create-sub": {"todo", "task", "create-sub", "--parent-id", "task-parent", "--title", "Fixture Sub Todo", "--executors", "user-1", "--yes"},
"todo task done": {"todo", "task", "done", "--task-id", "task-1", "--status", "true", "--yes"},
"todo task get": {"todo", "task", "get", "--task-id", "task-1"},
"todo task list": {"todo", "task", "list", "--role-types", "executor", "--page", "2", "--size", "7"},
"todo task update": {"todo", "task", "update", "--task-id", "task-1", "--done", "true", "--yes"},
"wiki +member-add": {"wiki", "+member-add", "--workspace", "workspace-1", "--user", "user-1", "--role", "READER", "--yes"},
"wiki +member-remove": {"wiki", "+member-remove", "--workspace", "workspace-1", "--user", "user-1", "--yes"},
"wiki +member-update": {"wiki", "+member-update", "--workspace", "workspace-1", "--user", "user-1", "--role", "EDITOR", "--yes"},
"wiki +move": {"wiki", "+move", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"},
"wiki +move-to-drive": {"wiki", "+move-to-drive", "--node", "node-1", "--folder", "folder-1", "--yes"},
"wiki +node-copy": {"wiki", "+node-copy", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"},
"wiki +node-delete": {"wiki", "+node-delete", "--workspace", "workspace-1", "--node", "node-1", "--yes"},
}
// A command can expose more than one mutually exclusive canonical route. In
// that case the shared command template above cannot contain every canonical
// flag at once, so select a fixture-specific complete invocation here.
@@ -531,6 +591,20 @@ var paramAliasNewConfirmationCases = []struct {
{command: "drive +version-revert", emitted: "version-number", canonical: "version"},
}
// Candidate confirmation cases become active with the joint draft. One write
// workflow per product plus TODO's reminder workflow proves semantic aliasing
// cannot move execution across the shared --yes barrier.
var paramAliasCandidateConfirmationCases = []struct {
command string
emitted string
canonical string
}{
{command: "minutes +record-pause", emitted: "uuid", canonical: "id"},
{command: "todo +create", emitted: "deadline", canonical: "due"},
{command: "todo +reminder", emitted: "reminder-time-stamp", canonical: "at"},
{command: "wiki +node-copy", emitted: "node-id", canonical: "node"},
}
// paramAliasRepresentativePayloadCases keeps final transport coverage across
// old concept aliases, command overrides, native compatibility flags, read and
// write commands, and different products. Every reviewed alias is still
@@ -600,6 +674,30 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
}
// Candidate representatives exercise the final transport boundary for each
// Minutes/TODO/Wiki alias family. They are required only when the exact fixture
// exists in the loaded reviewed table, so the tests are mergeable before the
// joint draft is promoted to internal/cli/param_concepts.json.
var paramAliasCandidateRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("minutes +latest", "query"): true,
paramAliasPayloadCaseKey("minutes +transcript", "query"): true,
paramAliasPayloadCaseKey("minutes get batch", "uuids"): true,
paramAliasPayloadCaseKey("minutes update title", "task-uuid"): true,
paramAliasPayloadCaseKey("minutes upload complete", "upload-id"): true,
paramAliasPayloadCaseKey("todo +create", "deadline"): true,
paramAliasPayloadCaseKey("todo +get-my-tasks", "current-page"): true,
paramAliasPayloadCaseKey("todo +reminder", "reminder-time-stamp"): true,
paramAliasPayloadCaseKey("todo comment add", "text"): true,
paramAliasPayloadCaseKey("todo task add-executor", "executor-ids"): true,
paramAliasPayloadCaseKey("todo task get", "todo-id"): true,
paramAliasPayloadCaseKey("todo task update", "status"): true,
paramAliasPayloadCaseKey("wiki +member-add", "user-id"): true,
paramAliasPayloadCaseKey("wiki +member-remove", "uid"): true,
paramAliasPayloadCaseKey("wiki +member-update", "user-id"): true,
paramAliasPayloadCaseKey("wiki +move-to-drive", "node-id"): true,
paramAliasPayloadCaseKey("wiki +node-copy", "node-id"): true,
}
// paramAliasCalendarPayloadCases keeps the full reviewed Calendar expansion
// separate from the long-lived app-c race process. Each case still executes
// both canonical and alias argv through the real PreParse/Cobra path and
@@ -709,6 +807,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
}
activeCommands := make(map[string]bool)
activeFixtureCases := make(map[string]bool)
activeCases := 0
executedRepresentatives := make(map[string]bool)
for _, fixture := range concepts.Fixture {
@@ -717,6 +816,8 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
}
activeCommands[fixture.Command] = true
activeCases++
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
activeFixtureCases[caseKey] = true
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
if !ok {
t.Errorf("reviewed active fixture %q/%q has no complete-command E2E template", fixture.Command, fixture.Emitted)
@@ -729,8 +830,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
continue
}
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
if !paramAliasRepresentativePayloadCases[caseKey] {
if !paramAliasRepresentativePayloadCases[caseKey] && !paramAliasCandidateRepresentativePayloadCases[caseKey] {
continue
}
executedRepresentatives[caseKey] = true
@@ -742,26 +842,43 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
if activeCases == 0 {
t.Fatal("reviewed fixture contains no active alias cases")
}
templateCommands := make(map[string]bool, len(paramAliasCompleteCommands)+len(paramAliasCandidateCompleteCommands))
for command := range paramAliasCompleteCommands {
if !activeCommands[command] {
t.Errorf("complete-command E2E template %q has no active reviewed fixture", command)
}
templateCommands[command] = true
}
for command := range paramAliasCandidateCompleteCommands {
if activeCommands[command] {
templateCommands[command] = true
}
}
for command := range activeCommands {
if _, ok := paramAliasCompleteCommands[command]; !ok {
if !templateCommands[command] {
t.Errorf("active reviewed command %q has no complete-command E2E template", command)
}
}
if len(activeCommands) != len(paramAliasCompleteCommands) {
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(paramAliasCompleteCommands), len(activeCommands), activeCases)
if len(activeCommands) != len(templateCommands) {
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(templateCommands), len(activeCommands), activeCases)
}
for caseKey := range paramAliasRepresentativePayloadCases {
if !executedRepresentatives[caseKey] {
t.Errorf("representative final-payload case %q has no active reviewed fixture", caseKey)
}
}
if len(executedRepresentatives) != len(paramAliasRepresentativePayloadCases) {
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), len(paramAliasRepresentativePayloadCases))
activeRepresentatives := len(paramAliasRepresentativePayloadCases)
for caseKey := range paramAliasCandidateRepresentativePayloadCases {
if !activeFixtureCases[caseKey] {
continue
}
activeRepresentatives++
if !executedRepresentatives[caseKey] {
t.Errorf("candidate representative final-payload case %q was not executed", caseKey)
}
}
if len(executedRepresentatives) != activeRepresentatives {
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), activeRepresentatives)
}
}
@@ -1091,7 +1208,20 @@ func TestCrossPlatformCoverageNewAITableDeleteDisableAliasesPreserveConfirmation
}
func TestCrossPlatformCoverageNewParamAliasesCannotBypassConfirmation(t *testing.T) {
for _, test := range paramAliasNewConfirmationCases {
tests := append([]struct {
command string
emitted string
canonical string
}{}, paramAliasNewConfirmationCases...)
for _, candidate := range paramAliasCandidateConfirmationCases {
entry, exists := cli.LookupParamAlias(candidate.command)
target, active := entry.ResolveAlias(candidate.emitted)
if exists && active && target == candidate.canonical {
tests = append(tests, candidate)
}
}
for _, test := range tests {
test := test
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
@@ -1173,6 +1303,10 @@ func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
return variant, true
}
}
if ok {
return complete, true
}
complete, ok = paramAliasCandidateCompleteCommands[command]
return complete, ok
}
+3
View File
@@ -72,6 +72,9 @@ func missingChatCatalogCoveragePaths() []string {
"chat clear-messages",
"chat clear-red-point",
"chat data-auth cross-org",
"chat emotion favorite",
"chat emotion list",
"chat emotion send",
"chat group audit-join-validation",
"chat group list-all",
"chat group list-join-validations",
+56 -5
View File
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 422
publicShortcutCount = 415
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including the hidden historical minutes.shortcut_minutes_search contract.
schemaPublishedShortcutCount = 447
// including reviewed hidden compatibility and unavailable contracts.
schemaPublishedShortcutCount = 453
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 422
publiclyDeliveredShortcutCount = 415
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -114,7 +114,7 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
product := executeShortcutSchemaQuery(t, "chat")
productPayload, _ := product["product"].(map[string]any)
if got, want := int(product["count"].(float64)), 217; got != want {
if got, want := int(product["count"].(float64)), 220; got != want {
t.Fatalf("schema chat count = %d, want %d", got, want)
}
summaries := schemaContractObjectSlice(productPayload["tools"])
@@ -140,6 +140,57 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
assertChatCatalogCompleteLeafContracts(t)
}
func TestChatPersonalEmotionSchemaDeclaresUnpinnedIMAdapter(t *testing.T) {
for _, tc := range []struct {
cliPath string
params map[string]string
}{
{
cliPath: "chat emotion list",
},
{
cliPath: "chat emotion send",
params: map[string]string{
"media-id": "mediaId",
"emotion-id": "emotionId",
"group": "openConversationId",
"open-dingtalk-id": "receiverOpenDingTalkId",
"idempotency-key": "uuid",
},
},
{
cliPath: "chat emotion favorite",
params: map[string]string{
"media-id": "mediaId",
"name": "name",
"source-conversation-id": "sourceConversationId",
"source-message-id": "sourceMessageId",
},
},
} {
t.Run(tc.cliPath, func(t *testing.T) {
leaf := executeShortcutSchemaQuery(t, "--cli-path", tc.cliPath)
if got := schemaContractString(leaf["interface_mode"]); got != "composite" {
t.Fatalf("%s interface_mode = %q, want composite", tc.cliPath, got)
}
reason := schemaContractString(leaf["interface_reason"])
if !strings.Contains(reason, "Reviewed unpinned remote adapter") {
t.Fatalf("%s interface_reason = %q", tc.cliPath, reason)
}
parameters := schemaContractMap(leaf["parameters"])
for name, want := range tc.params {
parameter := parameters[name]
if parameter == nil {
t.Fatalf("%s missing --%s parameter: %#v", tc.cliPath, name, parameters)
}
if got := schemaContractString(parameter["property"]); got != want {
t.Fatalf("%s --%s property = %q, want %q", tc.cliPath, name, got, want)
}
}
})
}
}
func TestCrossPlatformCoverageAITableTableBootstrapPublishesResultContract(t *testing.T) {
leaf := executeShortcutSchemaQuery(t, "--cli-path", "aitable +table-bootstrap")
result, _ := leaf["result"].(map[string]any)
+3 -3
View File
@@ -191,12 +191,12 @@
"from": "oa +list-processes",
"mode": "ambiguous",
"candidates": [
"oa +list-forms",
"oa +my-initiated",
"oa +search-forms",
"oa approval list-submitted",
"oa approval list-initiated"
],
"reviewed": true,
"review_reason": "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf."
"review_reason": "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation and removes +my-initiated from discovery because guaranteed-zero responses omit hasMore; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search or exact atomic initiated routes."
},
{
"from": "chat +conversation-detail",
@@ -242,9 +242,9 @@ var generatedCommandPathFallbacks = []CommandPathFallback{
{
From: "oa +list-processes",
Mode: "ambiguous",
Candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
Candidates: []string{"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"},
Reviewed: true,
ReviewReason: "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf.",
ReviewReason: "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation and removes +my-initiated from discovery because guaranteed-zero responses omit hasMore; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search or exact atomic initiated routes.",
},
}
+1 -1
View File
@@ -172,7 +172,7 @@ func TestCrossPlatformCoverageCommandPathFallbackAuditCoverage(t *testing.T) {
"chat +send-file": {"chat +messages-send", "chat message send"},
"chat +send-image": {"chat +messages-send", "chat message send"},
"chat +send-media": {"chat +messages-send", "chat message send"},
"oa +list-processes": {"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
"oa +list-processes": {"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"},
"doc +template": {"doc +template-list", "doc +template-search", "doc +create-from-template"},
"doc +version": {"doc +history-list", "doc +history-save", "doc +history-revert"},
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+366 -3
View File
@@ -7,6 +7,8 @@ import (
"io"
"math"
"os"
"reflect"
"sort"
"strconv"
"strings"
"time"
@@ -126,6 +128,83 @@ func resolveWorkflowDSL(cmd *cobra.Command) (map[string]any, error) {
return dsl, nil
}
// executeAitableWorkflowPublish executes a publish exactly once, then requires
// the reviewed valid/flowId envelope before rendering any success output.
// create_workflow is non-idempotent, so transport uncertainty is never retried.
func executeAitableWorkflowPublish(toolName string, args map[string]any) error {
if deps.Caller.DryRun() {
return callMCPToolOnServer("aitable", toolName, args)
}
raw, err := callMCPToolReturnTextOnServer(context.Background(), "aitable", toolName, args)
if err != nil {
return err
}
var envelope map[string]any
if err := json.Unmarshal([]byte(raw), &envelope); err != nil || envelope == nil {
return fmt.Errorf("%s response is not a JSON object", toolName)
}
valid, validFound, flowID, err := strictAitableWorkflowPublishResult(envelope)
if err != nil {
return fmt.Errorf("%s response validation failed: %w", toolName, err)
}
if !validFound {
return fmt.Errorf("%s response is missing valid", toolName)
}
if !valid {
return fmt.Errorf("%s returned valid=false; inspect issues and correct the workflow DSL", toolName)
}
if flowID == "" {
return fmt.Errorf("%s response is missing a non-empty flowId", toolName)
}
return RenderLegacyMCPText(toolName, raw)
}
func strictAitableWorkflowPublishResult(envelope map[string]any) (valid bool, validFound bool, flowID string, err error) {
var visit func(map[string]any) error
visit = func(object map[string]any) error {
if raw, exists := object["valid"]; exists {
value, ok := raw.(bool)
if !ok {
return fmt.Errorf("valid must be boolean, got %T", raw)
}
if validFound && valid != value {
return fmt.Errorf("conflicting valid values")
}
valid, validFound = value, true
}
for _, key := range []string{"flowId", "workflowId"} {
raw, exists := object[key]
if !exists {
continue
}
value, ok := raw.(string)
value = strings.TrimSpace(value)
if !ok || value == "" {
return fmt.Errorf("%s must be a non-empty string", key)
}
if flowID != "" && flowID != value {
return fmt.Errorf("conflicting workflow IDs")
}
flowID = value
}
for _, key := range []string{"data", "result", "response"} {
if nested, ok := object[key].(map[string]any); ok {
if err := visit(nested); err != nil {
return err
}
}
}
return nil
}
if envelope == nil {
return false, false, "", fmt.Errorf("empty response")
}
if err := visit(envelope); err != nil {
return false, false, "", err
}
return valid, validFound, flowID, nil
}
func validateWorkflowRunFlags(cmd *cobra.Command, _ []string) error {
tableID, _ := cmd.Flags().GetString("table-id")
tableID = strings.TrimSpace(tableID)
@@ -1184,6 +1263,290 @@ func runAitableViewUpdateArray(cmd *cobra.Command, blockKey string) error {
return callUpdateViewWithBlock(baseID, tableID, viewID, blockKey, cfgMap[blockKey], nil)
}
func runAitableViewUpdateFilter(cmd *cobra.Command) error {
baseID, tableID, viewID, _, err := viewUpdateCommonPreflight(cmd, "filter", nil, false)
if err != nil {
return err
}
jsonStr, _ := cmd.Flags().GetString("json")
if jsonStr == "" {
return fmt.Errorf("必须指定 --json 传入 filter JSON 数组")
}
var parsed any
if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil {
return fmt.Errorf("--json 解析失败: %v", err)
}
cfgMap := map[string]any{"filter": parsed}
if err := normalizeViewConfigBlock(cfgMap); err != nil {
return err
}
filter, _ := cfgMap["filter"].([]any)
fieldTypes, err := loadAitableFieldTypes(context.Background(), baseID, tableID)
if err != nil {
return err
}
if err := validateAitableViewFilter(filter, fieldTypes); err != nil {
return err
}
toolArgs := map[string]any{
"baseId": baseID, "tableId": tableID, "viewId": viewID,
"config": map[string]any{"filter": filter},
}
if deps.Caller.DryRun() {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true, "executed": false, "tool": "update_view", "arguments": toolArgs,
})
}
if _, err := callMCPToolReturnTextOnServer(context.Background(), "aitable", "update_view", toolArgs); err != nil {
return err
}
var actual any
var readBackErr error
for attempt := 0; attempt < aitableViewFilterReadbackAttempts; attempt++ {
if attempt > 0 {
backoff := time.Duration(1<<(attempt-1)) * time.Second
if backoff > 8*time.Second {
backoff = 8 * time.Second
}
aitableViewFilterReadbackSleep(backoff)
}
view, _, err := getViewRaw(context.Background(), baseID, tableID, viewID)
if err != nil {
readBackErr = err
continue
}
actualViewID, _ := view["viewId"].(string)
if actualViewID != viewID {
readBackErr = fmt.Errorf("update_view read-back returned viewId %q, want %q", actualViewID, viewID)
continue
}
actual = walkViewPath(view, "filter")
if persistedViewFilterMatches(actual, filter) {
readBackErr = nil
break
}
readBackErr = fmt.Errorf("update_view filter read-back mismatch: got %s, want %s", compactJSON(actual), compactJSON(filter))
}
if readBackErr != nil {
return &CLIError{Code: CodeMCPToolError, Message: readBackErr.Error(), Suggestion: "重新读取 view get filter,确认服务端支持所用字段类型和操作符后再试"}
}
return deps.Out.PrintJSON(map[string]any{
"success": true,
"data": map[string]any{"baseId": baseID, "tableId": tableID, "viewId": viewID, "filter": filter, "verified": true},
})
}
const aitableViewFilterReadbackAttempts = 6
var aitableViewFilterReadbackSleep = time.Sleep
func persistedViewFilterMatches(actual any, expected []any) bool {
if reflect.DeepEqual(actual, expected) {
return true
}
root, ok := actual.(map[string]any)
if !ok || root["operator"] != "and" {
return false
}
operands, ok := root["operands"].([]any)
return ok && reflect.DeepEqual(operands, expected)
}
func loadAitableFieldTypes(ctx context.Context, baseID, tableID string) (map[string]string, error) {
raw, err := callMCPReadToolReturnTextOnServer(ctx, "aitable", "get_fields", map[string]any{"baseId": baseID, "tableId": tableID})
if err != nil {
return nil, err
}
var payload any
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return nil, fmt.Errorf("get_fields response is not valid JSON: %v", err)
}
fields, ok := findAitableObjectList(payload, "fields", "fieldList")
if !ok {
return nil, fmt.Errorf("get_fields response is missing the fields collection")
}
types := make(map[string]string, len(fields))
for index, field := range fields {
fieldID, _ := field["fieldId"].(string)
if strings.TrimSpace(fieldID) == "" {
fieldID, _ = field["id"].(string)
}
fieldType, _ := field["type"].(string)
if strings.TrimSpace(fieldType) == "" {
fieldType, _ = field["fieldType"].(string)
}
if strings.TrimSpace(fieldID) == "" || strings.TrimSpace(fieldType) == "" {
return nil, fmt.Errorf("get_fields field %d is missing fieldId or type", index)
}
types[strings.TrimSpace(fieldID)] = strings.TrimSpace(fieldType)
}
return types, nil
}
func findAitableObjectList(value any, names ...string) ([]map[string]any, bool) {
wanted := make([]string, 0, len(names))
seen := make(map[string]bool, len(names))
for _, name := range names {
name = strings.ToLower(name)
if !seen[name] {
wanted = append(wanted, name)
seen[name] = true
}
}
var walk func(any) ([]map[string]any, bool)
walk = func(current any) ([]map[string]any, bool) {
switch typed := current.(type) {
case map[string]any:
keys := make([]string, 0, len(typed))
for key := range typed {
keys = append(keys, key)
}
sort.Strings(keys)
for _, name := range wanted {
for _, key := range keys {
if !strings.EqualFold(key, name) {
continue
}
items, ok := typed[key].([]any)
if !ok {
return nil, false
}
out := make([]map[string]any, 0, len(items))
for _, item := range items {
object, ok := item.(map[string]any)
if !ok {
return nil, false
}
out = append(out, object)
}
return out, true
}
}
for _, key := range keys {
if found, ok := walk(typed[key]); ok {
return found, true
}
}
case []any:
for _, child := range typed {
if found, ok := walk(child); ok {
return found, true
}
}
}
return nil, false
}
return walk(value)
}
func validateAitableViewFilter(filter []any, fieldTypes map[string]string) error {
for index, raw := range filter {
condition, ok := raw.(map[string]any)
if !ok {
return fmt.Errorf("filter[%d] must be an object", index)
}
if err := validateAitableViewFilterCondition(condition, fieldTypes); err != nil {
return fmt.Errorf("filter[%d]: %w", index, err)
}
}
return nil
}
func validateAitableViewFilterCondition(condition map[string]any, fieldTypes map[string]string) error {
operator, _ := condition["operator"].(string)
operator = strings.TrimSpace(operator)
if operator == "" || !validFilterOperators[operator] {
return fmt.Errorf("unsupported operator %q", operator)
}
operands, ok := condition["operands"].([]any)
if !ok {
return fmt.Errorf("operator %s requires an operands array", operator)
}
if operator == "and" || operator == "or" {
return fmt.Errorf("logical operator %s is not supported by the persisted view protocol; pass a flat array of leaf conditions (combined as AND)", operator)
}
wantOperands := 2
if operator == "exist" || operator == "un_exist" {
wantOperands = 1
}
if len(operands) != wantOperands {
return fmt.Errorf("operator %s requires %d operands", operator, wantOperands)
}
fieldID, ok := operands[0].(string)
fieldID = strings.TrimSpace(fieldID)
if !ok || fieldID == "" {
return fmt.Errorf("operator %s requires a fieldId as its first operand", operator)
}
fieldType, exists := fieldTypes[fieldID]
if !exists {
return fmt.Errorf("filter references unknown fieldId %q", fieldID)
}
if isAitableDateFieldType(fieldType) && !isAitableDateFilterOperator(operator) {
return fmt.Errorf("operator %s is invalid for %s field %s; use date_eq/before/after/not_before/not_after/exist/un_exist", operator, fieldType, fieldID)
}
if operator == "any_of" || operator == "all_of" || operator == "none_of" {
if !strings.EqualFold(fieldType, "multipleSelect") && !strings.EqualFold(fieldType, "multiSelect") {
return fmt.Errorf("operator %s requires a multipleSelect field, got %s", operator, fieldType)
}
if operator == "any_of" {
if values, ok := operands[1].([]any); ok {
if err := validateAitableMultiSelectOptionNames(values); err != nil {
return err
}
return fmt.Errorf("multipleSelect any_of with multiple values is not supported by the persisted view protocol; use one scalar option or separate views")
}
}
if err := validateAitableMultiSelectFilterValue(operands[1]); err != nil {
return err
}
}
return nil
}
func validateAitableMultiSelectOptionNames(values []any) error {
if len(values) == 0 {
return fmt.Errorf("multipleSelect any_of array must not be empty")
}
for index, value := range values {
text, ok := value.(string)
text = strings.TrimSpace(text)
if !ok || text == "" {
return fmt.Errorf("multipleSelect any_of value %d must be a non-empty option-name string", index)
}
}
return nil
}
func isAitableDateFieldType(fieldType string) bool {
return strings.EqualFold(fieldType, "date") ||
strings.EqualFold(fieldType, "createdTime") ||
strings.EqualFold(fieldType, "lastModifiedTime")
}
func isAitableDateFilterOperator(operator string) bool {
switch operator {
case "date_eq", "before", "after", "not_before", "not_after", "exist", "un_exist":
return true
default:
return false
}
}
func validateAitableMultiSelectFilterValue(value any) error {
if typed, ok := value.(string); ok && strings.TrimSpace(typed) != "" {
return nil
}
return fmt.Errorf("multipleSelect filter value must be one option-name string; the persisted view protocol does not support a multi-value OR expression")
}
func compactJSON(value any) string {
raw, err := json.Marshal(value)
if err != nil {
return fmt.Sprintf("%#v", value)
}
return string(raw)
}
func newAitableCommand() *cobra.Command {
// Product-level Agent routing Decl (migrated from selection/aitable.json
// products.aitable). Catalog assembly stamps provenance contract_final.
@@ -4076,7 +4439,7 @@ colorConfigs (JSON 数组) / officialHoliday (bool)。`,
若传对象会自动 wrap 为数组;其他非法格式拒绝。`,
Example: ` dws aitable view update filter --view-id VIEW_ID --json '[{"operator":"and","operands":[{"operator":"eq","operands":["fldX","value"]}]}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
return runAitableViewUpdateArray(cmd, "filter")
return runAitableViewUpdateFilter(cmd)
},
}
DeclareLeafMetadata(viewUpdateFilterCmd, LeafSpec{
@@ -5282,7 +5645,7 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
}
// create_workflow is non-idempotent. Bypass the retry wrapper to
// prevent an uncertain first response from creating a duplicate.
return callMCPToolOnServer("aitable", "create_workflow", toolArgs)
return executeAitableWorkflowPublish("create_workflow", toolArgs)
},
}
DeclareLeafMetadata(workflowCreateCmd, LeafSpec{
@@ -5376,7 +5739,7 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
if locale, _ := cmd.Flags().GetString("locale"); strings.TrimSpace(locale) != "" {
toolArgs["locale"] = locale
}
return callAitableTool("update_workflow", toolArgs)
return executeAitableWorkflowPublish("update_workflow", toolArgs)
},
}
DeclareLeafMetadata(workflowUpdateCmd, LeafSpec{
@@ -361,3 +361,228 @@ func TestCrossPlatformCoverageAitableDeleteCancellationEdges(t *testing.T) {
_ = runAitableCoverageCommand(t, &aitableCommandCoverageCaller{}, args...)
}
}
func TestCrossPlatformCoverageAitableViewFilterValidationAndReadBack(t *testing.T) {
testseam.Swap(t, &aitableViewFilterReadbackSleep, func(time.Duration) {})
oldArgs := os.Args
t.Cleanup(func() { os.Args = oldArgs })
os.Args = []string{"dws", "aitable"}
filter := `[{"operator":"eq","operands":["fldA","x"]},{"operator":"any_of","operands":["fldMulti","A"]}]`
fields := `{"data":{"fields":[{"fieldId":"fldA","type":"text"},{"fieldId":"fldB","type":"text"},{"fieldId":"fldMulti","type":"multipleSelect"}]}}`
readBack := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":[{"operator":"eq","operands":["fldA","x"]},{"operator":"any_of","operands":["fldMulti","A"]}]}]}}`
t.Run("flat leaf filters are written then exactly verified", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`, readBack}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter)
if err != nil || len(caller.calls) != 3 || caller.calls[0].tool != "get_fields" || caller.calls[1].tool != "update_view" || caller.calls[2].tool != "get_views" {
t.Fatalf("verified view filter = err:%v calls:%#v", err, caller.calls)
}
config, _ := caller.calls[1].args["config"].(map[string]any)
if _, ok := config["filter"].([]any); !ok {
t.Fatalf("update_view filter encoding = %#v", caller.calls[1].args)
}
})
t.Run("logical groups fail closed before write", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"or","operands":[{"operator":"eq","operands":["fldA","x"]},{"operator":"eq","operands":["fldB","y"]}]}]`)
if err == nil || !strings.Contains(err.Error(), "persisted view protocol") || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
t.Fatalf("logical filter group fail-closed = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("unknown field is rejected before write", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"eq","operands":["missing","x"]}]`)
if err == nil || !strings.Contains(err.Error(), "unknown fieldId") || len(caller.calls) != 1 {
t.Fatalf("unknown filter field = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("eventual persisted and wrapper is retried then verified", func(t *testing.T) {
input := `[{"operator":"any_of","operands":["fldMulti","A"]}]`
stale := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":{"operator":"and","operands":[]}}]}}`
terminal := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":{"operator":"and","operands":[{"operator":"any_of","operands":["fldMulti","A"]}]}}]}}`
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`, stale, terminal}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
if err != nil || len(caller.calls) != 4 || caller.calls[2].tool != "get_views" || caller.calls[3].tool != "get_views" {
t.Fatalf("eventual wrapped filter = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("multi-select operator rejects text field", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"any_of","operands":["fldA",["A"]]}]`)
if err == nil || !strings.Contains(err.Error(), "requires a multipleSelect field") || len(caller.calls) != 1 {
t.Fatalf("wrong filter field type = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("multi-select array fails closed before write", func(t *testing.T) {
input := `[{"operator":"any_of","operands":["fldMulti",["A","B"]]}]`
caller := &aitableTestCaller{responses: []string{fields}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
if err == nil || !strings.Contains(err.Error(), "persisted view protocol") || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
t.Fatalf("multi-select array fail-closed = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("multi-select invalid array value fails before write", func(t *testing.T) {
input := `[{"operator":"any_of","operands":["fldMulti",["A",""]]}]`
caller := &aitableTestCaller{responses: []string{fields}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
if err == nil || !strings.Contains(err.Error(), "non-empty option-name") || len(caller.calls) != 1 {
t.Fatalf("invalid multi-select array = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("date and system-time fields require date operators", func(t *testing.T) {
fieldTypes := map[string]string{"date": "date", "created": "createdTime", "modified": "lastModifiedTime"}
for fieldID := range fieldTypes {
valid := []any{map[string]any{"operator": "date_eq", "operands": []any{fieldID, "2026-08-18"}}}
if err := validateAitableViewFilter(valid, fieldTypes); err != nil {
t.Fatalf("date_eq for %s: %v", fieldID, err)
}
invalid := []any{map[string]any{"operator": "eq", "operands": []any{fieldID, "2026-08-18"}}}
if err := validateAitableViewFilter(invalid, fieldTypes); err == nil || !strings.Contains(err.Error(), "invalid for") {
t.Fatalf("eq for %s = %v, want date-operator error", fieldID, err)
}
}
})
t.Run("dry-run validates but performs no write or read-back", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields}, dryRun: true}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter, "--dry-run")
if err != nil || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
t.Fatalf("view filter dry-run = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("read-back mismatch is not success", func(t *testing.T) {
responses := []string{fields, `{"success":true}`}
for range aitableViewFilterReadbackAttempts {
responses = append(responses, `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":[]}]}}`)
}
caller := &aitableTestCaller{responses: responses}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter)
if err == nil || !strings.Contains(err.Error(), "read-back mismatch") || len(caller.calls) != 2+aitableViewFilterReadbackAttempts {
t.Fatalf("mismatched filter readback = err:%v calls:%#v", err, caller.calls)
}
})
}
func TestCrossPlatformCoverageAitableViewFilterFailureAndShapeEdges(t *testing.T) {
testseam.Swap(t, &aitableViewFilterReadbackSleep, func(time.Duration) {})
testseam.Protect(t, &os.Args)
os.Args = []string{"dws", "aitable"}
fields := `{"data":{"fields":[{"fieldId":"fldA","type":"text"}]}}`
filter := `[{"operator":"eq","operands":["fldA","x"]}]`
args := []string{"view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json=" + filter}
t.Run("update transport error", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields}, errors: []error{nil, context.Canceled}}
if err := runAitableCoverageCommand(t, caller, args...); err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || len(caller.calls) != 2 {
t.Fatalf("update error = %v, calls=%#v", err, caller.calls)
}
})
t.Run("readback transport errors exhaust", func(t *testing.T) {
errs := []error{nil, nil}
for range aitableViewFilterReadbackAttempts {
errs = append(errs, context.DeadlineExceeded)
}
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`}, errors: errs}
if err := runAitableCoverageCommand(t, caller, args...); err == nil || len(caller.calls) != 2+aitableViewFilterReadbackAttempts {
t.Fatalf("readback errors = %v, calls=%d", err, len(caller.calls))
}
})
t.Run("readback wrong identity exhausts", func(t *testing.T) {
responses := []string{fields, `{"success":true}`}
for range aitableViewFilterReadbackAttempts {
responses = append(responses, `{"data":{"views":[{"viewId":"other","filter":[]}]}}`)
}
caller := &aitableTestCaller{responses: responses}
if err := runAitableCoverageCommand(t, caller, args...); err == nil || !strings.Contains(err.Error(), "returned viewId") {
t.Fatalf("wrong readback identity = %v", err)
}
})
loadCases := []struct {
name string
response string
callErr error
wantError string
}{
{name: "transport", callErr: context.Canceled, wantError: "context canceled"},
{name: "invalid json", response: `{`, wantError: "not valid JSON"},
{name: "missing collection", response: `{}`, wantError: "missing the fields collection"},
{name: "missing identity", response: `{"fields":[{"type":"text"}]}`, wantError: "missing fieldId or type"},
}
for _, tc := range loadCases {
t.Run("load fields "+tc.name, func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{tc.response}, errors: []error{tc.callErr}}
installAitableDeps(t, caller)
if _, err := loadAitableFieldTypes(context.Background(), "b", "t"); err == nil || !strings.Contains(err.Error(), tc.wantError) {
t.Fatalf("load fields error = %v, want %q", err, tc.wantError)
}
})
}
t.Run("load legacy field keys", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{`{"fieldList":[{"id":"legacy","fieldType":"text"}]}`}}
installAitableDeps(t, caller)
got, err := loadAitableFieldTypes(context.Background(), "b", "t")
if err != nil || got["legacy"] != "text" {
t.Fatalf("legacy field keys = %#v, %v", got, err)
}
})
if _, ok := findAitableObjectList(map[string]any{"fields": "bad"}, "fields"); ok {
t.Fatal("scalar fields collection must fail")
}
if _, ok := findAitableObjectList(map[string]any{"fields": []any{"bad"}}, "fields"); ok {
t.Fatal("scalar field item must fail")
}
if got, ok := findAitableObjectList([]any{"skip", map[string]any{"nested": map[string]any{"fields": []any{map[string]any{"fieldId": "f"}}}}}, "fields"); !ok || len(got) != 1 {
t.Fatalf("recursive fields = %#v, %v", got, ok)
}
if got, ok := findAitableObjectList(map[string]any{
"fieldList": []any{map[string]any{"fieldId": "legacy"}},
"fields": []any{map[string]any{"fieldId": "canonical"}},
}, "fields", "fieldList"); !ok || len(got) != 1 || got[0]["fieldId"] != "canonical" {
t.Fatalf("declared collection priority = %#v, %v", got, ok)
}
invalidFilters := []struct {
filter []any
want string
}{
{filter: []any{"bad"}, want: "must be an object"},
{filter: []any{map[string]any{"operator": "bogus", "operands": []any{}}}, want: "unsupported operator"},
{filter: []any{map[string]any{"operator": "eq", "operands": "bad"}}, want: "requires an operands array"},
{filter: []any{map[string]any{"operator": "and", "operands": []any{}}}, want: "logical operator"},
{filter: []any{map[string]any{"operator": "exist", "operands": []any{"f", "extra"}}}, want: "requires 1 operands"},
{filter: []any{map[string]any{"operator": "eq", "operands": []any{1, "x"}}}, want: "requires a fieldId"},
{filter: []any{map[string]any{"operator": "any_of", "operands": []any{"multi", 1}}}, want: "one option-name string"},
}
for _, tc := range invalidFilters {
if err := validateAitableViewFilter(tc.filter, map[string]string{"f": "text", "multi": "multipleSelect"}); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("validate filter %#v = %v, want %q", tc.filter, err, tc.want)
}
}
if err := validateAitableMultiSelectOptionNames(nil); err == nil {
t.Fatal("empty any_of array must fail")
}
if err := validateAitableMultiSelectOptionNames([]any{"ok", 1}); err == nil {
t.Fatal("non-string any_of option must fail")
}
if err := validateAitableMultiSelectOptionNames([]any{"first", " second "}); err != nil {
t.Fatalf("valid any_of option names = %v", err)
}
if got := compactJSON(make(chan int)); !strings.HasPrefix(got, "(chan int)") {
t.Fatalf("compactJSON fallback = %q", got)
}
if persistedViewFilterMatches("bad", nil) || persistedViewFilterMatches(map[string]any{"operator": "or"}, nil) {
t.Fatal("invalid persisted wrapper must not match")
}
}
+5 -1
View File
@@ -24,6 +24,7 @@ type aitableTestCaller struct {
responses []string
errors []error
calls []aitableTestCall
dryRun bool
}
func (c *aitableTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
@@ -38,8 +39,11 @@ func (c *aitableTestCaller) CallTool(_ context.Context, server, tool string, arg
}
return textToolResult(response), nil
}
func (c *aitableTestCaller) CallReadTool(ctx context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
return c.CallTool(ctx, server, tool, args)
}
func (*aitableTestCaller) Format() string { return "json" }
func (*aitableTestCaller) DryRun() bool { return false }
func (c *aitableTestCaller) DryRun() bool { return c.dryRun }
func (*aitableTestCaller) Fields() string { return "" }
func (*aitableTestCaller) JQ() string { return "" }
@@ -23,27 +23,42 @@ type aitableWorkflowCall struct {
}
type aitableWorkflowCaller struct {
calls []aitableWorkflowCall
calls []aitableWorkflowCall
response string
err error
dryRun bool
}
func (c *aitableWorkflowCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, aitableWorkflowCall{productID: productID, toolName: toolName, args: args})
if c.err != nil {
return nil, c.err
}
response := c.response
if response == "" {
response = `{"status":"success","data":{"valid":true,"flowId":"flow-test","issues":[]}}`
}
return &edition.ToolResult{Content: []edition.ContentBlock{{
Type: "text",
Text: `{"status":"success","data":{"valid":true,"flowId":"flow-test","issues":[]}}`,
Text: response,
}}}, nil
}
func (*aitableWorkflowCaller) Format() string { return "json" }
func (*aitableWorkflowCaller) DryRun() bool { return false }
func (c *aitableWorkflowCaller) DryRun() bool { return c.dryRun }
func (*aitableWorkflowCaller) Fields() string { return "" }
func (*aitableWorkflowCaller) JQ() string { return "" }
func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*aitableWorkflowCaller, error) {
t.Helper()
caller := &aitableWorkflowCaller{}
return caller, runAitableWorkflowCommandWithCaller(t, caller, stdin, args...)
}
func runAitableWorkflowCommandWithCaller(t *testing.T, caller *aitableWorkflowCaller, stdin io.Reader, args ...string) error {
t.Helper()
testseam.Protect(t, &os.Args)
caller := &aitableWorkflowCaller{}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
os.Args = append([]string{"dws", "aitable", "workflow"}, args...)
@@ -51,6 +66,7 @@ func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*
cmd := newAitableCommand()
cmd.PersistentFlags().String("format", "json", "output format")
cmd.PersistentFlags().Bool("yes", false, "skip confirmation")
cmd.PersistentFlags().Bool("dry-run", false, "preview only")
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs(append([]string{"workflow"}, args...))
@@ -58,10 +74,10 @@ func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*
stdin = strings.NewReader("")
}
cmd.SetIn(stdin)
return caller, cmd.Execute()
return cmd.Execute()
}
func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
func TestCrossPlatformCoverageAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
wantDSL := map[string]any{
"version": "workflow-dsl/v1",
"name": "create test",
@@ -92,6 +108,65 @@ func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
}
}
func TestCrossPlatformCoverageAitableWorkflowPublishRejectsFalseSuccess(t *testing.T) {
tests := []struct {
name string
response string
want string
}{
{name: "valid false", response: `{"status":"success","data":{"valid":false,"issues":[{"message":"bad dsl"}]}}`, want: "valid=false"},
{name: "missing valid", response: `{"status":"success","data":{"flowId":"flow-test"}}`, want: "missing valid"},
{name: "missing flow id", response: `{"status":"success","data":{"valid":true}}`, want: "missing a non-empty flowId"},
{name: "wrong valid type", response: `{"status":"success","data":{"valid":"true","flowId":"flow-test"}}`, want: "valid must be boolean"},
{name: "malformed response", response: `{`, want: "not a JSON object"},
{name: "null response", response: `null`, want: "not a JSON object"},
{name: "conflicting valid", response: `{"valid":true,"data":{"valid":false,"flowId":"flow-test"}}`, want: "conflicting valid values"},
{name: "invalid flow id", response: `{"valid":true,"flowId":1}`, want: "flowId must be a non-empty string"},
{name: "conflicting workflow ids", response: `{"valid":true,"flowId":"one","data":{"workflowId":"two"}}`, want: "conflicting workflow IDs"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &aitableWorkflowCaller{response: tc.response}
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
if err == nil || !strings.Contains(err.Error(), tc.want) || len(caller.calls) != 1 {
t.Fatalf("workflow false success = err:%v calls:%#v", err, caller.calls)
}
})
}
t.Run("update uses the same strict contract", func(t *testing.T) {
caller := &aitableWorkflowCaller{response: `{"status":"success","data":{"valid":false}}`}
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
"update", "--base-id", "base", "--workflow-id", "flow", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
if err == nil || !strings.Contains(err.Error(), "valid=false") || len(caller.calls) != 1 || caller.calls[0].toolName != "update_workflow" {
t.Fatalf("workflow update false success = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("dry-run does not call publish tool", func(t *testing.T) {
caller := &aitableWorkflowCaller{dryRun: true}
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`, "--dry-run")
if err != nil || len(caller.calls) != 0 {
t.Fatalf("workflow create dry-run = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("transport error", func(t *testing.T) {
caller := &aitableWorkflowCaller{err: context.Canceled}
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || len(caller.calls) != 1 {
t.Fatalf("workflow transport error = err:%v calls:%#v", err, caller.calls)
}
})
if _, _, _, err := strictAitableWorkflowPublishResult(nil); err == nil || !strings.Contains(err.Error(), "empty response") {
t.Fatalf("nil workflow envelope = %v", err)
}
}
func TestAitableWorkflowEditExampleMapsEmptyArguments(t *testing.T) {
caller, err := runAitableWorkflowCommand(t, nil, "edit-example")
if err != nil {
+1 -1
View File
@@ -10569,7 +10569,7 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
chatCategoryCmd.AddCommand(chatCategoryCreateSmartCmd)
chatMessageCmd.AddCommand(chatMessageListDirectCmd, chatMessageSearchCommonCmd, chatMessageCombineForwardCmd, chatMessageForwardTopicCmd, chatMessageSetPinCmd, chatMessageUnsetPinCmd, chatMessageListPinCmd, chatMessageAddFavoriteCmd, chatMessageRemoveFavoriteCmd, chatMessageListFavoritesCmd, chatMessageSetTopMsgCmd, chatMessageUnsetTopMsgCmd, chatMessageListEmotionRepliesCmd)
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd, newChatToolbarCommand())
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd, newChatToolbarCommand(), newChatEmotionCommand())
// Keep the v1.0.56 command surface recognizable while directing callers to
// the supported nested commands. The chat root's "im" alias makes these
+253
View File
@@ -0,0 +1,253 @@
package helpers
import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
"github.com/spf13/cobra"
)
const personalEmotionUnpinnedReason = "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command."
func newChatEmotionCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "emotion",
Short: "个人收藏表情",
Long: "查询、发送和新增当前用户的个人收藏表情。",
RunE: groupRunE,
}
cmd.AddCommand(
newChatEmotionListCommand(),
newChatEmotionSendCommand(),
newChatEmotionFavoriteCommand(),
)
return cmd
}
func newChatEmotionListCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "列出个人收藏表情",
RunE: func(cmd *cobra.Command, args []string) error {
return callMCPToolOnServer("im", "list_personal_emotions", map[string]any{})
},
}
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat", Name: "list_personal_emotions",
CanonicalPath: "chat.list_personal_emotions", CLIPath: "chat emotion list", PrimaryCLIPath: "chat emotion list",
},
Description: "列出当前用户的个人收藏表情",
Interface: &contract.InterfaceSpec{
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
},
Selection: contract.SelectionSpec{
AgentSummary: "列出当前用户的个人收藏表情",
UseWhen: []string{"需要查看当前用户已收藏的表情、获取 emotionId 或 mediaId 时"},
AvoidWhen: []string{"查询消息 reaction 使用 chat message list-emotion-replies"},
Examples: []string{"dws chat emotion list --format json"},
},
},
})
return cmd
}
func newChatEmotionSendCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "send",
Short: "发送个人收藏表情",
Long: `发送当前用户的个人收藏表情。
⚠️ 重要:该接口会真实发送表情到目标会话,不可用于测试或试探性调用。调用前必须确认表情媒体 ID 和接收对象无误。`,
Example: ` dws chat emotion send --media-id <mediaId> --group <openConversationId>
dws chat emotion send --media-id <mediaId> --emotion-id <emotionId> --user <userId>
dws chat emotion send --media-id <mediaId> --open-dingtalk-id <openDingTalkId> --uuid <idempotencyKey>`,
RunE: func(cmd *cobra.Command, args []string) error {
mediaID, _ := cmd.Flags().GetString("media-id")
if strings.TrimSpace(mediaID) == "" {
return fmt.Errorf("--media-id is required")
}
target, err := personalEmotionSendTarget(cmd)
if err != nil {
return err
}
payload := map[string]any{"mediaId": strings.TrimSpace(mediaID)}
emotionID, _ := cmd.Flags().GetString("emotion-id")
if strings.TrimSpace(emotionID) != "" {
payload["emotionId"] = strings.TrimSpace(emotionID)
}
for key, value := range target {
payload[key] = value
}
if uuid := strings.TrimSpace(flagOrFallback(cmd, "uuid", "idempotency-key")); uuid != "" {
payload["uuid"] = uuid
}
return callMCPToolOnServer("im", "send_personal_emotion", payload)
},
}
cmd.Flags().String("media-id", "", "表情媒体 ID (必填)")
cmd.Flags().String("emotion-id", "", "表情 ID")
cmd.Flags().String("conversation-id", "", "群聊 openConversationId")
cmd.Flags().String("group", "", "群聊 openConversationId(--conversation-id 别名)")
cmd.Flags().String("user", "", "单聊接收人 userId;CLI 会解析为 openDingTalkId")
cmd.Flags().String("open-dingtalk-id", "", "单聊接收人 openDingTalkId")
cmd.Flags().String("uuid", "", "幂等键")
cmd.Flags().String("idempotency-key", "", "幂等键(--uuid 别名)")
cmd.MarkFlagsMutuallyExclusive("conversation-id", "group")
cli.AnnotateRuntimeConstraints(cmd, cli.RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"conversation-id", "group", "user", "open-dingtalk-id"}},
RequireOneOf: [][]string{{"conversation-id", "group", "user", "open-dingtalk-id"}},
})
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
},
Contract: personalEmotionSendContract(),
})
return cmd
}
func newChatEmotionFavoriteCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "favorite",
Short: "新增个人收藏表情",
Example: ` dws chat emotion favorite --media-id <mediaId> --name "赞"
dws chat emotion favorite --media-id <mediaId> --source-conversation-id <cid> --source-message-id <mid>`,
RunE: func(cmd *cobra.Command, args []string) error {
mediaID, _ := cmd.Flags().GetString("media-id")
if strings.TrimSpace(mediaID) == "" {
return fmt.Errorf("--media-id is required")
}
sourceConversationID, _ := cmd.Flags().GetString("source-conversation-id")
sourceMessageID, _ := cmd.Flags().GetString("source-message-id")
if err := validatePersonalEmotionSourcePair(sourceConversationID, sourceMessageID); err != nil {
return err
}
payload := map[string]any{"mediaId": strings.TrimSpace(mediaID)}
name, _ := cmd.Flags().GetString("name")
if strings.TrimSpace(name) != "" {
payload["name"] = strings.TrimSpace(name)
}
if strings.TrimSpace(sourceConversationID) != "" {
payload["sourceConversationId"] = strings.TrimSpace(sourceConversationID)
payload["sourceMessageId"] = strings.TrimSpace(sourceMessageID)
}
return callMCPToolOnServer("im", "favorite_personal_emotion", payload)
},
}
cmd.Flags().String("media-id", "", "待收藏 mediaId (必填)")
cmd.Flags().String("name", "", "表情名称")
cmd.Flags().String("source-conversation-id", "", "来源会话 ID;需与 --source-message-id 成对指定")
cmd.Flags().String("source-message-id", "", "来源消息 ID;需与 --source-conversation-id 成对指定")
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
},
Contract: personalEmotionFavoriteContract(),
})
return cmd
}
func personalEmotionSendTarget(cmd *cobra.Command) (map[string]any, error) {
groupID := strings.TrimSpace(flagOrFallback(cmd, "conversation-id", "group"))
userID, _ := cmd.Flags().GetString("user")
openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id")
userID = strings.TrimSpace(userID)
openDingTalkID = strings.TrimSpace(openDingTalkID)
specified := 0
for _, value := range []string{groupID, userID, openDingTalkID} {
if value != "" {
specified++
}
}
if specified != 1 {
return nil, fmt.Errorf("--conversation-id, --user or --open-dingtalk-id is required; specify exactly one")
}
if groupID != "" {
return map[string]any{"openConversationId": groupID}, nil
}
if openDingTalkID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
return nil, err
}
return map[string]any{"receiverOpenDingTalkId": openDingTalkID}, nil
}
if isOpenDingTalkID(userID) {
return map[string]any{"receiverOpenDingTalkId": userID}, nil
}
resolved, err := resolveOpenDingTalkID(cmd.Context(), userID)
if err != nil {
return nil, fmt.Errorf("cannot resolve --user %q to openDingTalkId: %w; pass --open-dingtalk-id instead", userID, err)
}
return map[string]any{"receiverOpenDingTalkId": resolved}, nil
}
func validatePersonalEmotionSourcePair(sourceConversationID, sourceMessageID string) error {
hasConversation := strings.TrimSpace(sourceConversationID) != ""
hasMessage := strings.TrimSpace(sourceMessageID) != ""
if hasConversation != hasMessage {
return fmt.Errorf("--source-conversation-id and --source-message-id must be specified together")
}
return nil
}
func personalEmotionSendContract() LeafContract {
return LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat", Name: "send_personal_emotion",
CanonicalPath: "chat.send_personal_emotion", CLIPath: "chat emotion send", PrimaryCLIPath: "chat emotion send",
},
Description: "以当前用户身份向群聊或单聊发送个人收藏表情",
Interface: &contract.InterfaceSpec{
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
},
Selection: contract.SelectionSpec{
AgentSummary: "以当前用户身份发送个人收藏表情",
UseWhen: []string{"用户明确要求发送个人收藏表情,且已提供 mediaId 或 emotionId 时"},
AvoidWhen: []string{"发送普通文本、Markdown 或文件时使用 chat message send"},
Examples: []string{"dws chat emotion send --media-id <mediaId> --group <openConversationId> --uuid <idempotencyKey>"},
},
Parameters: []contract.ParamDecl{
{Name: "media-id", Property: "mediaId", Required: boolPtr(true)},
{Name: "emotion-id", Property: "emotionId", Required: boolPtr(false)},
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)},
{Name: "group", Property: "openConversationId", Required: boolPtr(false)},
{Name: "user", Property: "receiverOpenDingTalkId", Required: boolPtr(false)},
{Name: "open-dingtalk-id", Property: "receiverOpenDingTalkId", Required: boolPtr(false)},
{Name: "uuid", Property: "uuid", Required: boolPtr(false)},
{Name: "idempotency-key", Property: "uuid", Required: boolPtr(false)},
},
}
}
func personalEmotionFavoriteContract() LeafContract {
return LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat", Name: "favorite_personal_emotion",
CanonicalPath: "chat.favorite_personal_emotion", CLIPath: "chat emotion favorite", PrimaryCLIPath: "chat emotion favorite",
},
Description: "将 mediaId 新增到当前用户的个人收藏表情",
Interface: &contract.InterfaceSpec{
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
},
Selection: contract.SelectionSpec{
AgentSummary: "新增当前用户的个人收藏表情",
UseWhen: []string{"用户要把一个 mediaId 收藏为个人表情时"},
AvoidWhen: []string{"收藏消息使用 chat message add-favorite"},
Examples: []string{"dws chat emotion favorite --media-id <mediaId> --name \"赞\""},
},
Parameters: []contract.ParamDecl{
{Name: "media-id", Property: "mediaId", Required: boolPtr(true)},
{Name: "name", Property: "name", Required: boolPtr(false)},
{Name: "source-conversation-id", Property: "sourceConversationId", Required: boolPtr(false), RequiredWhen: "source-message-id is provided"},
{Name: "source-message-id", Property: "sourceMessageId", Required: boolPtr(false), RequiredWhen: "source-conversation-id is provided"},
},
}
}
@@ -0,0 +1,254 @@
package helpers
import (
"context"
"io"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type personalEmotionCall struct {
server string
tool string
args map[string]any
}
type personalEmotionCaller struct {
calls []personalEmotionCall
}
func (c *personalEmotionCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
copied := make(map[string]any, len(args))
for key, value := range args {
copied[key] = value
}
c.calls = append(c.calls, personalEmotionCall{server: server, tool: tool, args: copied})
if server == "contact" && tool == "get_user_info_by_user_ids" {
return textToolResult(`{"result":[{"userId":"u1","openDingTalkId":"` + helperCurrentDOpenID2 + `"}]}`), nil
}
return textToolResult(`{"ok":true}`), nil
}
func (*personalEmotionCaller) Format() string { return "json" }
func (*personalEmotionCaller) DryRun() bool { return false }
func (*personalEmotionCaller) Fields() string { return "" }
func (*personalEmotionCaller) JQ() string { return "" }
func executePersonalEmotionCommand(t *testing.T, caller *personalEmotionCaller, args ...string) error {
t.Helper()
installHelpersCoreDeps(t, caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
root := newChatCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
return root.ExecuteContext(context.Background())
}
func requirePersonalEmotionCall(t *testing.T, caller *personalEmotionCaller, tool string, want map[string]any) {
t.Helper()
if len(caller.calls) != 1 {
t.Fatalf("calls = %d, want 1: %+v", len(caller.calls), caller.calls)
}
call := caller.calls[0]
if call.server != "im" || call.tool != tool {
t.Fatalf("tool call = %s/%s, want im/%s", call.server, call.tool, tool)
}
if !reflect.DeepEqual(call.args, want) {
t.Fatalf("args = %#v, want %#v", call.args, want)
}
}
func TestChatEmotionListCallsIMToolWithoutBusinessArgs(t *testing.T) {
// TC-001: list 无业务参数,当前用户身份由 MCP server 注入。
caller := &personalEmotionCaller{}
if err := executePersonalEmotionCommand(t, caller, "emotion", "list"); err != nil {
t.Fatalf("chat emotion list returned error: %v", err)
}
requirePersonalEmotionCall(t, caller, "list_personal_emotions", map[string]any{})
}
func TestChatEmotionSendMapsGroupTargetAndIdempotency(t *testing.T) {
// TC-002: 群聊目标映射为 openConversationId,uuid 与表情字段按 MCP 字段透传。
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller,
"emotion", "send",
"--media-id", "@media",
"--emotion-id", "emotion123",
"--group", "cid123",
"--idempotency-key", "idem-001",
)
if err != nil {
t.Fatalf("chat emotion send returned error: %v", err)
}
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
"mediaId": "@media",
"emotionId": "emotion123",
"openConversationId": "cid123",
"uuid": "idem-001",
})
}
func TestChatEmotionSendMapsOpenDingTalkTarget(t *testing.T) {
// TC-003: 已知 openDingTalkId 时直传 receiverOpenDingTalkId,不做外部解析。
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller,
"emotion", "send",
"--media-id", "@media",
"--open-dingtalk-id", helperCurrentDOpenID,
)
if err != nil {
t.Fatalf("chat emotion send returned error: %v", err)
}
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
"mediaId": "@media",
"receiverOpenDingTalkId": helperCurrentDOpenID,
})
}
func TestChatEmotionSendTreatsOpenDingTalkIDPassedAsUserAsResolvedTarget(t *testing.T) {
// TC-004: --user 收到 openDingTalkId 形态时保持 chat message send 的兼容语义。
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller,
"emotion", "send",
"--media-id", "@media",
"--user", helperCurrentDOpenID,
)
if err != nil {
t.Fatalf("chat emotion send returned error: %v", err)
}
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
"mediaId": "@media",
"receiverOpenDingTalkId": helperCurrentDOpenID,
})
}
func TestChatEmotionSendResolvesUserIDTarget(t *testing.T) {
// TC-004b: --user 收到普通 userId 时先解析为 openDingTalkId,再发送个人收藏表情。
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller,
"emotion", "send",
"--media-id", "@media",
"--user", "u1",
)
if err != nil {
t.Fatalf("chat emotion send returned error: %v", err)
}
if len(caller.calls) != 2 {
t.Fatalf("calls = %d, want contact resolve then send: %+v", len(caller.calls), caller.calls)
}
resolveCall := caller.calls[0]
if resolveCall.server != "contact" || resolveCall.tool != "get_user_info_by_user_ids" {
t.Fatalf("resolve call = %s/%s, want contact/get_user_info_by_user_ids", resolveCall.server, resolveCall.tool)
}
sendCall := caller.calls[1]
if sendCall.server != "im" || sendCall.tool != "send_personal_emotion" {
t.Fatalf("send call = %s/%s, want im/send_personal_emotion", sendCall.server, sendCall.tool)
}
want := map[string]any{
"mediaId": "@media",
"receiverOpenDingTalkId": helperCurrentDOpenID2,
}
if !reflect.DeepEqual(sendCall.args, want) {
t.Fatalf("send args = %#v, want %#v", sendCall.args, want)
}
}
func TestChatEmotionSendRejectsInvalidTargets(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "missing target",
args: []string{"emotion", "send", "--media-id", "@media"},
wantErr: "specify exactly one",
},
{
name: "multiple targets",
args: []string{"emotion", "send", "--media-id", "@media", "--group", "cid", "--open-dingtalk-id", helperCurrentDOpenID},
wantErr: "specify exactly one",
},
{
name: "missing media",
args: []string{"emotion", "send", "--group", "cid"},
wantErr: "--media-id is required",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller, tc.args...)
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("error = %v, want containing %q", err, tc.wantErr)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid command reached MCP: %+v", caller.calls)
}
})
}
}
func TestChatEmotionFavoriteMapsOptionalSourcePair(t *testing.T) {
// TC-005: 收藏来源字段成对出现时透传为 sourceConversationId/sourceMessageId。
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller,
"emotion", "favorite",
"--media-id", "@media",
"--name", "赞",
"--source-conversation-id", "cid123",
"--source-message-id", "msg123",
)
if err != nil {
t.Fatalf("chat emotion favorite returned error: %v", err)
}
requirePersonalEmotionCall(t, caller, "favorite_personal_emotion", map[string]any{
"mediaId": "@media",
"name": "赞",
"sourceConversationId": "cid123",
"sourceMessageId": "msg123",
})
}
func TestChatEmotionFavoriteRejectsMissingRequiredOrUnpairedSource(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "missing media",
args: []string{"emotion", "favorite", "--name", "赞"},
wantErr: "--media-id is required",
},
{
name: "source conversation only",
args: []string{"emotion", "favorite", "--media-id", "@media", "--source-conversation-id", "cid123"},
wantErr: "must be specified together",
},
{
name: "source message only",
args: []string{"emotion", "favorite", "--media-id", "@media", "--source-message-id", "msg123"},
wantErr: "must be specified together",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller, tc.args...)
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("error = %v, want containing %q", err, tc.wantErr)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid command reached MCP: %+v", caller.calls)
}
})
}
}
+20 -2
View File
@@ -17,6 +17,16 @@ import (
// remindType: 服务端 API 1=应用内 2=短信 3=电话
var dingRemindTypeMap = map[string]int{"app": 1, "sms": 2, "call": 3}
var dingPersonalRemindTypeMap = map[string]string{"app": "APP", "sms": "SMS", "call": "PHONE"}
func dingPersonalRemindType(value string) (string, error) {
remindType, ok := dingPersonalRemindTypeMap[strings.ToLower(strings.TrimSpace(value))]
if !ok {
return "", fmt.Errorf("--type must be one of app, sms, call")
}
return remindType, nil
}
func newDingCommand() *cobra.Command {
// Product-level Agent routing Decl (migrated from selection/ding.json
// products.ding). Catalog assembly stamps provenance contract_final.
@@ -232,11 +242,15 @@ func newDingCommand() *cobra.Command {
if err := validateRequiredFlags(cmd, "users", "content"); err != nil {
return err
}
remindType, err := dingPersonalRemindType(mustGetFlag(cmd, "type"))
if err != nil {
return err
}
users := parseCSVValues(mustGetFlag(cmd, "users"))
toolArgs := map[string]any{
"receiverOpenDingTalkIds": users,
"content": mustGetFlag(cmd, "content"),
"remindType": mustGetFlag(cmd, "type"),
"remindType": remindType,
}
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
toolArgs["uuid"] = v
@@ -262,12 +276,16 @@ func newDingCommand() *cobra.Command {
if err := validateRequiredFlags(cmd, "group", "message-id", "users"); err != nil {
return err
}
remindType, err := dingPersonalRemindType(mustGetFlag(cmd, "type"))
if err != nil {
return err
}
users := parseCSVValues(mustGetFlag(cmd, "users"))
toolArgs := map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
"openMessageId": mustGetFlag(cmd, "message-id"),
"receiverOpenDingTalkIds": users,
"remindType": mustGetFlag(cmd, "type"),
"remindType": remindType,
}
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
toolArgs["uuid"] = v
@@ -0,0 +1,18 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package helpers
import "testing"
func TestCrossPlatformCoverageDingPersonalRemindTypeMatchesMCPEnum(t *testing.T) {
for input, want := range map[string]string{"app": "APP", "sms": "SMS", "call": "PHONE", " APP ": "APP"} {
got, err := dingPersonalRemindType(input)
if err != nil || got != want {
t.Errorf("dingPersonalRemindType(%q)=(%q,%v), want %q", input, got, err, want)
}
}
if got, err := dingPersonalRemindType("push"); err == nil || got != "" {
t.Fatalf("unsupported remind type=(%q,%v)", got, err)
}
}
+2 -2
View File
@@ -108,7 +108,7 @@ func driveFolderPushResultSpec() *contract.ResultSpec {
"preview_kind":{"type":"string","description":"预览类型","enum":["plan"]},
"operation":{"type":"string","description":"预览的命令"},
"if_exists":{"type":"string","description":"远端同名文件处理策略"},
"plan":{"type":"object","description":"预览的推送计划","properties":{"summary":{"type":"object","description":"计划动作计数","properties":{"uploaded":{"type":"integer","description":"计划上传或覆盖的文件数"},"skipped":{"type":"integer","description":"计划跳过的文件数"},"failed":{"type":"integer","description":"预检失败的条目数"},"aborted":{"type":"boolean","description":"计划是否会中止"}},"required":["uploaded","skipped","failed","aborted"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"计划动作"},"size_bytes":{"type":"integer","description":"文件字节数"},"error":{"type":"string","description":"预检失败原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["summary","items"],"additionalProperties":false}
"plan":{"type":"object","description":"预览的推送计划","properties":{"summary":{"type":"object","description":"计划动作计数;兼容执行计数 uploaded/skipped 在预览中保持为零","properties":{"uploaded":{"type":"integer","description":"兼容字段;预览未执行上传,固定为零"},"skipped":{"type":"integer","description":"兼容字段;预览未执行跳过,固定为零"},"failed":{"type":"integer","description":"预检失败的条目数"},"aborted":{"type":"boolean","description":"计划是否会中止"},"planned_uploads":{"type":"integer","description":"计划上传或覆盖的文件数"},"planned_skips":{"type":"integer","description":"计划按策略跳过的文件数"},"planned_folders":{"type":"integer","description":"计划创建的远端目录数"}},"required":["uploaded","skipped","failed","aborted"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"未执行的 planned_* 计划动作或已确认的预检失败"},"size_bytes":{"type":"integer","description":"文件字节数"},"error":{"type":"string","description":"预检失败原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["summary","items"],"additionalProperties":false}
},
"additionalProperties":false
}`),
@@ -134,7 +134,7 @@ func driveFolderSyncResultSpec() *contract.ResultSpec {
"executed":{"type":"boolean","description":"是否执行了写操作","const":false},
"preview_kind":{"type":"string","description":"预览类型","enum":["plan"]},
"operation":{"type":"string","description":"预览的命令","const":"drive sync"},
"plan":{"type":"object","description":"预览的同步计划","properties":{"detection":{"type":"string","description":"计划使用的差异检测模式","enum":["exact","quick"]},"diff":{"type":"object","description":"计划执行前的双端差异","properties":{"new_local":{"type":"array","description":"仅本地存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"new_remote":{"type":"array","description":"仅钉盘存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"modified":{"type":"array","description":"双端都存在但内容或时间不同的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unchanged":{"type":"array","description":"双端一致的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unknown":{"type":"array","description":"exact 模式下因缺少可靠远端哈希而无法判定的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}},"required":["new_local","new_remote","modified","unchanged","unknown"],"additionalProperties":false},"summary":{"type":"object","description":"计划动作计数","properties":{"pulled":{"type":"integer","description":"计划拉取的条目数"},"pushed":{"type":"integer","description":"计划推送的条目数"},"skipped":{"type":"integer","description":"计划跳过的条目数"},"failed":{"type":"integer","description":"预检失败的条目数"}},"required":["pulled","pushed","skipped","failed"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"计划动作"},"direction":{"type":"string","description":"同步方向"},"error":{"type":"string","description":"失败或跳过原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["detection","diff","summary","items"],"additionalProperties":false}
"plan":{"type":"object","description":"预览的同步计划","properties":{"detection":{"type":"string","description":"计划使用的差异检测模式","enum":["exact","quick"]},"diff":{"type":"object","description":"计划执行前的双端差异","properties":{"new_local":{"type":"array","description":"仅本地存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"new_remote":{"type":"array","description":"仅钉盘存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"modified":{"type":"array","description":"双端都存在但内容或时间不同的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unchanged":{"type":"array","description":"双端一致的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unknown":{"type":"array","description":"exact 模式下因缺少可靠远端哈希而无法判定的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}},"required":["new_local","new_remote","modified","unchanged","unknown"],"additionalProperties":false},"summary":{"type":"object","description":"计划动作计数;兼容执行计数 pulled/pushed/skipped 在预览中保持为零","properties":{"pulled":{"type":"integer","description":"兼容字段;预览未执行拉取,固定为零"},"pushed":{"type":"integer","description":"兼容字段;预览未执行推送,固定为零"},"skipped":{"type":"integer","description":"兼容字段;预览未执行跳过,固定为零"},"failed":{"type":"integer","description":"预检失败的条目数"},"planned_pulls":{"type":"integer","description":"计划拉取的文件数"},"planned_pushes":{"type":"integer","description":"计划上传或覆盖的文件数"},"planned_skips":{"type":"integer","description":"计划按策略跳过的文件数"},"planned_folders":{"type":"integer","description":"计划创建的远端目录数"}},"required":["pulled","pushed","skipped","failed"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"未执行的 planned_* 计划动作、待决策动作或预检失败"},"direction":{"type":"string","description":"同步方向"},"error":{"type":"string","description":"失败或跳过原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["detection","diff","summary","items"],"additionalProperties":false}
},
"oneOf":[
{"required":["detection","diff","summary","items"]},
@@ -93,10 +93,26 @@ func TestCrossPlatformCoverageDriveFolderContractsPublishResultAndDryRun(t *test
if name == "sync" {
assertDriveSyncResultSchema(t, final.Result.DataSchema)
}
if name == "push" {
assertDrivePushResultSchema(t, final.Result.DataSchema)
}
})
}
}
func assertDrivePushResultSchema(t *testing.T, raw json.RawMessage) {
t.Helper()
properties := resultSchemaProperties(t, raw)
plan := schemaObjectProperty(t, properties, "plan")
summary := schemaObjectProperty(t, schemaProperties(t, plan), "summary")
summaryProperties := schemaProperties(t, summary)
for _, name := range []string{"planned_uploads", "planned_skips", "planned_folders"} {
if _, ok := summaryProperties[name]; !ok {
t.Fatalf("push plan summary schema is missing %s", name)
}
}
}
func resultSchemaProperties(t *testing.T, raw json.RawMessage) map[string]any {
t.Helper()
var schema map[string]any
@@ -133,6 +149,13 @@ func assertDriveSyncResultSchema(t *testing.T, raw json.RawMessage) {
planProperties := schemaProperties(t, plan)
planDiff := schemaObjectProperty(t, planProperties, "diff")
assertSchemaRequired(t, planDiff, "new_local", "new_remote", "modified", "unchanged", "unknown")
planSummary := schemaObjectProperty(t, planProperties, "summary")
planSummaryProperties := schemaProperties(t, planSummary)
for _, name := range []string{"planned_pulls", "planned_pushes", "planned_skips", "planned_folders"} {
if _, ok := planSummaryProperties[name]; !ok {
t.Fatalf("sync plan summary schema is missing %s", name)
}
}
var schema map[string]any
if err := json.Unmarshal(raw, &schema); err != nil {
+26 -14
View File
@@ -39,6 +39,11 @@ const (
pushActionSkipped = "skipped" // 按 --if-exists 跳过
pushActionFolderCreated = "folder_created" // 新建远端目录(不计入 uploaded)
pushActionFailed = "failed"
pushActionPlannedUpload = "planned_upload"
pushActionPlannedOverwrite = "planned_overwrite"
pushActionPlannedSkip = "planned_skip"
pushActionPlannedFolderCreate = "planned_folder_create"
)
// localPushFile 描述一个待推送的本地常规文件。
@@ -79,10 +84,13 @@ type drivePushItem struct {
// drivePushSummary 是各动作的计数汇总。uploaded 同时统计新建与覆盖。
type drivePushSummary struct {
Uploaded int `json:"uploaded"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Aborted bool `json:"aborted"`
Uploaded int `json:"uploaded"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Aborted bool `json:"aborted"`
PlannedUploads int `json:"planned_uploads,omitempty"`
PlannedSkips int `json:"planned_skips,omitempty"`
PlannedFolders int `json:"planned_folders,omitempty"`
}
type drivePushResult struct {
@@ -380,7 +388,8 @@ func printDrivePushDryRunWithPreflight(ifExists string, remoteFiles map[string]*
continue
}
plannedFolders[dir] = "dry-run-planned-folder"
plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionFolderCreated})
plan.Summary.PlannedFolders++
plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionPlannedFolderCreate})
}
for _, lf := range localFiles {
size := lf.Size
@@ -390,25 +399,25 @@ func printDrivePushDryRunWithPreflight(ifExists string, remoteFiles map[string]*
plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: "父目录未能创建"})
continue
}
action := pushActionUploaded
action := pushActionPlannedUpload
if rf, exists := remoteFiles[lf.RelPath]; exists {
switch ifExists {
case ifExistsSkip:
action = pushActionSkipped
action = pushActionPlannedSkip
case ifExistsSmart:
if rf.ModifiedTimeValid && rf.ModifiedTime >= lf.ModTimeMillis {
action = pushActionSkipped
action = pushActionPlannedSkip
} else {
action = pushActionOverwritten
action = pushActionPlannedOverwrite
}
case ifExistsOverwrite:
action = pushActionOverwritten
action = pushActionPlannedOverwrite
}
}
if action == pushActionSkipped {
plan.Summary.Skipped++
if action == pushActionPlannedSkip {
plan.Summary.PlannedSkips++
} else {
plan.Summary.Uploaded++
plan.Summary.PlannedUploads++
}
plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: action, SizeBytes: &size})
}
@@ -811,7 +820,10 @@ func defaultPushPutOpenedFile(ctx context.Context, url string, headers map[strin
if _, err := file.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("failed to seek upload file: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, file)
// net/http owns and closes Request.Body after RoundTrip. The *os.File belongs
// to pushUploadFilePinned, which must stat it after PUT before commit_upload;
// wrap it in a no-op closer so HTTP completion cannot close that shared handle.
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, io.NopCloser(file))
if err != nil {
return fmt.Errorf("failed to create upload request: %w", err)
}
@@ -421,6 +421,29 @@ func TestCrossPlatformCoverageDrivePushFinalDefaultOpenedPUTAndCommit(t *testing
}
}
func TestCrossPlatformCoverageDrivePushOpenedPUTKeepsCallerOwnedHandle(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
_, _ = io.Copy(io.Discard, request.Body)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
path := filepath.Join(t.TempDir(), "payload")
mustWrite(t, path, "payload")
file, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
if err := defaultPushPutOpenedFile(context.Background(), server.URL, nil, file, 7); err != nil {
t.Fatalf("PUT failed: %v", err)
}
if _, err := file.Stat(); err != nil {
t.Fatalf("HTTP PUT closed its caller-owned file handle: %v", err)
}
}
func TestCrossPlatformCoverageDriveSyncFinalPreflightAndKeepBothErrors(t *testing.T) {
t.Run("local target preflight lstat error and directory", func(t *testing.T) {
_, root, _ := openDriveFinalCoverageRoot(t)
+120 -4
View File
@@ -65,6 +65,14 @@ const (
syncActionRenamedLocal = "renamed_local"
syncActionSkipped = "skipped"
syncActionFailed = "failed"
syncActionPlannedDownload = "planned_download"
syncActionPlannedUpload = "planned_upload"
syncActionPlannedOverwrite = "planned_overwrite"
syncActionPlannedFolderCreate = "planned_folder_create"
syncActionPlannedRenameLocal = "planned_rename_local"
syncActionPlannedSkip = "planned_skip"
syncActionDecisionRequired = "decision_required"
)
// driveSyncItem 是输出 items[] 中每条操作的明细。
@@ -77,10 +85,14 @@ type driveSyncItem struct {
// driveSyncSummary 是各方向的计数汇总。
type driveSyncSummary struct {
Pulled int `json:"pulled"`
Pushed int `json:"pushed"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Pulled int `json:"pulled"`
Pushed int `json:"pushed"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
PlannedPulls int `json:"planned_pulls,omitempty"`
PlannedPushes int `json:"planned_pushes,omitempty"`
PlannedSkips int `json:"planned_skips,omitempty"`
PlannedFolders int `json:"planned_folders,omitempty"`
}
// driveSyncDiff 是本次同步前算出的五类差异(与 status 同源)。
@@ -267,6 +279,12 @@ func runDriveSync(cmd *cobra.Command, _ []string) error {
// --dry-run:只算差异、不执行任何同步动作。
if deps.Caller.DryRun() {
if len(preflight) == 0 && res.Summary.Failed == 0 {
appendDriveSyncDryRunPlan(&res, driveSyncDryRunPlanInput{
LocalDirs: localDirs, LocalByRel: localByRel, RemoteFiles: remoteFiles, RemoteFolders: remoteFolders,
NewLocal: newLocal, NewRemote: newRemote, Modified: modified, Unknown: unknown, OnConflict: onConflict,
})
}
return deps.Out.PrintJSON(driveSyncDryRunResult{
DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive sync", Plan: res,
})
@@ -394,6 +412,104 @@ func runDriveSync(cmd *cobra.Command, _ []string) error {
return nil
}
type driveSyncDryRunPlanInput struct {
LocalDirs []string
LocalByRel map[string]localPushFile
RemoteFiles map[string]*remoteFile
RemoteFolders map[string]string
NewLocal []string
NewRemote []string
Modified []string
Unknown []string
OnConflict string
}
func appendDriveSyncDryRunPlan(res *driveSyncResult, input driveSyncDryRunPlanInput) {
for _, rel := range input.Unknown {
res.Summary.PlannedSkips++
res.Items = append(res.Items, driveSyncItem{
RelPath: rel, Action: syncActionPlannedSkip, Direction: syncDirectionConflict,
Error: "远端无可靠 md5,内容无法核对,执行时会跳过(可改用 --quick 按 modified_time 比对)",
})
}
plannedFolders := make(map[string]string, len(input.RemoteFolders)+len(input.LocalDirs))
for rel, fileID := range input.RemoteFolders {
plannedFolders[rel] = fileID
}
for _, dir := range input.LocalDirs {
if _, ok := plannedFolders[dir]; ok {
continue
}
parentRel, _ := splitRel(dir)
if plannedFolders[parentRel] == "" {
res.Summary.Failed++
res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"})
continue
}
plannedFolders[dir] = "dry-run-planned-folder"
res.Summary.PlannedFolders++
res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionPlannedFolderCreate, Direction: syncDirectionPush})
}
for _, rel := range input.NewRemote {
res.Summary.PlannedPulls++
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull})
}
for _, rel := range input.NewLocal {
parentRel, _ := splitRel(rel)
if plannedFolders[parentRel] == "" {
res.Summary.Failed++
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"})
continue
}
res.Summary.PlannedPushes++
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedUpload, Direction: syncDirectionPush})
}
occupied := make(map[string]bool, len(input.LocalByRel)+len(input.LocalDirs)+len(input.RemoteFiles)+len(input.RemoteFolders))
for rel := range input.LocalByRel {
occupied[rel] = true
}
for _, rel := range input.LocalDirs {
occupied[rel] = true
}
for rel := range input.RemoteFiles {
occupied[rel] = true
}
for rel := range input.RemoteFolders {
if rel != "" {
occupied[rel] = true
}
}
for _, rel := range input.Modified {
switch input.OnConflict {
case syncConflictRemoteWins:
res.Summary.PlannedPulls++
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull})
case syncConflictLocalWins:
res.Summary.PlannedPushes++
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedOverwrite, Direction: syncDirectionConflict})
case syncConflictKeepBoth:
candidate := driveSyncSuffixedRel(rel, input.RemoteFiles[rel].FileID, occupied)
occupied[candidate] = true
res.Summary.PlannedPulls++
res.Items = append(res.Items,
driveSyncItem{RelPath: candidate, Action: syncActionPlannedRenameLocal, Direction: syncDirectionConflict},
driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull},
)
case syncConflictAsk:
res.Items = append(res.Items, driveSyncItem{
RelPath: rel, Action: syncActionDecisionRequired, Direction: syncDirectionConflict,
Error: "执行时需要交互选择 remote-wins、local-wins、keep-both 或 skip",
})
default:
res.Summary.PlannedSkips++
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedSkip, Direction: syncDirectionConflict})
}
}
}
// syncPathBlockedByTypeConflict 判断 rel 本身或任一祖先是否是文件/目录类型冲突根。
// 祖先检查保证冲突目录下的本地、远端后代不会绕过根路径的 fail-closed 结论。
func syncPathBlockedByTypeConflict(rel string, conflicts map[string]string) bool {
@@ -3,6 +3,7 @@ package helpers
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
@@ -189,6 +190,116 @@ func TestCrossPlatformCoverageDrivePush_dryRunPlansWithoutRemoteWrites(t *testin
}
}
func TestCrossPlatformCoverageDrivePushDryRunPublishesPlannedNotExecutedActions(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "local.txt"), "local")
caller := syncCaller(nil)
caller.dryRun = true
var out bytes.Buffer
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
testseam.Swap(t, &os.Args, []string{"dws", "drive", "push"})
cmd := findDriveSubcommand(t, "push")
mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"})
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("dry-run push: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("decode dry-run push: %v\n%s", err, out.String())
}
plan := payload["plan"].(map[string]any)
summary := plan["summary"].(map[string]any)
if summary["uploaded"] != float64(0) || summary["planned_uploads"] != float64(1) {
t.Fatalf("dry-run summary reports execution instead of a plan: %#v", summary)
}
items := plan["items"].([]any)
if got := items[0].(map[string]any)["action"]; got != "planned_upload" {
t.Fatalf("dry-run action = %v, want planned_upload", got)
}
}
func TestCrossPlatformCoverageDriveSyncDryRunReturnsRealActionPlan(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "local.txt"), "local")
mustWrite(t, filepath.Join(dir, "shared.txt"), "shared")
if err := os.Mkdir(filepath.Join(dir, "empty-folder"), 0o755); err != nil {
t.Fatal(err)
}
caller := syncCaller(map[string]string{
"ROOT": `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2},{"name":"shared.txt","type":"file","fileId":"S","modifyTime":2}],"nextToken":""}}`,
})
caller.dryRun = true
var out bytes.Buffer
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
testseam.Swap(t, &os.Args, []string{"dws", "drive", "sync"})
cmd := findDriveSubcommand(t, "sync")
mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"})
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("dry-run sync: %v", err)
}
var payload driveSyncDryRunResult
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("decode dry-run sync: %v\n%s", err, out.String())
}
if payload.Executed || payload.Plan.Summary.Pulled != 0 || payload.Plan.Summary.Pushed != 0 || payload.Plan.Summary.Skipped != 0 {
t.Fatalf("dry-run plan summary = %+v, executed=%v", payload.Plan.Summary, payload.Executed)
}
if payload.Plan.Summary.PlannedPulls != 1 || payload.Plan.Summary.PlannedPushes != 1 ||
payload.Plan.Summary.PlannedSkips != 1 || payload.Plan.Summary.PlannedFolders != 1 {
t.Fatalf("dry-run planned summary = %+v", payload.Plan.Summary)
}
actions := map[string]int{}
for _, item := range payload.Plan.Items {
actions[item.Action]++
}
if len(payload.Plan.Items) != 4 || actions["planned_download"] != 1 || actions["planned_upload"] != 1 ||
actions["planned_skip"] != 1 || actions["planned_folder_create"] != 1 {
t.Fatalf("dry-run action plan = %#v", payload.Plan.Items)
}
for _, tool := range []string{"download_file", "get_upload_info", "commit_upload", "create_folder"} {
if calls := caller.callsFor(tool); len(calls) != 0 {
t.Fatalf("dry-run called %s: %#v", tool, calls)
}
}
}
func TestCrossPlatformCoverageDriveSyncDryRunPlanBranchMatrix(t *testing.T) {
remoteFiles := map[string]*remoteFile{
"conflict.txt": {FileID: "remote-file"},
}
remoteFolders := map[string]string{"": "root", "existing": "existing-id"}
localDirs := []string{"existing", "new", "missing/child"}
localFiles := map[string]localPushFile{"conflict.txt": {}, "new/file.txt": {}}
for _, policy := range []string{syncConflictRemoteWins, syncConflictLocalWins, syncConflictKeepBoth, syncConflictAsk, syncConflictSkip} {
t.Run(policy, func(t *testing.T) {
res := &driveSyncResult{}
appendDriveSyncDryRunPlan(res, driveSyncDryRunPlanInput{
LocalDirs: localDirs, LocalByRel: localFiles, RemoteFiles: remoteFiles, RemoteFolders: remoteFolders,
NewLocal: []string{"new/file.txt", "missing/file.txt"}, NewRemote: []string{"remote.txt"},
Modified: []string{"conflict.txt"}, Unknown: []string{"unknown.txt"}, OnConflict: policy,
})
if res.Summary.PlannedSkips == 0 || res.Summary.PlannedFolders == 0 || res.Summary.PlannedPulls == 0 || res.Summary.Failed != 2 {
t.Fatalf("dry-run plan summary for %s = %#v; items=%#v", policy, res.Summary, res.Items)
}
switch policy {
case syncConflictRemoteWins, syncConflictKeepBoth:
if res.Summary.PlannedPulls < 2 {
t.Fatalf("%s planned pulls = %d", policy, res.Summary.PlannedPulls)
}
case syncConflictLocalWins:
if res.Summary.PlannedPushes < 2 {
t.Fatalf("local wins planned pushes = %d", res.Summary.PlannedPushes)
}
}
})
}
}
func TestCrossPlatformCoverageDrivePull_dryRunPlanBranches(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "skip.txt"), "skip")
@@ -242,7 +353,7 @@ func TestCrossPlatformCoverageDrivePush_dryRunPlanBranches(t *testing.T) {
[]string{"existing", "missing/child"}, files("orphan/a.txt", "skip.txt", "new.txt")); err != nil {
t.Fatalf("skip plan: %v", err)
}
if text := out.String(); !strings.Contains(text, pushActionFailed) || !strings.Contains(text, pushActionSkipped) || !strings.Contains(text, pushActionUploaded) {
if text := out.String(); !strings.Contains(text, pushActionFailed) || !strings.Contains(text, pushActionPlannedSkip) || !strings.Contains(text, pushActionPlannedUpload) {
t.Fatalf("skip plan did not cover failed/skipped/uploaded: %s", text)
}
out.Reset()
@@ -250,14 +361,14 @@ func TestCrossPlatformCoverageDrivePush_dryRunPlanBranches(t *testing.T) {
files("smart-skip.txt", "smart-overwrite.txt")); err != nil {
t.Fatalf("smart plan: %v", err)
}
if text := out.String(); !strings.Contains(text, pushActionSkipped) || !strings.Contains(text, pushActionOverwritten) {
if text := out.String(); !strings.Contains(text, pushActionPlannedSkip) || !strings.Contains(text, pushActionPlannedOverwrite) {
t.Fatalf("smart plan did not cover skip/overwrite: %s", text)
}
out.Reset()
if err := printDrivePushDryRun(ifExistsOverwrite, remoteFiles, remoteFolders, nil, files("overwrite.txt")); err != nil {
t.Fatalf("overwrite plan: %v", err)
}
if !strings.Contains(out.String(), pushActionOverwritten) {
if !strings.Contains(out.String(), pushActionPlannedOverwrite) {
t.Fatalf("overwrite plan must overwrite existing file: %s", out.String())
}
}
+6 -1
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -557,13 +558,17 @@ func newWikiCommand() *cobra.Command {
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
role := normalizePermissionRole(mustGetFlag(cmd, "role"))
if role == "OWNER" {
return apperrors.NewValidation("OWNER 角色不可通过 wiki member add 添加")
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
return callMCPTool("add_member", map[string]any{
"workspaceId": workspaceID,
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
"roleId": role,
"userIds": userIds,
})
},
+25 -2
View File
@@ -1,11 +1,13 @@
package helpers
import (
stderrors "errors"
"io"
"os"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -106,10 +108,17 @@ func TestCrossPlatformCoverageProxySubCommandCoverage(t *testing.T) {
}
func executeWikiEdge(t *testing.T, args ...string) error {
t.Helper()
_, err := executeWikiEdgeWithCaller(t, args...)
return err
}
func executeWikiEdgeWithCaller(t *testing.T, args ...string) (*scriptedToolCaller, error) {
t.Helper()
oldDeps := deps
oldArgs := os.Args
InitDeps(&scriptedToolCaller{})
caller := &scriptedToolCaller{}
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
t.Cleanup(func() {
@@ -125,7 +134,7 @@ func executeWikiEdge(t *testing.T, args ...string) error {
root.SetIn(os.Stdin)
root.SetArgs(args)
os.Args = append([]string{"dws", "wiki"}, args...)
return root.Execute()
return caller, root.Execute()
}
func TestCrossPlatformCoverageWikiRoutingAndValidationEdges(t *testing.T) {
@@ -154,6 +163,20 @@ func TestCrossPlatformCoverageWikiRoutingAndValidationEdges(t *testing.T) {
}
}
func TestCrossPlatformCoverageWikiMemberAddRejectsOwner(t *testing.T) {
caller, err := executeWikiEdgeWithCaller(t, "member", "add", "--workspace", "space", "--users", "u1", "--role", "OWNER")
if err == nil || !strings.Contains(strings.ToUpper(err.Error()), "OWNER") {
t.Fatalf("member add OWNER error = %v, want local OWNER rejection", err)
}
var appErr *apperrors.Error
if !stderrors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
t.Fatalf("member add OWNER error = %#v, want validation category", err)
}
if caller.calls != 0 {
t.Fatalf("member add OWNER made %d remote calls, want 0", caller.calls)
}
}
func TestCrossPlatformCoverageWikiDeleteCancellationEdges(t *testing.T) {
oldStdin := os.Stdin
t.Cleanup(func() { os.Stdin = oldStdin })
@@ -479,8 +479,8 @@ func evaluateCommandMigrationLifecycle(
}
}
if allAfter {
if exists {
return nil, fmt.Errorf("consumed command migration %s is stale after all references reached the after state", approved.displayKey())
if exists && proposed.State != CommandMigrationConsumed {
return nil, fmt.Errorf("candidate changed consumed command migration %s back to pending", approved.displayKey())
}
continue
}
@@ -383,9 +383,9 @@ func TestCrossPlatformCoverageCommandMigrationLifecycleEdges(t *testing.T) {
{"after pending receipt", after, map[string]Snapshot{"main": before, "stable": before}, pending, pending, "without marking it consumed"},
{"partial", partial, map[string]Snapshot{"main": before, "stable": before}, pending, consumed, "partially applied"},
{"consumed drift", before, map[string]Snapshot{"main": after, "stable": before}, consumed, consumed, "drifted from consumed"},
{"stale receipt", after, map[string]Snapshot{"main": after, "stable": after}, consumed, consumed, "stale after all references"},
{"early cleanup", after, map[string]Snapshot{"main": after, "stable": before}, consumed, empty, "must retain consumed"},
{"consumed back to pending", after, map[string]Snapshot{"main": after, "stable": before}, consumed, pending, "must retain consumed"},
{"inert consumed back to pending", after, map[string]Snapshot{"main": after, "stable": after}, consumed, pending, "back to pending"},
{"candidate added consumed", after, map[string]Snapshot{"main": before, "stable": before}, empty, consumed, "must start pending"},
{"candidate base mismatch", before, map[string]Snapshot{"main": after, "stable": before}, empty, pending, "does not match"},
} {
@@ -402,6 +402,9 @@ func TestCrossPlatformCoverageCommandMigrationLifecycleEdges(t *testing.T) {
if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": after}, consumed, empty); err != nil || len(got) != 0 {
t.Fatalf("cleaned stale receipt=%#v, %v", got, err)
}
if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": after}, consumed, consumed); err != nil || len(got) != 0 {
t.Fatalf("retained inert receipt=%#v, %v", got, err)
}
if got, err := AuthorizeCommandMigrations(before, map[string]Snapshot{"main": before, "stable": before}, empty, pending); err != nil || len(got) != 0 {
t.Fatalf("candidate pending plan=%#v, %v", got, err)
}
+8 -4
View File
@@ -889,7 +889,7 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRetainsConsumedReceipt
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresCleanupAfterAllReferencesCatchUp(t *testing.T) {
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsAllowsRetentionOrCleanupAfterAllReferencesCatchUp(t *testing.T) {
after := testFlagMigrationSnapshot(true, true)
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
references := map[string]Snapshot{
@@ -897,11 +897,15 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresCleanupAfterAl
"stable": after,
}
if report, err := CompareAllWithFlagMigrations(after, references, consumed, consumed); err == nil {
t.Fatalf("stale consumed receipt was accepted after every reference caught up: %#v", report)
report, err := CompareAllWithFlagMigrations(after, references, consumed, consumed)
if err != nil {
t.Fatalf("inert consumed receipt was rejected after every reference caught up: %v", err)
}
if !report.Compatible {
t.Fatalf("inert consumed receipt changed compatibility: %#v", report.Comparisons)
}
report, err := CompareAllWithFlagMigrations(after, references, consumed, testEmptyFlagMigrationManifest())
report, err = CompareAllWithFlagMigrations(after, references, consumed, testEmptyFlagMigrationManifest())
if err != nil {
t.Fatalf("cleanup of stale consumed receipt was rejected: %v", err)
}
+2 -2
View File
@@ -579,8 +579,8 @@ func evaluateFlagMigrationLifecycle(
}
}
if allReferencesAfter {
if exists {
return nil, fmt.Errorf("consumed flag migration %s is stale after all references reached the after state", approved.displayKey())
if exists && proposed.State != FlagMigrationConsumed {
return nil, fmt.Errorf("candidate changed consumed flag migration %s back to pending", approved.displayKey())
}
continue
}
@@ -602,12 +602,12 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsLifecycleErrors(t *tes
wantErr: "back to pending",
},
{
name: "consumed receipt becomes stale",
name: "inert consumed receipt cannot revert to pending",
current: after,
references: map[string]Snapshot{"merge-base": after, "stable": after},
authority: consumed,
candidate: consumed,
wantErr: "is stale after all references reached the after state",
candidate: pending,
wantErr: "back to pending",
},
{
name: "candidate-added receipt starts pending",
@@ -857,15 +857,15 @@ func TestCrossPlatformCoverageOptionalFlagMigrationLifecycleRemainsHostile(t *te
}
})
t.Run("consumed receipt remains stale after every reference converges", func(t *testing.T) {
_, err := CompareAllWithFlagMigrations(
t.Run("consumed receipt becomes inert after every reference converges", func(t *testing.T) {
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": after, "stable": after},
consumed,
consumed,
)
if err == nil || !strings.Contains(err.Error(), "stale after all references reached the after state") {
t.Fatalf("stale optional migration error = %v", err)
if err != nil || !report.Compatible {
t.Fatalf("inert optional migration = (%#v, %v), want compatible", report, err)
}
})
}
+5 -13
View File
@@ -403,11 +403,7 @@ var BaseCopy = shortcut.Shortcut{
},
Tips: []string{`dws aitable +base-copy --base-id BASE_ID --target-folder-id FOLDER_ID`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("copy_base", map[string]any{
"baseId": rt.Str("base-id"),
"targetFolderId": rt.Str("target-folder-id"),
"onlyCopyMeta": rt.Bool("only-struct"),
})
return executeBaseCopy(rt)
},
}
@@ -660,7 +656,7 @@ var RecordQuery = shortcut.Shortcut{
Command: "+record-query",
Product: serverMain,
Description: "查询表格记录(按 ID / 条件 / 关键词,并支持字段投影和分页)",
Intent: "读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗。",
Intent: "读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗;服务端未返回的计算字段不会由 CLI 本地补算。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -682,7 +678,7 @@ var RecordQuery = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "查询表格记录(按 ID / 条件 / 关键词,并支持字段投影和分页)",
UseWhen: []string{"读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗。"},
UseWhen: []string{"读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗;服务端未返回的计算字段不会由 CLI 本地补算。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{
"dws aitable +record-query --base-id B --table-id T --query \"关键词\" --limit 50",
@@ -975,11 +971,7 @@ var RecordPrimaryDocGet = shortcut.Shortcut{
},
Tips: []string{`dws aitable +record-primary-doc-get --base-id B --table-id T --record-id R`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("get_primary_doc", map[string]any{
"baseId": rt.Str("base-id"),
"tableId": rt.Str("table-id"),
"recordId": rt.Str("record-id"),
})
return executeRecordPrimaryDocGet(rt)
},
}
@@ -2078,7 +2070,7 @@ func workflowListProject(data map[string]any) ([]map[string]any, error) {
return nil, fmt.Errorf("list_workflows response item %d must be an object, got %T", index, item)
}
row := map[string]any{}
if v, ok := workflowListFirst(m, "workflowId", "workflow_id", "id"); ok {
if v, ok := workflowListFirst(m, "workflowId", "flowId", "workflow_id", "id"); ok {
row["workflowId"] = v
}
if v, ok := workflowListFirst(m, "name", "workflowName", "title"); ok {
@@ -79,6 +79,154 @@ func TestCrossPlatformCoverageBaseSchemaSnapshotE2E(t *testing.T) {
})
}
func TestCrossPlatformCoverageBaseCopyRequiresNewIDAndExactReadBackE2E(t *testing.T) {
t.Run("verified copy", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"data":{"fileId":"folder","type":"FOLDER"}}`},
{text: `{"data":{"newBaseId":"new-base"}}`},
{text: `{"data":{"baseId":"new-base","tables":[]}}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
if err != nil || !strings.Contains(out, `"newBaseId": "new-base"`) || len(caller.calls) != 3 || caller.calls[0].product != "drive" || caller.calls[0].tool != "get_file_info" || caller.calls[2].tool != "get_base" {
t.Fatalf("base copy = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("missing new ID is unknown without read-back", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"data":{"fileId":"folder","type":"folder"}}`},
{text: `{"success":true}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
var typed *apperrors.Error
if err == nil || out != "" || len(caller.calls) != 2 || !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" || typed.Retryable {
t.Fatalf("missing newBaseId = output:%q err:%#v calls:%#v", out, err, caller.calls)
}
})
t.Run("explicit downstream target rejection is stable and not retryable", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"data":{"fileId":"folder","type":"folder"}}`},
{err: errors.New("Invalid target folder ID")},
}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
var typed *apperrors.Error
if err == nil || out != "" || len(caller.calls) != 2 || !errors.As(err, &typed) || typed.Reason != "target_not_supported" || typed.Retryable {
t.Fatalf("rejected target = output:%q err:%#v calls:%#v", out, err, caller.calls)
}
})
t.Run("rejects a non-folder target before write", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"data":{"fileId":"file-1","type":"FILE"}}`}}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "file-1", "--yes")
var typed *apperrors.Error
if err == nil || out != "" || len(caller.calls) != 1 || !errors.As(err, &typed) || typed.Reason != "target_wrong_type" || typed.Retryable || typed.ExecutionStarted == nil || *typed.ExecutionStarted {
t.Fatalf("wrong target type = output:%q err:%#v calls:%#v", out, err, caller.calls)
}
})
t.Run("rejects target metadata without a supported type", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"data":{"fileId":"folder"}}`}}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
var typed *apperrors.Error
if err == nil || out != "" || len(caller.calls) != 1 || !errors.As(err, &typed) || typed.Reason != "target_not_supported" || typed.Retryable {
t.Fatalf("unsupported target metadata = output:%q err:%#v calls:%#v", out, err, caller.calls)
}
})
t.Run("rejects URL target before transport", func(t *testing.T) {
caller := &upsertByKeyCaller{}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "https://example.test/folder", "--yes")
if err == nil || out != "" || len(caller.calls) != 0 {
t.Fatalf("URL target = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("rejects pure numeric dentryId before transport", func(t *testing.T) {
caller := &upsertByKeyCaller{}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "123456789", "--yes")
if err == nil || out != "" || len(caller.calls) != 0 {
t.Fatalf("numeric target = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("nodeId-only metadata cannot prove dentryUuid", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"data":{"nodeId":"folder","type":"FOLDER"}}`}}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
var typed *apperrors.Error
if err == nil || out != "" || len(caller.calls) != 1 || !errors.As(err, &typed) || typed.Reason != "target_not_supported" || typed.Retryable {
t.Fatalf("nodeId-only metadata = output:%q err:%#v calls:%#v", out, err, caller.calls)
}
})
}
func TestCrossPlatformCoverageBaseCopyFailureAndDryRunEdgesE2E(t *testing.T) {
t.Run("invalid source", func(t *testing.T) {
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+base-copy", "--base-id", "bad/id", "--target-folder-id", "folder", "--yes")
if err == nil || out != "" {
t.Fatalf("invalid source = output:%q err:%v", out, err)
}
})
t.Run("dry run", func(t *testing.T) {
caller := &upsertByKeyCaller{dryRun: true}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--dry-run")
if err != nil || len(caller.calls) != 0 || !strings.Contains(out, `"status": "planned"`) {
t.Fatalf("dry run = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("target lookup error", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{err: context.Canceled}}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || out != "" || len(caller.calls) != 1 {
t.Fatalf("target error = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("generic copy error is unknown", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"data":{"fileId":"folder","type":"folder"}}`},
{err: errors.New("transport failed")},
}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
if err == nil || out != "" || len(caller.calls) != 2 {
t.Fatalf("copy error = output:%q err:%v", out, err)
}
})
readBackCases := []struct {
name string
step upsertByKeyStep
}{
{name: "transport", step: upsertByKeyStep{err: context.DeadlineExceeded}},
{name: "missing id", step: upsertByKeyStep{text: `{}`}},
{name: "wrong id", step: upsertByKeyStep{text: `{"baseId":"other"}`}},
}
for _, tc := range readBackCases {
t.Run("readback "+tc.name, func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"data":{"fileId":"folder","type":"folder"}}`},
{text: `{"newBaseId":"new-base"}`},
tc.step,
}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
if err == nil || out != "" || len(caller.calls) != 3 {
t.Fatalf("readback %s = output:%q err:%v", tc.name, out, err)
}
})
}
for _, value := range []string{"", "white space", "control\x00"} {
if validCompositeOpaqueID(value) {
t.Errorf("validCompositeOpaqueID(%q) = true", value)
}
}
if pureNumericID("") || pureNumericID("123a") || !pureNumericID("123") {
t.Fatal("pureNumericID edge mismatch")
}
}
func bootstrapFields(count int) []any {
fields := make([]any, 0, count)
for index := 0; index < count; index++ {
+136
View File
@@ -0,0 +1,136 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"fmt"
"strings"
"unicode"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func executeBaseCopy(rt *shortcut.RuntimeContext) error {
baseID := strings.TrimSpace(rt.Str("base-id"))
targetFolderID := strings.TrimSpace(rt.Str("target-folder-id"))
if !validCompositeOpaqueID(baseID) {
return apperrors.NewValidation("--base-id 不是合法的 Base ID")
}
if !validCompositeOpaqueID(targetFolderID) || pureNumericID(targetFolderID) {
return apperrors.NewValidation("--target-folder-id 必须是文件夹 dentryUuid,不能是 URL、路径或空值")
}
params := map[string]any{
"baseId": baseID,
"targetFolderId": targetFolderID,
"onlyCopyMeta": rt.Bool("only-struct"),
}
result := newCompositeResult("base_copy")
result.Resolved = map[string]any{"sourceBaseId": baseID, "targetFolderId": targetFolderID}
result.Plan = []compositeStep{
{Index: 1, Name: "verify target folder", Tool: "drive/get_file_info", Status: "planned", Arguments: map[string]any{"fileId": targetFolderID}},
{Index: 2, Name: "copy base", Tool: "copy_base", Status: "planned", Arguments: params},
}
if rt.DryRun() {
result.Status = "planned"
result.Executed = false
return rt.Output(result)
}
targetData, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": targetFolderID})
if err != nil {
return err
}
actualTargetID := findStringByKeys(targetData, "fileId", "dentryUuid")
if actualTargetID == "" || actualTargetID != targetFolderID {
return apperrors.NewAPI("drive/get_file_info did not prove the exact target dentryUuid",
apperrors.WithOperation("drive/get_file_info"), apperrors.WithReason("target_not_supported"),
apperrors.WithFailureStage("target_resolution"), apperrors.WithExecutionStarted(false), apperrors.WithRetryable(false))
}
targetType := findStringByKeys(targetData, "type", "dentryType", "fileType")
if targetType == "" {
return apperrors.NewAPI("drive/get_file_info response is missing the target node type",
apperrors.WithOperation("drive/get_file_info"), apperrors.WithReason("target_not_supported"),
apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false), apperrors.WithRetryable(false))
}
if !strings.EqualFold(targetType, "folder") {
return apperrors.NewAPI(fmt.Sprintf("target %s is %s, not a folder", targetFolderID, targetType),
apperrors.WithOperation("drive/get_file_info"), apperrors.WithReason("target_wrong_type"),
apperrors.WithFailureStage("target_resolution"), apperrors.WithExecutionStarted(false), apperrors.WithRetryable(false))
}
result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: 1, Name: "verify target folder", Tool: "drive/get_file_info", Status: "completed", Result: map[string]any{"fileId": actualTargetID, "type": targetType}})
writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, "copy_base", params)
newBaseID := findStringByKeys(writeData, "newBaseId")
if newBaseID == "" && copyBaseRejectedTarget(writeErr) {
return apperrors.NewAPI("copy_base rejected a target that Drive proved is the exact folder dentryUuid",
apperrors.WithOperation("aitable/copy_base"), apperrors.WithOrigin("mcp"),
apperrors.WithReason("target_not_supported"), apperrors.WithFailureStage("copy_base"),
apperrors.WithExecutionStarted(true), apperrors.WithRetryable(false), apperrors.WithCause(writeErr),
apperrors.WithHint("the current copy_base service does not accept this Drive folder contract; retrying or substituting spaceId/nodeId/dentryId is unsafe"))
}
if newBaseID == "" || !validCompositeOpaqueID(newBaseID) {
cause := fmt.Errorf("copy_base response is missing a valid newBaseId")
if writeErr != nil {
cause = fmt.Errorf("copy_base response error: %w; newBaseId is unavailable", writeErr)
}
result.Status = "unknown"
return compositeError(result, cause, false)
}
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "copy_base", "newBaseId": newBaseID})
readBack, verifyErr := rt.CallMCPData(serverMain, "get_base", map[string]any{"baseId": newBaseID})
if verifyErr == nil {
actualID := findStringByKeys(readBack, "baseId")
if actualID == "" {
verifyErr = fmt.Errorf("get_base read-back is missing baseId")
} else if actualID != newBaseID {
verifyErr = fmt.Errorf("get_base read-back identity mismatch: got %q, want %q", actualID, newBaseID)
}
}
if verifyErr != nil {
result.Status = "partial_success"
result.Verification = map[string]any{"status": "failed", "newBaseId": newBaseID, "error": verifyErr.Error()}
return compositeError(result, verifyErr, false)
}
result.CompletedCount = 2
result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: 2, Name: "copy base", Tool: "copy_base", Status: "completed", Result: map[string]any{"newBaseId": newBaseID}})
result.Verification = map[string]any{"status": "verified", "newBaseId": newBaseID}
result.Result = map[string]any{"newBaseId": newBaseID, "base": readBack}
return rt.Output(result)
}
func copyBaseRejectedTarget(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "invalid target folder") ||
strings.Contains(message, "invalid targetfolder")
}
func validCompositeOpaqueID(value string) bool {
if value == "" || len(value) > 512 || strings.ContainsAny(value, "/?#") {
return false
}
for _, r := range value {
if unicode.IsSpace(r) || unicode.IsControl(r) {
return false
}
}
return true
}
func pureNumericID(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return true
}
+142
View File
@@ -0,0 +1,142 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"fmt"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func executeRecordPrimaryDocGet(rt *shortcut.RuntimeContext) error {
baseID, tableID, requestedRecordID := rt.Str("base-id"), rt.Str("table-id"), rt.Str("record-id")
fieldsData, err := rt.CallMCPData(serverMain, "get_fields", map[string]any{"baseId": baseID, "tableId": tableID})
if err != nil {
return err
}
fields, found := findNamedObjectList(fieldsData, "fields", "fieldList")
if !found {
return apperrors.NewAPI("get_fields response is missing the fields collection",
apperrors.WithOperation("aitable/get_fields"), apperrors.WithReason("target_invalid_response"),
apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false))
}
primaryFieldIDs := make([]string, 0, 1)
for index, field := range fields {
fieldType := stringValue(field, "type", "fieldType")
if !strings.EqualFold(fieldType, "primaryDoc") {
continue
}
fieldID := stringValue(field, "fieldId", "id")
if fieldID == "" {
return apperrors.NewAPI(fmt.Sprintf("primaryDoc field %d is missing fieldId", index),
apperrors.WithOperation("aitable/get_fields"), apperrors.WithReason("target_invalid_response"),
apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false))
}
primaryFieldIDs = append(primaryFieldIDs, fieldID)
}
recordParams := map[string]any{
"baseId": baseID, "tableId": tableID, "recordIds": []string{requestedRecordID},
}
if len(primaryFieldIDs) > 0 {
recordParams["fieldIds"] = primaryFieldIDs
}
window, err := queryRecordWindow(rt, recordParams, 1)
if err != nil {
return err
}
records := window.Records
if len(records) == 0 {
return apperrors.NewAPI(fmt.Sprintf("record %s was not found", requestedRecordID),
apperrors.WithOperation("aitable/query_records"), apperrors.WithReason("RESOURCE_NOT_FOUND"),
apperrors.WithFailureStage("target_resolution"), apperrors.WithExecutionStarted(false))
}
if len(records) != 1 || recordID(records[0]) != requestedRecordID {
return apperrors.NewAPI("record preflight did not return the exact requested record",
apperrors.WithOperation("aitable/query_records"), apperrors.WithReason("target_invalid_response"),
apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false))
}
if len(primaryFieldIDs) == 0 {
return rt.Output(map[string]any{
"status": "no_primary_doc_field", "exists": false, "recordId": requestedRecordID, "nodeId": nil,
})
}
data, err := rt.CallMCPData(serverHelper, "get_primary_doc", map[string]any{
"baseId": baseID, "tableId": tableID, "recordId": requestedRecordID,
})
if err != nil {
if knownPrimaryDocUnassociatedError(err) {
return rt.Output(map[string]any{
"status": "unassociated", "exists": false, "recordId": requestedRecordID, "fieldIds": primaryFieldIDs, "nodeId": nil,
})
}
return err
}
nodeID := primaryDocNodeID(data)
if nodeID == "" {
if knownPrimaryDocUnassociatedData(data) {
return rt.Output(map[string]any{
"status": "unassociated", "exists": false, "recordId": requestedRecordID, "fieldIds": primaryFieldIDs, "nodeId": nil,
})
}
return apperrors.NewAPI("get_primary_doc response is missing nodeId",
apperrors.WithOperation("aitable-helper/get_primary_doc"), apperrors.WithReason("target_invalid_response"),
apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false))
}
return rt.Output(map[string]any{
"status": "associated", "exists": true, "recordId": requestedRecordID, "fieldIds": primaryFieldIDs, "nodeId": nodeID,
})
}
func knownPrimaryDocUnassociatedError(err error) bool {
message := strings.ToLower(strings.TrimSpace(err.Error()))
return strings.Contains(message, "no record") || strings.Contains(message, "unassociated")
}
func primaryDocNodeID(value any) string {
object, ok := value.(map[string]any)
if !ok {
return ""
}
for _, key := range []string{"nodeId", "dentryUuid"} {
if nodeID, ok := object[key].(string); ok && strings.TrimSpace(nodeID) != "" {
return strings.TrimSpace(nodeID)
}
}
for _, envelopeKey := range []string{"data", "result", "response"} {
if nodeID := primaryDocNodeID(object[envelopeKey]); nodeID != "" {
return nodeID
}
}
return ""
}
func knownPrimaryDocUnassociatedData(value any) bool {
object, ok := value.(map[string]any)
if !ok {
return false
}
if exists, ok := object["exists"].(bool); ok && !exists {
return true
}
for _, key := range []string{"nodeId", "dentryUuid"} {
if nodeID, exists := object[key]; exists && (nodeID == nil || strings.TrimSpace(fmt.Sprint(nodeID)) == "") {
return true
}
}
if status, ok := object["status"].(string); ok {
status = strings.ToLower(strings.TrimSpace(status))
if status == "unassociated" || status == "no_record" {
return true
}
}
for _, envelopeKey := range []string{"data", "result", "response"} {
if child, ok := object[envelopeKey].(map[string]any); ok && knownPrimaryDocUnassociatedData(child) {
return true
}
}
return false
}
+78 -6
View File
@@ -84,9 +84,43 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error {
return rt.Output(result)
}
for offset := 0; offset < len(ids); offset += recordBatchSize {
end := minInt(offset+recordBatchSize, len(ids))
batch := ids[offset:end]
existingIDs, err := queryExistingRecordIDs(rt, baseID, tableID, ids)
if err != nil {
return err
}
existingSet := make(map[string]bool, len(existingIDs))
for _, id := range existingIDs {
existingSet[id] = true
}
missingIDs := make([]string, 0, len(ids)-len(existingIDs))
for _, id := range ids {
if !existingSet[id] {
missingIDs = append(missingIDs, id)
}
}
result.Resolved = map[string]any{
"existingRecordIds": existingIDs,
"missingRecordIds": missingIDs,
}
result.Plan = nil
for offset := 0; offset < len(existingIDs); offset += recordBatchSize {
end := minInt(offset+recordBatchSize, len(existingIDs))
result.Plan = append(result.Plan, compositeStep{
Index: len(result.Plan) + 1, Name: "delete existing record batch", Tool: "delete_records",
Status: "planned", Offset: offset, Count: end - offset,
})
}
if len(existingIDs) == 0 {
result.Status = "unchanged"
result.Executed = false
result.Verification = map[string]any{"status": "verified_absent_before_write", "missingCount": len(missingIDs)}
result.Result = map[string]any{"deletedCount": 0, "missingCount": len(missingIDs), "batchCount": 0}
return rt.Output(result)
}
for offset := 0; offset < len(existingIDs); offset += recordBatchSize {
end := minInt(offset+recordBatchSize, len(existingIDs))
batch := existingIDs[offset:end]
writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, "delete_records", map[string]any{
"baseId": baseID, "tableId": tableID, "recordIds": batch,
})
@@ -109,7 +143,7 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error {
step.Status = "unknown"
result.CompletedSteps = append(result.CompletedSteps, step)
result.CompletedCount = offset
result.FailedCount = len(ids) - offset
result.FailedCount = len(existingIDs) - offset
result.Status = "unknown"
if offset > 0 {
result.Status = "partial_success"
@@ -130,11 +164,49 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error {
result.CompletedCount = end
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "delete_records", "offset": offset, "recordIds": batch})
}
result.Verification = map[string]any{"status": "verified_absent", "verifiedCount": len(ids)}
result.Result = map[string]any{"deletedCount": len(ids), "batchCount": len(result.CompletedSteps)}
result.Verification = map[string]any{
"status": "verified_absent", "verifiedCount": len(existingIDs),
"missingBeforeWriteCount": len(missingIDs),
}
result.Result = map[string]any{
"deletedCount": len(existingIDs), "missingCount": len(missingIDs),
"batchCount": len(result.CompletedSteps),
}
return rt.Output(result)
}
func queryExistingRecordIDs(rt *shortcut.RuntimeContext, baseID, tableID string, ids []string) ([]string, error) {
records, err := queryDeletedRecordsByIDs(rt, baseID, tableID, ids)
if err != nil {
return nil, err
}
wanted := make(map[string]bool, len(ids))
for _, id := range ids {
wanted[id] = true
}
present := make(map[string]bool, len(records))
for index, record := range records {
id := recordID(record)
if id == "" {
return nil, fmt.Errorf("delete preflight record %d is missing recordId", index)
}
if !wanted[id] {
return nil, fmt.Errorf("delete preflight returned unexpected recordId %q", id)
}
if present[id] {
return nil, fmt.Errorf("delete preflight returned duplicate recordId %q", id)
}
present[id] = true
}
existing := make([]string, 0, len(present))
for _, id := range ids {
if present[id] {
existing = append(existing, id)
}
}
return existing, nil
}
func parseRecordIDs(values []string) ([]string, error) {
seen := map[string]bool{}
ids := make([]string, 0, len(values))
@@ -4,11 +4,14 @@
package aitable
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
func TestCrossPlatformCoverageRecordObjectAndIDValidation(t *testing.T) {
@@ -90,6 +93,137 @@ func TestCrossPlatformCoverageRecordDeleteDryRunAndDualFailureE2E(t *testing.T)
}
}
func TestCrossPlatformCoverageRecordPrimaryDocPreflightAndNormalizationE2E(t *testing.T) {
t.Run("record not found is classified", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`},
{text: `{"records":[]}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "missing")
var typed *apperrors.Error
if err == nil || out != "" || !errors.As(err, &typed) || typed.Reason != "RESOURCE_NOT_FOUND" {
t.Fatalf("missing primary-doc record = output:%q err:%#v", out, err)
}
})
t.Run("table without primary doc field is normalized", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"fields":[{"fieldId":"text","type":"text"}]}`},
{text: `{"records":[{"recordId":"r1","cells":{"text":"x"}}]}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
if err != nil || !strings.Contains(out, `"status": "no_primary_doc_field"`) || !strings.Contains(out, `"exists": false`) || len(caller.calls) != 2 {
t.Fatalf("no primary field = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("empty primary doc cell is unassociated", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`},
{text: `{"records":[{"recordId":"r1","cells":{}}]}`},
{text: `{"data":{"nodeId":null}}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
if err != nil || !strings.Contains(out, `"status": "unassociated"`) || !strings.Contains(out, `"exists": false`) || len(caller.calls) != 3 || caller.calls[2].tool != "get_primary_doc" {
t.Fatalf("unassociated primary doc = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("known helper no-record error is unassociated", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`},
{text: `{"records":[{"recordId":"r1","cells":{}}]}`},
{err: errors.New("no record")},
}}
out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
if err != nil || !strings.Contains(out, `"status": "unassociated"`) || !strings.Contains(out, `"exists": false`) || len(caller.calls) != 3 {
t.Fatalf("no-record primary doc = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("associated doc is resolved through helper", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`},
{text: `{"records":[{"recordId":"r1","cells":{"primary":{"associated":true}}}]}`},
{text: `{"data":{"nodeId":"node-1"}}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
if err != nil || !strings.Contains(out, `"nodeId": "node-1"`) || !strings.Contains(out, `"exists": true`) || len(caller.calls) != 3 || caller.calls[2].tool != "get_primary_doc" {
t.Fatalf("associated primary doc = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
}
func TestCrossPlatformCoverageRecordPrimaryDocFailureAndShapeEdgesE2E(t *testing.T) {
cases := []struct {
name string
steps []upsertByKeyStep
}{
{name: "fields transport", steps: []upsertByKeyStep{{err: context.Canceled}}},
{name: "missing fields collection", steps: []upsertByKeyStep{{text: `{}`}}},
{name: "primary field missing id", steps: []upsertByKeyStep{{text: `{"fields":[{"type":"primaryDoc"}]}`}}},
{name: "record query error", steps: []upsertByKeyStep{{text: `{"fields":[]}`}, {err: context.DeadlineExceeded}}},
{name: "wrong record identity", steps: []upsertByKeyStep{{text: `{"fields":[]}`}, {text: `{"records":[{"recordId":"other"}]}`}}},
{name: "helper unknown error", steps: []upsertByKeyStep{{text: `{"fields":[{"fieldId":"p","type":"primaryDoc"}]}`}, {text: `{"records":[{"recordId":"r1"}]}`}, {err: errors.New("permission denied")}}},
{name: "helper missing node", steps: []upsertByKeyStep{{text: `{"fields":[{"fieldId":"p","type":"primaryDoc"}]}`}, {text: `{"records":[{"recordId":"r1"}]}`}, {text: `{"data":{"message":"ok"}}`}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
if err == nil || out != "" {
t.Fatalf("primary doc failure = output:%q err:%v", out, err)
}
})
}
for _, value := range []any{
map[string]any{"exists": false},
map[string]any{"dentryUuid": ""},
map[string]any{"status": "NO_RECORD"},
map[string]any{"data": map[string]any{"result": map[string]any{"status": "unassociated"}}},
} {
if !knownPrimaryDocUnassociatedData(value) {
t.Errorf("known unassociated shape not recognized: %#v", value)
}
}
if knownPrimaryDocUnassociatedData([]any{"unrelated"}) || knownPrimaryDocUnassociatedData(nil) {
t.Fatal("unrelated shapes must not be unassociated")
}
for _, value := range []any{
map[string]any{"metadata": map[string]any{"exists": false}},
map[string]any{"items": []any{map[string]any{"nodeId": nil}}},
} {
if knownPrimaryDocUnassociatedData(value) {
t.Errorf("unrelated nested marker must not be unassociated: %#v", value)
}
}
if got := primaryDocNodeID(map[string]any{"metadata": map[string]any{"nodeId": "wrong"}}); got != "" {
t.Fatalf("unrelated nested nodeId = %q, want empty", got)
}
if got := primaryDocNodeID(map[string]any{"data": map[string]any{"response": map[string]any{"dentryUuid": " node-1 "}}}); got != "node-1" {
t.Fatalf("known envelope nodeId = %q, want node-1", got)
}
}
func TestCrossPlatformCoverageRecordDeleteRejectsMalformedPreflightRecordsE2E(t *testing.T) {
for _, tc := range []struct {
name string
records string
}{
{name: "missing id", records: `[{"cells":{}}]`},
{name: "unexpected id", records: `[{"recordId":"other"}]`},
{name: "duplicate id", records: `[{"recordId":"r1"},{"recordId":"r1"}]`},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"records":` + tc.records + `}`}}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err == nil || out != "" || len(caller.calls) != 1 {
t.Fatalf("delete malformed preflight = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
}
}
func TestCrossPlatformCoverageRecordBatchVerificationEdgesE2E(t *testing.T) {
record := []map[string]any{{"recordId": "r1", "cells": map[string]any{"f": "new"}}}
@@ -612,11 +612,18 @@ func TestCrossPlatformCoverageRecordQueryFailureAndContinuationBranches(t *testi
})
t.Run("delete readback follows active continuation to absence", func(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, args map[string]any) (string, error) {
written := false
verificationPage := 0
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, args map[string]any) (string, error) {
if tool != "query_records" {
written = true
return `{"deletedCount":1}`, nil
}
if call == 1 {
if !written {
return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil
}
verificationPage++
if verificationPage == 1 {
if _, exists := args["cursor"]; exists {
t.Fatalf("first deletion readback unexpectedly has cursor: %#v", args)
}
@@ -628,23 +635,30 @@ func TestCrossPlatformCoverageRecordQueryFailureAndContinuationBranches(t *testi
return `{"success":true,"data":{"records":[],"hasMore":false}}`, nil
}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err != nil || out == "" || len(caller.calls) != 3 {
if err != nil || out == "" || len(caller.calls) != 4 {
t.Fatalf("delete continued absence output=%q error=%v calls=%#v", out, err, caller.calls)
}
})
t.Run("delete readback follows active continuation to remaining record", func(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, _ map[string]any) (string, error) {
written := false
verificationPage := 0
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, _ map[string]any) (string, error) {
if tool != "query_records" {
written = true
return `{"deletedCount":1}`, nil
}
if call == 1 {
if !written {
return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil
}
verificationPage++
if verificationPage == 1 {
return `{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c1"}}`, nil
}
return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil
}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err == nil || out != "" || len(caller.calls) != 3 {
if err == nil || out != "" || len(caller.calls) != 4 {
t.Fatalf("delete continued remaining output=%q error=%v calls=%#v", out, err, caller.calls)
}
var typed *apperrors.Error
@@ -654,14 +668,21 @@ func TestCrossPlatformCoverageRecordQueryFailureAndContinuationBranches(t *testi
})
t.Run("delete readback fails closed on empty continuation stall", func(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, _ map[string]any) (string, error) {
written := false
verificationPage := 0
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, _ map[string]any) (string, error) {
if tool != "query_records" {
written = true
return `{"deletedCount":1}`, nil
}
return fmt.Sprintf(`{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c%d"}}`, call), nil
if !written {
return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil
}
verificationPage++
return fmt.Sprintf(`{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c%d"}}`, verificationPage), nil
}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err == nil || out != "" || len(caller.calls) != recordQueryMaxConsecutiveEmptyPages+1 {
if err == nil || out != "" || len(caller.calls) != recordQueryMaxConsecutiveEmptyPages+2 {
t.Fatalf("delete stalled continuation output=%q error=%v calls=%#v", out, err, caller.calls)
}
var typed *apperrors.Error
@@ -678,21 +699,26 @@ func TestCrossPlatformCoverageRecordWriteReadbackUsesStableServicePagesE2E(t *te
records := updateFixtureRecords(0, size, "完成")
caller := &upsertByKeyCaller{}
queryCalls := 0
writeSeen := false
caller.callFn = func(_ int, _, tool string, args map[string]any) (string, error) {
if tool != "query_records" {
writeSeen = true
return `{"success":true}`, nil
}
queryCalls++
if operation == "delete" {
ids := args["recordIds"].([]string)
empty := make([]map[string]any, len(ids))
response := pagedRecordQueryResponse(t, empty, args)
requested := make([]map[string]any, 0, len(ids))
if !writeSeen {
for _, id := range ids {
requested = append(requested, map[string]any{"recordId": id})
}
}
response := pagedRecordQueryResponse(t, requested, args)
var payload map[string]any
if err := json.Unmarshal([]byte(response), &payload); err != nil {
t.Fatal(err)
}
page := payload["data"].(map[string]any)
page["records"] = []any{}
raw, _ := json.Marshal(payload)
return string(raw), nil
}
@@ -725,6 +751,9 @@ func TestCrossPlatformCoverageRecordWriteReadbackUsesStableServicePagesE2E(t *te
t.Fatalf("%s size %d false negative: output=%q err=%v", operation, size, out, err)
}
wantQueries := (size + recordQueryServicePageSize - 1) / recordQueryServicePageSize
if operation == "delete" {
wantQueries *= 2
}
if queryCalls != wantQueries {
t.Fatalf("%s size %d query calls=%d, want %d", operation, size, queryCalls, wantQueries)
}
@@ -860,15 +889,20 @@ func recordIDFixtures(count int) []string {
func TestCrossPlatformCoverageRecordDeleteAutoChunksAndProvesAbsenceE2E(t *testing.T) {
ids := recordIDFixtures(101)
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"deletedCount":100}`},
{text: `{"data":{"records":[]}}`},
{text: `{"data":{"records":[]}}`},
{text: `{"data":{"records":[]}}`},
{text: `{"data":{"records":[]}}`},
{text: `{"data":{"records":[]}}`},
{text: `{"deletedCount":1}`},
{text: `{"data":{"records":[]}}`},
writes := 0
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, args map[string]any) (string, error) {
if tool == "delete_records" {
writes++
return fmt.Sprintf(`{"deletedCount":%d}`, len(args["recordIds"].([]string))), nil
}
requested := args["recordIds"].([]string)
records := make([]map[string]any, 0, len(requested))
if writes == 0 {
for _, id := range requested {
records = append(records, map[string]any{"recordId": id})
}
}
return recordListJSON(t, records), nil
}}
out, err := runRecordDeleteCLI(t, caller, ids)
if err != nil {
@@ -879,14 +913,43 @@ func TestCrossPlatformCoverageRecordDeleteAutoChunksAndProvesAbsenceE2E(t *testi
t.Fatalf("delete output missing %s: %s", want, out)
}
}
if len(caller.calls) != 8 || caller.calls[0].tool != "delete_records" || caller.calls[1].tool != "query_records" || caller.calls[6].tool != "delete_records" {
if len(caller.calls) != 14 || caller.calls[0].tool != "query_records" || caller.calls[6].tool != "delete_records" || caller.calls[12].tool != "delete_records" {
t.Fatalf("delete call sequence = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageRecordDeleteDoesNotCountMissingIDsE2E(t *testing.T) {
t.Run("all requested IDs were already absent", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"success":true,"data":{"records":[],"hasMore":false}}`}}}
out, err := runRecordDeleteCLI(t, caller, []string{"missing-1", "missing-2"})
if err != nil || len(caller.calls) != 1 || caller.calls[0].tool != "query_records" ||
!strings.Contains(out, `"deletedCount": 0`) || !strings.Contains(out, `"missingCount": 2`) {
t.Fatalf("already absent delete = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("only records proven present are counted", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`},
{text: `{"deletedCount":1}`},
{text: `{"success":true,"data":{"records":[],"hasMore":false}}`},
}}
out, err := runRecordDeleteCLI(t, caller, []string{"missing", "r1"})
if err != nil || !strings.Contains(out, `"deletedCount": 1`) || !strings.Contains(out, `"missingCount": 1`) {
t.Fatalf("mixed delete = output:%q err:%v", out, err)
}
deleted := caller.calls[1].args["recordIds"].([]string)
if !slices.Equal(deleted, []string{"r1"}) {
t.Fatalf("delete target IDs = %#v", deleted)
}
})
}
func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t *testing.T) {
t.Run("legal empty collection proves deletion", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: ""}, {text: `{"records":[]}`}}}
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"records":[{"recordId":"r1"}]}`}, {text: ""}, {text: `{"records":[]}`},
}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err != nil || !strings.Contains(out, `"status": "recovered"`) {
t.Fatalf("delete recovered = output:%q err:%v", out, err)
@@ -895,6 +958,7 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t
t.Run("explicit service success with empty data proves deletion", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"records":[{"recordId":"r1"}]}`},
{text: `{"deletedCount":1}`},
{text: `{"success":true,"status":"success","error":{},"data":{}}`},
}}
@@ -905,7 +969,9 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t
})
t.Run("missing records contract is unknown", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"deletedCount":1}`}, {text: `{"data":{}}`}}}
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"records":[{"recordId":"r1"}]}`}, {text: `{"deletedCount":1}`}, {text: `{"data":{}}`},
}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err == nil || out != "" {
t.Fatalf("delete missing records = output:%q err:%v", out, err)
@@ -918,6 +984,7 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t
t.Run("remaining record is not success", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"records":[{"recordId":"r1"}]}`},
{text: `{"deletedCount":0}`},
{text: `{"records":[{"recordId":"r1","cells":{"fld":"still here"}}]}`},
}}
@@ -930,15 +997,22 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t
func TestCrossPlatformCoverageRecordDeletePartialCheckpointE2E(t *testing.T) {
ids := recordIDFixtures(101)
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"deletedCount":100}`},
{text: `{"records":[]}`},
{text: `{"records":[]}`},
{text: `{"records":[]}`},
{text: `{"records":[]}`},
{text: `{"records":[]}`},
{text: `{"deletedCount":0}`},
{text: `{"records":[{"recordId":"r100","cells":{}}]}`},
writes := 0
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, args map[string]any) (string, error) {
if tool == "delete_records" {
writes++
return `{"deletedCount":0}`, nil
}
requested := args["recordIds"].([]string)
records := make([]map[string]any, 0, len(requested))
if writes == 0 {
for _, id := range requested {
records = append(records, map[string]any{"recordId": id})
}
} else if writes == 2 {
records = append(records, map[string]any{"recordId": "r100", "cells": map[string]any{}})
}
return recordListJSON(t, records), nil
}}
out, err := runRecordDeleteCLI(t, caller, ids)
if err == nil || out != "" {
@@ -77,6 +77,10 @@ func TestCrossPlatformCoverageAITableProjectionValidAndMissingIdentityShapes(t *
if err != nil || len(workflows) != 1 {
t.Fatalf("workflow projection = %#v, %v", workflows, err)
}
workflows, err = workflowListProject(map[string]any{"list": []any{map[string]any{"flowId": "flow", "name": "real service shape"}}})
if err != nil || len(workflows) != 1 || workflows[0]["workflowId"] != "flow" {
t.Fatalf("workflow flowId projection = %#v, %v", workflows, err)
}
}
func TestCrossPlatformCoverageCompositeAndReviewedContractFallbacks(t *testing.T) {
@@ -183,6 +187,7 @@ func TestCrossPlatformCoverageViewPresetValidationAndDryRunE2E(t *testing.T) {
}
func TestCrossPlatformCoverageViewPresetReadBackFailuresAndUpdateE2E(t *testing.T) {
disableViewPresetSleep(t)
preflight := `{"views":[{"viewId":"v","viewName":"X","viewType":"Grid","config":{"f":0}}]}`
cases := []struct {
name string
@@ -237,6 +242,40 @@ func TestCrossPlatformCoverageViewPresetPureMatching(t *testing.T) {
}
}
func TestCrossPlatformCoverageViewPresetProjectionShapeEdges(t *testing.T) {
if !presetViewMatches(map[string]any{"viewType": "Grid", "f": 1}, "Grid", map[string]any{"f": 1}) {
t.Fatal("top-level projected config must match")
}
invalid := []map[string]any{
{"columns": "bad", "custom": map[string]any{}},
{"columns": []any{"f"}, "custom": map[string]any{"hiddenFields": map[string]any{"f": "bad"}}},
{"columns": []any{1}, "custom": map[string]any{"hiddenFields": map[string]any{"1": false}}},
{"columns": []any{"f"}, "custom": map[string]any{"hiddenFields": map[string]any{}}},
{"columns": []any{"f"}, "custom": map[string]any{"hiddenFields": []any{}}},
{"columns": []any{1}, "custom": map[string]any{"hiddenFields": []any{false}}},
{"columns": []any{"f"}, "custom": map[string]any{"hiddenFields": []any{"bad"}}},
}
for _, view := range invalid {
if got, ok := projectedVisibleFieldIDs(view); ok || got != nil {
t.Errorf("invalid projection %#v = %#v, %v", view, got, ok)
}
}
}
func TestCrossPlatformCoverageViewPresetReadBackDuplicateStopsE2E(t *testing.T) {
disableViewPresetSleep(t)
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"views":[]}`},
{text: `{"viewId":"v1"}`},
{text: `{"views":[{"viewId":"v1","viewName":"X"},{"viewId":"v2","viewName":"X"}]}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
"--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"f":1}`, "--yes")
if err == nil || out != "" || len(caller.calls) != 3 {
t.Fatalf("duplicate readback = output:%q err:%v calls:%#v", out, err, caller.calls)
}
}
func TestCrossPlatformCoverageWorkflowDeployValidationAndFailureStagesE2E(t *testing.T) {
cases := []struct {
name string
+103 -19
View File
@@ -7,12 +7,17 @@ import (
"fmt"
"reflect"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const viewPresetReadbackAttempts = 8
var viewPresetSleep = time.Sleep
var ViewPresetApply = shortcut.Shortcut{
Service: "aitable",
Command: "+view-preset-apply",
@@ -100,23 +105,39 @@ func executeViewPresetApply(rt *shortcut.RuntimeContext) error {
if action == "create" {
viewID = findStringByKeys(writeData, "viewId")
}
readBack, verifyErr := rt.CallMCPData(serverMain, "get_views", map[string]any{"baseId": baseID, "tableId": tableID})
verifiedViews, found := findNamedObjectList(readBack, "views", "viewList")
if verifyErr == nil && !found {
verifyErr = fmt.Errorf("get_views read-back is missing the views collection")
}
verifiedMatches := viewsByExactName(verifiedViews, name)
if verifyErr == nil && len(verifiedMatches) != 1 {
verifyErr = fmt.Errorf("view read-back matched %d exact-name views, want 1", len(verifiedMatches))
}
if verifyErr == nil {
actualID := stringValue(verifiedMatches[0], "viewId", "id")
if actualID == "" || (viewID != "" && actualID != viewID) {
verifyErr = fmt.Errorf("view read-back identity mismatch: got %q, response %q", actualID, viewID)
} else if !presetViewMatches(verifiedMatches[0], viewType, config) {
verifyErr = fmt.Errorf("view read-back does not contain the declared type/config")
} else {
viewID = actualID
var verifiedMatches []map[string]any
var verifyErr error
for attempt := 0; attempt < viewPresetReadbackAttempts; attempt++ {
if attempt > 0 {
backoff := time.Duration(1<<(attempt-1)) * time.Second
if backoff > 12*time.Second {
backoff = 12 * time.Second
}
viewPresetSleep(backoff)
}
readBack, readErr := rt.CallMCPData(serverMain, "get_views", map[string]any{"baseId": baseID, "tableId": tableID})
verifyErr = readErr
verifiedViews, found := findNamedObjectList(readBack, "views", "viewList")
if verifyErr == nil && !found {
verifyErr = fmt.Errorf("get_views read-back is missing the views collection")
}
verifiedMatches = viewsByExactName(verifiedViews, name)
if verifyErr == nil && len(verifiedMatches) != 1 {
verifyErr = fmt.Errorf("view read-back matched %d exact-name views, want 1", len(verifiedMatches))
if len(verifiedMatches) > 1 {
break
}
}
if verifyErr == nil {
actualID := stringValue(verifiedMatches[0], "viewId", "id")
if actualID == "" || (viewID != "" && actualID != viewID) {
verifyErr = fmt.Errorf("view read-back identity mismatch: got %q, response %q", actualID, viewID)
} else if !presetViewMatches(verifiedMatches[0], viewType, config) {
verifyErr = fmt.Errorf("view read-back does not contain the declared type/config")
} else {
viewID = actualID
break
}
}
}
if verifyErr != nil {
@@ -153,8 +174,71 @@ func presetViewMatches(view map[string]any, viewType string, config map[string]a
if actualType != "" && actualType != viewType {
return false
}
actualConfig, ok := view["config"].(map[string]any)
return ok && mapContains(actualConfig, config)
actualConfig := make(map[string]any, len(config))
if nested, ok := view["config"].(map[string]any); ok {
for key, value := range nested {
actualConfig[key] = value
}
}
for key := range config {
if value, ok := view[key]; ok {
actualConfig[key] = value
}
}
if _, wanted := config["visibleFieldIds"]; wanted {
if visible, ok := projectedVisibleFieldIDs(view); ok {
actualConfig["visibleFieldIds"] = visible
}
}
return mapContains(actualConfig, config)
}
// get_views projects visible fields as columns plus hiddenFields instead of
// echoing create_view's config.visibleFieldIds input. Different deployments
// return hiddenFields either as a fieldId-keyed object or a parallel array.
func projectedVisibleFieldIDs(view map[string]any) ([]any, bool) {
columns, columnsOK := view["columns"].([]any)
custom, customOK := view["custom"].(map[string]any)
if !columnsOK || !customOK {
return nil, false
}
if hidden, ok := custom["hiddenFields"].(map[string]any); ok {
visible := make([]any, 0, len(columns))
for _, column := range columns {
fieldID, fieldOK := column.(string)
if !fieldOK {
return nil, false
}
isHidden, exists := hidden[fieldID]
if !exists {
return nil, false
}
hiddenFlag, hiddenTypeOK := isHidden.(bool)
if !hiddenTypeOK {
return nil, false
}
if !hiddenFlag {
visible = append(visible, fieldID)
}
}
return visible, true
}
hidden, hiddenOK := custom["hiddenFields"].([]any)
if !hiddenOK || len(columns) != len(hidden) {
return nil, false
}
visible := make([]any, 0, len(columns))
for index, column := range columns {
fieldID, fieldOK := column.(string)
isHidden, hiddenTypeOK := hidden[index].(bool)
if !fieldOK || !hiddenTypeOK {
return nil, false
}
if !isHidden {
visible = append(visible, fieldID)
}
}
return visible, true
}
func mapContains(actual, expected map[string]any) bool {
@@ -7,13 +7,21 @@ import (
"errors"
"strings"
"testing"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
const workflowDSLFixture = `{"version":"workflow-dsl/v1","name":"提醒","nodes":[]}`
func disableViewPresetSleep(t *testing.T) {
t.Helper()
testseam.Swap(t, &viewPresetSleep, func(time.Duration) {})
}
func TestCrossPlatformCoverageViewPresetCreateUpdateAndVerificationE2E(t *testing.T) {
disableViewPresetSleep(t)
t.Run("create and verify exact config", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"views":[]}`},
@@ -30,6 +38,32 @@ func TestCrossPlatformCoverageViewPresetCreateUpdateAndVerificationE2E(t *testin
}
})
t.Run("projected columns and hidden flags verify visible field config", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"views":[]}`},
{text: `{"data":{"viewId":"v1"}}`},
{text: `{"views":[{"viewId":"v1","viewName":"投影","viewType":"Grid","columns":["f1","f2","f3"],"custom":{"hiddenFields":[false,true,false]}}]}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
"--base-id", "base", "--table-id", "table", "--name", "投影", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f1","f3"]}`, "--yes")
if err != nil || !strings.Contains(out, `"status": "verified"`) {
t.Fatalf("projected view preset = output:%q err:%v", out, err)
}
})
t.Run("projected columns and keyed hidden flags verify visible field config", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"views":[]}`},
{text: `{"data":{"viewId":"v1"}}`},
{text: `{"views":[{"viewId":"v1","viewName":"投影对象","viewType":"Grid","columns":["f1","f2","f3"],"custom":{"hiddenFields":{"f1":true,"f2":false,"f3":false}}}]}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
"--base-id", "base", "--table-id", "table", "--name", "投影对象", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f2","f3"]}`, "--yes")
if err != nil || !strings.Contains(out, `"status": "verified"`) {
t.Fatalf("keyed projected view preset = output:%q err:%v", out, err)
}
})
t.Run("unchanged preset makes no write", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"views":[{"viewId":"v1","viewName":"待处理","viewType":"Grid","config":{"visibleFieldIds":["f1"]}}]}`}}}
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
@@ -52,7 +86,26 @@ func TestCrossPlatformCoverageViewPresetCreateUpdateAndVerificationE2E(t *testin
})
}
func TestCrossPlatformCoverageViewPresetRetriesUntilTerminalStateE2E(t *testing.T) {
disableViewPresetSleep(t)
steps := []upsertByKeyStep{
{text: `{"views":[]}`},
{text: `{"data":{"viewId":"v1"}}`},
}
for attempt := 0; attempt < viewPresetReadbackAttempts-1; attempt++ {
steps = append(steps, upsertByKeyStep{text: `{"views":[]}`})
}
steps = append(steps, upsertByKeyStep{text: `{"views":[{"viewId":"v1","viewName":"X","viewType":"Grid","config":{"visibleFieldIds":["f1"]}}]}`})
caller := &upsertByKeyCaller{steps: steps}
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
"--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f1"]}`, "--yes")
if err != nil || !strings.Contains(out, `"status": "verified"`) || len(caller.calls) != viewPresetReadbackAttempts+2 {
t.Fatalf("eventual view preset = output:%q err:%v calls:%#v", out, err, caller.calls)
}
}
func TestCrossPlatformCoverageViewPresetAmbiguousOrMismatchedIsNotSuccessE2E(t *testing.T) {
disableViewPresetSleep(t)
t.Run("duplicate name stops before write", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"views":[{"viewId":"v1","viewName":"X"},{"viewId":"v2","viewName":"X"}]}`}}}
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
@@ -219,6 +219,9 @@ func ResolveBaseName(reader Reader, name string, allowFuzzy bool) (Resolution, e
all = append(all, Candidate{ID: id, Name: candidateName})
}
next, hasMore, hasMoreKnown := pagination(data)
if hasMoreKnown && !hasMore {
return selectCandidate("base", query, dedupe(all), allowFuzzy)
}
if next == "" {
if hasMoreKnown && hasMore {
return Resolution{}, incomplete("base", query, all, "search_bases 声明有后续页但没有 cursor")
@@ -125,6 +125,24 @@ func TestCrossPlatformCoverageResolveBaseNameExactPaginationE2E(t *testing.T) {
}
}
func TestCrossPlatformCoverageResolveBaseStopsOnAuthoritativeTerminalCursor(t *testing.T) {
reader := &resolverReader{steps: []resolverStep{
{data: map[string]any{"bases": []any{}, "hasMore": true, "nextCursor": "next"}},
{data: map[string]any{
"bases": []any{map[string]any{"baseId": "b1", "baseName": "项目管理"}},
"hasMore": false,
"nextCursor": "next",
}},
}}
got, err := ResolveBaseName(reader, "项目管理", false)
if err != nil || got.Selected.ID != "b1" {
t.Fatalf("terminal cursor resolution = %#v, %v", got, err)
}
if len(reader.calls) != 2 {
t.Fatalf("terminal hasMore=false made %d calls, want 2", len(reader.calls))
}
}
func TestCrossPlatformCoverageResolveNameFuzzyIsExplicitAndAmbiguityFails(t *testing.T) {
data := map[string]any{"bases": []any{
map[string]any{"baseId": "b1", "baseName": "项目管理归档"},
@@ -0,0 +1,101 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package builtin_test
import (
"encoding/json"
"os"
"sort"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageDINGSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
raw, err := os.ReadFile("../semantic_catalog_ding.json")
if err != nil {
t.Fatal(err)
}
var source chatSemanticCatalogFixture
if err := json.Unmarshal(raw, &source); err != nil {
t.Fatal(err)
}
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service == "ding" {
registered[item.Command] = item
}
}
if source.Service != "ding" || len(registered) != 5 || len(source.Shortcuts) != 5 {
t.Fatalf("service/registered/catalog=%s/%d/%d, want ding/5/5", source.Service, len(registered), len(source.Shortcuts))
}
wantCompatibilityVisible := map[string]bool{
"+list": true,
"+recall-personal": true,
"+send-personal": true,
}
wantCompatibilityAvailability := map[string]shortcut.Availability{
"+list": shortcut.AvailabilityUnavailable,
"+recall-personal": shortcut.AvailabilityAvailable,
"+send-personal": shortcut.AvailabilityAvailable,
}
public, unavailable, compatibilityVisible := 0, 0, 0
var missing, stale []string
for command, item := range registered {
record, ok := source.Shortcuts[command]
if !ok {
missing = append(missing, command)
continue
}
availability := record.Availability
if availability == "" {
availability = source.Availability
}
if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
t.Errorf("%s semantic review facts drifted", command)
}
if item.Risk != record.Risk || item.Availability != availability {
t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability)
}
if record.Public {
public++
if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("ding", command) {
t.Errorf("%s public availability/visibility drift", command)
}
if item.Contract.Empty() || item.Contract.Result == nil || item.Safety.Effect == "" || item.OutputRollout != output.RolloutUnifiedActive {
t.Errorf("%s lacks public Contract/Safety/Result/unified output", command)
}
} else {
if record.CompatibilityVisible {
compatibilityVisible++
if item.Hidden || !item.CompatibilityVisible || availability != wantCompatibilityAvailability[command] || shortcut.InPublicCatalog("ding", command) {
t.Errorf("%s compatibility-visible boundary drift", command)
}
} else if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog("ding", command) {
t.Errorf("%s nonpublic shortcut visibility drift", command)
}
if availability == shortcut.AvailabilityUnavailable {
unavailable++
}
}
if record.CompatibilityVisible != wantCompatibilityVisible[command] {
t.Errorf("%s compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command])
}
}
for command := range source.Shortcuts {
if _, ok := registered[command]; !ok {
stale = append(stale, command)
}
}
sort.Strings(missing)
sort.Strings(stale)
if len(missing) > 0 || len(stale) > 0 {
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
}
if public != 1 || unavailable != 2 || compatibilityVisible != len(wantCompatibilityVisible) {
t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/2/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible))
}
}
@@ -0,0 +1,102 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package builtin_test
import (
"encoding/json"
"os"
"sort"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageOASemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
raw, err := os.ReadFile("../semantic_catalog_oa.json")
if err != nil {
t.Fatal(err)
}
var source chatSemanticCatalogFixture
if err := json.Unmarshal(raw, &source); err != nil {
t.Fatal(err)
}
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service == "oa" {
registered[item.Command] = item
}
}
if source.Service != "oa" || len(registered) != 10 || len(source.Shortcuts) != 10 {
t.Fatalf("service/registered/catalog=%s/%d/%d, want oa/10/10", source.Service, len(registered), len(source.Shortcuts))
}
wantCompatibilityVisible := map[string]bool{
"+list-cc": true,
"+list-executed": true,
"+list-forms": true,
"+list-pending": true,
"+list-submitted": true,
"+my-initiated": true,
}
public, unavailable, compatibilityVisible := 0, 0, 0
var missing, stale []string
for command, item := range registered {
record, ok := source.Shortcuts[command]
if !ok {
missing = append(missing, command)
continue
}
availability := record.Availability
if availability == "" {
availability = source.Availability
}
if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
t.Errorf("%s semantic review facts drifted", command)
}
if item.Risk != record.Risk || item.Availability != availability {
t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability)
}
if item.Contract.Interface == nil || item.Contract.Interface.Availability != string(availability) {
t.Errorf("%s final interface availability=%v, want %q", command, item.Contract.Interface, availability)
}
if record.Public {
public++
if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("oa", command) {
t.Errorf("%s public availability/visibility drift", command)
}
if item.Contract.Empty() || item.Contract.Result == nil || item.Safety.Effect == "" || item.OutputRollout != output.RolloutUnifiedActive {
t.Errorf("%s lacks public Contract/Safety/Result/unified output", command)
}
} else {
if record.CompatibilityVisible {
compatibilityVisible++
if item.Hidden || !item.CompatibilityVisible || availability != shortcut.AvailabilityAvailable || shortcut.InPublicCatalog("oa", command) {
t.Errorf("%s compatibility-visible boundary drift", command)
}
} else if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog("oa", command) {
t.Errorf("%s nonpublic shortcut visibility drift", command)
}
if availability == shortcut.AvailabilityUnavailable {
unavailable++
}
}
if record.CompatibilityVisible != wantCompatibilityVisible[command] {
t.Errorf("%s compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command])
}
}
for command := range source.Shortcuts {
if _, ok := registered[command]; !ok {
stale = append(stale, command)
}
}
sort.Strings(missing)
sort.Strings(stale)
if len(missing) > 0 || len(stale) > 0 {
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
}
if public != 1 || unavailable != 0 || compatibilityVisible != len(wantCompatibilityVisible) {
t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/0/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible))
}
}
@@ -0,0 +1,83 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package builtin_test
import (
"encoding/json"
"os"
"sort"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageReportSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
raw, err := os.ReadFile("../semantic_catalog_report.json")
if err != nil {
t.Fatal(err)
}
var source chatSemanticCatalogFixture
if err := json.Unmarshal(raw, &source); err != nil {
t.Fatal(err)
}
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service == "report" {
registered[item.Command] = item
}
}
if source.Service != "report" || len(registered) != 4 || len(source.Shortcuts) != 4 {
t.Fatalf("service/registered/catalog=%s/%d/%d, want report/4/4", source.Service, len(registered), len(source.Shortcuts))
}
public, unavailable := 0, 0
var missing, stale []string
for command, item := range registered {
record, ok := source.Shortcuts[command]
if !ok {
missing = append(missing, command)
continue
}
availability := record.Availability
if availability == "" {
availability = source.Availability
}
if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
t.Errorf("%s semantic review facts drifted", command)
}
if item.Risk != record.Risk || item.Availability != availability {
t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability)
}
if record.Public {
public++
if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("report", command) {
t.Errorf("%s public availability/visibility drift", command)
}
if item.Contract.Empty() || item.Contract.Result == nil || item.Safety.Effect == "" || item.OutputRollout != output.RolloutUnifiedActive {
t.Errorf("%s lacks public Contract/Safety/Result/unified output", command)
}
} else {
if !item.Hidden || shortcut.InPublicCatalog("report", command) {
t.Errorf("%s nonpublic shortcut is visible", command)
}
if availability == shortcut.AvailabilityUnavailable {
unavailable++
}
}
}
for command := range source.Shortcuts {
if _, ok := registered[command]; !ok {
stale = append(stale, command)
}
}
sort.Strings(missing)
sort.Strings(stale)
if len(missing) > 0 || len(stale) > 0 {
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
}
if public != 4 || unavailable != 0 {
t.Fatalf("public/unavailable=%d/%d, want 4/0", public, unavailable)
}
}
+7 -2
View File
@@ -162,8 +162,13 @@ var SearchUser = shortcut.Shortcut{
// the clean output projection applied to every list command.
// Field names are probed defensively across candidate keys.
func searchUserProject(data map[string]any) []map[string]any {
raw, ok := data["result"].([]any)
if !ok {
var raw []any
switch result := data["result"].(type) {
case []any:
raw = result
case map[string]any:
raw = []any{result}
default:
return []map[string]any{}
}
out := make([]map[string]any, 0, len(raw))
@@ -0,0 +1,45 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package contact
import "testing"
func TestCrossPlatformCoverageSearchMobileProjectsObjectEnvelope(t *testing.T) {
users := searchUserProject(map[string]any{
"success": true,
"result": map[string]any{
"name": "Alice",
"userId": "u1",
"flowerName": "A",
"openDingTalkId": "open-1",
"title": "Engineer",
"unrelatedPrivate": "drop-me",
},
})
if len(users) != 1 || users[0]["userId"] != "u1" || users[0]["name"] != "Alice" {
t.Fatalf("object envelope projection = %#v, want one user", users)
}
if _, exists := users[0]["unrelatedPrivate"]; exists {
t.Fatalf("projection leaked unrelated field: %#v", users[0])
}
}
func TestCrossPlatformCoverageSearchMobilePreservesEmptyObjectSemantics(t *testing.T) {
users := searchUserProject(map[string]any{"success": true, "result": map[string]any{}})
if len(users) != 0 {
t.Fatalf("empty result projected users: %#v", users)
}
}
func TestCrossPlatformCoverageSearchMobileProjectsArrayAndMissingResult(t *testing.T) {
users := searchUserProject(map[string]any{
"result": []any{map[string]any{"name": "Alice", "userId": "u1"}},
})
if len(users) != 1 || users[0]["userId"] != "u1" || users[0]["name"] != "Alice" {
t.Fatalf("array envelope projection = %#v, want one user", users)
}
if users := searchUserProject(map[string]any{"success": true}); len(users) != 0 {
t.Fatalf("missing result projected users: %#v", users)
}
}
+316
View File
@@ -0,0 +1,316 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package ding
import (
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const (
dingCompositeReason = "Reviewed DING Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable DING identity checks, truthful pagination, and unified output projection."
dingCompatibilityWriteReason = "Historical DING CLI write compatibility: the command remains executable behind user confirmation, but it is excluded from Agent public discovery because receiver identity and recall terminal-state verification are incomplete."
dingWriteUnavailableReason = "DING mutation is unavailable to Agents until the downstream exposes stable receiver identities and a queryable recall terminal state; isolated self-fixtures already prove stable DING receipts but cannot prove those two facts."
)
type dingPageEvidence struct {
HasMore bool
Next string
}
func dingReadSafety() contract.SafetySpec {
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
}
func dingWriteSafety(destructive bool) contract.SafetySpec {
effect, risk := "write", "medium"
if destructive {
effect, risk = "destructive", "high"
}
return contract.SafetySpec{Effect: effect, Risk: risk, Confirmation: "user_required", Idempotency: "unknown"}
}
func dingListResult() *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"严格验证的 DING 消息页","properties":{"count":{"type":"integer","description":"当前页有效 DING 消息数量"},"messages":{"type":"array","description":"严格验证并投影后的 DING 消息","items":{"type":"object","description":"一条具有稳定 openDingId 的 DING 消息","properties":{"openDingId":{"type":"string","description":"稳定 DING 消息 ID"},"content":{"type":"string","description":"DING 消息内容"},"sourceMessageId":{"type":"string","description":"作为 DING 来源的聊天消息 ID"},"sendTime":{"type":"string","description":"服务端返回的发送时间"},"senderNick":{"type":"string","description":"发送人显示名"}},"required":["openDingId"],"additionalProperties":false}},"complete":{"type":"boolean","description":"当前页是否具有服务端分页终态证据"}},"required":["count","messages","complete"],"additionalProperties":false}`),
SensitivePaths: []string{"messages.content", "messages.senderNick"},
}
}
func dingReceiverResult() *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"指定 DING 的严格接收状态","properties":{"count":{"type":"integer","description":"经验证的接收状态数量"},"receivers":{"type":"array","description":"身份匹配的 DING 接收状态","items":{"type":"object","description":"一名接收人的 DING 确认状态","properties":{"openDingId":{"type":"string","description":"与请求一致的稳定 DING ID"},"confirmedStatus":{"type":"integer","description":"服务端 DING 确认状态码"},"receiverNick":{"type":"string","description":"接收人显示名"}},"required":["openDingId","confirmedStatus","receiverNick"],"additionalProperties":false}}},"required":["count","receivers"],"additionalProperties":false}`),
SensitivePaths: []string{"receivers.receiverNick"},
}
}
func dingPagination() *contract.PaginationSpec {
return &contract.PaginationSpec{
Kind: contract.PaginationKindCursor,
CursorParameter: "cursor",
MetaPath: contract.PaginationMetaPath,
EndpointExhaustedPath: contract.PaginationExhaustedPath,
NextTokenPath: contract.PaginationNextTokenPath,
}
}
func dingContract(command, description, intent string, available bool, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
cliPath := "ding " + command
availability, reason := contract.InterfaceAvailable, dingCompositeReason
if !available {
availability, reason = contract.InterfaceUnavailable, dingWriteUnavailableReason
}
return corecmd.ContractDecl{
Description: description,
Result: result,
Pagination: pagination,
Parameters: params,
Identity: contract.ToolIdentitySpec{
ProductID: "ding", Name: name, CanonicalPath: "ding." + name,
CLIPath: cliPath, PrimaryCLIPath: cliPath,
},
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: availability, Reason: reason},
Selection: contract.SelectionSpec{
AgentSummary: description,
UseWhen: []string{intent},
AvoidWhen: []string{"普通聊天消息使用 chat;写能力在缺少可逆真实 fixture、精确读回或清理证据时不要执行"},
Examples: examples,
},
}
}
func dingResponseError(operation, reason, message string) error {
return apperrors.NewAPI(message,
apperrors.WithOperation(operation),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("response_validation"),
apperrors.WithRetryable(false),
apperrors.WithReason(reason),
)
}
func dingRequireResult(data map[string]any, operation string) (map[string]any, error) {
if len(data) == 0 {
return nil, dingResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明 DING 调用成功或结果确实为空")
}
success, present := data["success"]
if !present {
return nil, dingResponseError(operation, "missing_success", "DING 响应缺少 success 业务状态")
}
succeeded, ok := success.(bool)
if !ok {
return nil, dingResponseError(operation, "invalid_success_type", fmt.Sprintf("DING 响应 success 应为布尔值,实际为 %T", success))
}
if !succeeded {
message := "DING 服务明确返回失败"
for _, key := range []string{"errorMsg", "errorMessage", "message"} {
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
message = strings.TrimSpace(value)
break
}
}
return nil, dingResponseError(operation, "remote_failure", message)
}
result, ok := data["result"].(map[string]any)
if !ok || result == nil {
return nil, dingResponseError(operation, "missing_result", "DING 成功响应缺少 result 对象")
}
return result, nil
}
func dingRequireObjectCollection(result map[string]any, operation, field string) ([]map[string]any, error) {
raw, present := result[field]
if !present {
return nil, dingResponseError(operation, "missing_collection", fmt.Sprintf("DING 成功响应缺少 result.%s 数组", field))
}
values, ok := raw.([]any)
if !ok {
return nil, dingResponseError(operation, "malformed_collection", fmt.Sprintf("DING result.%s 应为数组,实际为 %T", field, raw))
}
items := make([]map[string]any, 0, len(values))
for index, value := range values {
item, ok := value.(map[string]any)
if !ok || len(item) == 0 {
return nil, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING result.%s[%d] 不是非空对象", field, index))
}
items = append(items, item)
}
return items, nil
}
func dingRequiredString(item map[string]any, operation, field string, index int) (string, error) {
value, ok := item[field].(string)
value = strings.TrimSpace(value)
if !ok || value == "" {
return "", dingResponseError(operation, "missing_item_identity", fmt.Sprintf("DING 结果第 %d 项缺少非空 %s", index, field))
}
return value, nil
}
func dingOptionalString(item map[string]any, field string) (string, error) {
value, present := item[field]
if !present || value == nil {
return "", nil
}
text, ok := value.(string)
if !ok {
return "", fmt.Errorf("字段 %s 应为字符串,实际为 %T", field, value)
}
return strings.TrimSpace(text), nil
}
func dingInteger(value any) (int64, bool) {
switch typed := value.(type) {
case float64:
if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed || typed > math.MaxInt64 || typed < math.MinInt64 {
return 0, false
}
return int64(typed), true
case int:
return int64(typed), true
case int64:
return typed, true
case json.Number:
parsed, err := typed.Int64()
return parsed, err == nil
default:
return 0, false
}
}
func dingProjectMessages(data map[string]any, operation string) ([]map[string]any, dingPageEvidence, error) {
result, err := dingRequireResult(data, operation)
if err != nil {
return nil, dingPageEvidence{}, err
}
items, err := dingRequireObjectCollection(result, operation, "dingMessages")
if err != nil {
return nil, dingPageEvidence{}, err
}
messages := make([]map[string]any, 0, len(items))
seen := make(map[string]struct{}, len(items))
for index, item := range items {
id, identityErr := dingRequiredString(item, operation, "openDingId", index)
if identityErr != nil {
return nil, dingPageEvidence{}, identityErr
}
if _, duplicate := seen[id]; duplicate {
return nil, dingPageEvidence{}, dingResponseError(operation, "duplicate_item_identity", "当前 DING 页包含重复 openDingId")
}
seen[id] = struct{}{}
projected := map[string]any{"openDingId": id}
for source, target := range map[string]string{
"dingContent": "content", "dingSourceOpenMessageId": "sourceMessageId",
"sendTime": "sendTime", "senderNick": "senderNick",
} {
value, valueErr := dingOptionalString(item, source)
if valueErr != nil {
return nil, dingPageEvidence{}, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING 结果第 %d 项%s", index, valueErr.Error()))
}
if value != "" {
projected[target] = value
}
}
messages = append(messages, projected)
}
hasMoreValue, present := result["hasMore"]
if !present {
return nil, dingPageEvidence{}, dingResponseError(operation, "missing_pagination", "DING 列表缺少 result.hasMore,不能宣称当前页完整")
}
hasMore, ok := hasMoreValue.(bool)
if !ok {
return nil, dingPageEvidence{}, dingResponseError(operation, "malformed_pagination", "DING result.hasMore 应为布尔值")
}
page := dingPageEvidence{HasMore: hasMore}
if !hasMore {
if next, exists := result["nextCursor"]; exists && next != nil {
if numeric, valid := dingInteger(next); !valid || numeric != 0 {
return nil, dingPageEvidence{}, dingResponseError(operation, "conflicting_pagination", "hasMore=false 但 nextCursor 仍表示后续页")
}
}
return messages, page, nil
}
if len(messages) == 0 {
return nil, dingPageEvidence{}, dingResponseError(operation, "empty_page_with_continuation", "DING 空页仍声明 hasMore=true,无法证明游标可安全推进")
}
next, valid := dingInteger(result["nextCursor"])
if !valid || next <= 0 {
return nil, dingPageEvidence{}, dingResponseError(operation, "missing_next_cursor", "hasMore=true 时必须返回正整数 nextCursor")
}
page.Next = strconv.FormatInt(next, 10)
return messages, page, nil
}
func dingProjectReceivers(data map[string]any, operation, expectedID string) ([]map[string]any, error) {
result, err := dingRequireResult(data, operation)
if err != nil {
return nil, err
}
items, err := dingRequireObjectCollection(result, operation, "receivers")
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, dingResponseError(operation, "empty_receiver_status", "DING 接收状态显式为空,无法证明目标 DING 的接收状态")
}
receivers := make([]map[string]any, 0, len(items))
for index, item := range items {
id, identityErr := dingRequiredString(item, operation, "openDingId", index)
if identityErr != nil {
return nil, identityErr
}
if id != expectedID {
return nil, dingResponseError(operation, "identity_mismatch", "DING 接收状态的 openDingId 与请求目标不一致")
}
status, ok := dingInteger(item["confirmedStatus"])
if !ok {
return nil, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING 接收状态第 %d 项 confirmedStatus 不是整数", index))
}
nick, nickErr := dingRequiredString(item, operation, "receiverNick", index)
if nickErr != nil {
return nil, nickErr
}
receivers = append(receivers, map[string]any{"openDingId": id, "confirmedStatus": status, "receiverNick": nick})
}
return receivers, nil
}
func outputDingPage(rt *shortcut.RuntimeContext, messages []map[string]any, page dingPageEvidence) error {
payload := map[string]any{"count": len(messages), "messages": messages, "complete": !page.HasMore}
if !output.UsesUnifiedResult(rt.Command()) {
if page.HasMore {
payload["nextCursor"] = page.Next
}
return rt.Output(payload)
}
pagination, err := output.NewPagination(!page.HasMore, page.Next)
if err != nil {
return dingResponseError("im/list_ding_messages", "invalid_pagination", err.Error())
}
meta := &output.Meta{Count: output.NewCount(len(messages)), Pagination: pagination}
return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta)))
}
func dingUnavailable(operation string) error {
return apperrors.NewDiscovery("该 DING 写 Shortcut 虽有稳定写回执,但下游没有稳定接收人身份和可查询撤回终态,当前不可执行",
apperrors.WithOperation(operation),
apperrors.WithOrigin("shortcut_registry"),
apperrors.WithFailureStage("capability_gate"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
apperrors.WithReason("write_fixture_unavailable"),
)
}
+154 -175
View File
@@ -1,226 +1,205 @@
// 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.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Licensed under the Apache License, Version 2.0
// Package ding holds the built-in DING (钉) shortcuts. Tool names and params are
// copied verbatim from internal/helpers/ding.go. Some tools route to the "im"
// MCP server (helper uses callMCPToolOnServer("im", ...)), reflected in Product.
// Package ding registers strict declarative shortcuts for DingTalk DING.
package ding
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
var List = shortcut.Shortcut{
Service: "ding", Command: "+list", Product: "im",
Description: "查询 DING 消息列表", Intent: "当你想查看当前身份收到或发出的 DING 消息、回顾有哪些强提醒或获取某条 DING 的 openDingId 以便后续查已读或撤回时使用;可选按类型过滤并用 cursor 翻页,只读不产生副作用。", Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "ding",
Name: "shortcut_list",
CanonicalPath: "ding.shortcut_list",
CLIPath: "ding +list",
PrimaryCLIPath: "ding +list",
},
Description: "查询 DING 消息列表",
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: "查询 DING 消息列表",
UseWhen: []string{"当你想查看当前身份收到或发出的 DING 消息、回顾有哪些强提醒或获取某条 DING 的 openDingId 以便后续查已读或撤回时使用;可选按类型过滤并用 cursor 翻页,只读不产生副作用。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws ding +list"},
},
},
Description: "查询 DING 消息列表",
Intent: "需要查看 DING 历史、取得稳定 openDingId,或按 ALL、UNREAD、SEND、NEW_COMMENT、DELETED 类型读取一页时使用。",
Risk: shortcut.RiskRead,
Safety: dingReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: dingContract(
"+list", "查询 DING 消息列表",
"需要查看 DING 历史、取得稳定 openDingId,或按 ALL、UNREAD、SEND、NEW_COMMENT、DELETED 类型读取一页时使用。",
true, dingListResult(), dingPagination(),
[]contract.ParamDecl{{Name: "cursor", Property: "cursor"}, {Name: "type", Property: "type"}},
"dws ding +list --type ALL", "dws ding +list --type DELETED",
),
Flags: []shortcut.Flag{
{Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标 (可选)"},
{Name: "type", Type: shortcut.FlagString, Default: "ALL", Desc: "类型 (可选,默认 ALL)"},
{Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标,首次不传或传 0"},
{Name: "type", Type: shortcut.FlagString, Default: "ALL", Desc: "DING 类型:ALL、UNREAD、SEND、NEW_COMMENT 或 DELETED"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"cursor"}, Description: "--cursor 不能小于 0,续页响应必须返回严格前进的正整数 nextCursor"}},
Tips: []string{"dws ding +list --type ALL", "dws ding +list --type DELETED"},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("cursor") < 0 {
return apperrors.NewValidation("--cursor 不能小于 0")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
// type 是服务端必填,空值会报错;不传或传空时兜底为 ALL(对齐 helper)。
msgType := rt.Str("type")
if msgType == "" {
msgType = "ALL"
}
params := map[string]any{"type": msgType}
params := map[string]any{"type": rt.Str("type")}
if rt.Changed("cursor") {
params["cursor"] = rt.Int("cursor")
}
return rt.CallMCP("list_ding_messages", params)
data, err := rt.CallMCPData("im", "list_ding_messages", params)
if err != nil {
return err
}
messages, page, err := dingProjectMessages(data, "im/list_ding_messages")
if err != nil {
return err
}
if page.HasMore {
current := int64(rt.Int("cursor"))
next, parseErr := strconv.ParseInt(page.Next, 10, 64)
if parseErr != nil || next <= current {
return dingResponseError("im/list_ding_messages", "stalled_cursor", "nextCursor 没有严格前进")
}
}
return outputDingPage(rt, messages, page)
},
}
var ReceiverStatus = shortcut.Shortcut{
Service: "ding", Command: "+receiver-status", Product: "im",
Description: "查询 DING 消息接收人已读状态", Intent: "当你发出一条 DING 后想确认每位接收人是否已读、追踪谁还没看到以便催办时使用;需提供该 DING 的 openDingId,返回各接收人的已读/未读状态,只读操作。", Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "ding",
Name: "shortcut_receiver_status",
CanonicalPath: "ding.shortcut_receiver_status",
CLIPath: "ding +receiver-status",
PrimaryCLIPath: "ding +receiver-status",
},
Description: "查询 DING 消息接收人已读状态",
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: "查询 DING 消息接收人已读状态",
UseWhen: []string{"当你发出一条 DING 后想确认每位接收人是否已读、追踪谁还没看到以便催办时使用;需提供该 DING 的 openDingId,返回各接收人的已读/未读状态,只读操作。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws ding +receiver-status --ding-id <DING_ID>"},
},
},
Flags: []shortcut.Flag{
{Name: "ding-id", Type: shortcut.FlagString, Desc: "openDingId", Required: true},
},
Description: "查询 DING 消息接收人已读状态",
Intent: "已经从 +list 取得稳定 openDingId,需要确认该 DING 的接收状态时使用;这是精确 ID 查询,不是消息搜索。",
Risk: shortcut.RiskRead,
Safety: dingReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: dingContract(
"+receiver-status", "查询 DING 消息接收人已读状态",
"已经从 +list 取得稳定 openDingId,需要确认该 DING 的接收状态时使用;这是精确 ID 查询,不是消息搜索。",
true, dingReceiverResult(), nil,
[]contract.ParamDecl{{Name: "ding-id", Property: "dingId"}},
"dws ding +receiver-status --ding-id <DING_ID>",
),
Flags: []shortcut.Flag{{Name: "ding-id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}},
Tips: []string{"dws ding +receiver-status --ding-id <DING_ID>"},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("list_ding_receiver_status", map[string]any{
"openDingId": rt.Str("ding-id"),
})
const operation = "im/list_ding_receiver_status"
data, err := rt.CallMCPData("im", "list_ding_receiver_status", map[string]any{"openDingId": rt.Str("ding-id")})
if err != nil {
return err
}
receivers, err := dingProjectReceivers(data, operation, rt.Str("ding-id"))
if err != nil {
return err
}
payload := map[string]any{"count": len(receivers), "receivers": receivers}
if !output.UsesUnifiedResult(rt.Command()) {
return rt.Output(payload)
}
meta := &output.Meta{Count: output.NewCount(len(receivers))}
return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta)))
},
}
var SendPersonal = shortcut.Shortcut{
Service: "ding", Command: "+send-personal", Product: "im",
Description: "以本人身份发送 DING 给指定人", Intent: "当你想以自己(而非机器人)的身份直接给某些同事发 DING 强提醒,让对方看到是本人发起时使用;需提供接收人的 openDingTalkId 列表和内容,可选提醒方式与幂等 uuid,会真实向这些人发出 DING。", Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "ding",
Name: "shortcut_send_personal",
CanonicalPath: "ding.shortcut_send_personal",
CLIPath: "ding +send-personal",
PrimaryCLIPath: "ding +send-personal",
},
Description: "以本人身份发送 DING 给指定人",
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: "以本人身份发送 DING 给指定人",
UseWhen: []string{"当你想以自己(而非机器人)的身份直接给某些同事发 DING 强提醒,让对方看到是本人发起时使用;需提供接收人的 openDingTalkId 列表和内容,可选提醒方式与幂等 uuid,会真实向这些人发出 DING。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws ding +send-personal --users <VALUES> --content <CONTENT>"},
},
},
Flags: []shortcut.Flag{
var SendPersonal = compatibilityDingWrite(
"+send-personal", "以本人身份发送 DING 给指定人",
"仅为历史 CLI 兼容保留:明确确认后按原有参数发送;因接收人稳定身份与撤回终态尚不可验证,不进入 Agent 公共发现。",
shortcut.RiskWrite, false,
[]shortcut.Flag{
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "消息内容", Required: true},
{Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式 remindType"},
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键 (可选)"},
{Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式:app、sms 或 call"},
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键"},
},
Execute: func(rt *shortcut.RuntimeContext) error {
[]contract.ParamDecl{
{Name: "users", Property: "users", InterfaceType: "array"},
{Name: "content", Property: "content"}, {Name: "type", Property: "type"}, {Name: "uuid", Property: "uuid"},
},
"dws ding +send-personal --users <VALUES> --content <CONTENT>",
func(rt *shortcut.RuntimeContext) error {
remindType, err := dingPersonalRemindType(rt.Str("type"))
if err != nil {
return err
}
params := map[string]any{
"receiverOpenDingTalkIds": rt.StrSlice("users"),
"content": rt.Str("content"),
"remindType": rt.Str("type"),
"remindType": remindType,
}
if rt.Changed("uuid") {
params["uuid"] = rt.Str("uuid")
}
return rt.CallMCP("send_personal_ding", params)
},
}
)
var SendByMessage = shortcut.Shortcut{
Service: "ding", Command: "+send-by-message", Product: "im",
Description: "针对某条消息发起 DING 提醒", Intent: "当群里已有一条聊天消息需要被重点跟进,你想直接把它变成 DING 去强提醒相关人(而不是另写新内容)时使用;需提供群 openConversationId、该消息 openMessageId 和接收人 openDingTalkId 列表,会真实基于这条消息发出 DING。", Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
var SendByMessage = unavailableDingWrite(
"+send-by-message", "针对某条消息发起 DING 提醒",
"需要把指定聊天消息转成应用内、短信或电话 DING 时使用;对应 lark-cli 的三种 urgent 任务,但接收人稳定身份与精确撤回终态仍缺失。",
shortcut.RiskWrite, false,
[]shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "openConversationId", Required: true},
{Name: "message-id", Type: shortcut.FlagString, Desc: "openMessageId", Required: true},
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true},
{Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式 remindType"},
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键 (可选)"},
{Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式", Enum: []string{"app", "sms", "call"}},
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键"},
},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"openConversationId": rt.Str("group"),
"openMessageId": rt.Str("message-id"),
"receiverOpenDingTalkIds": rt.StrSlice("users"),
"remindType": rt.Str("type"),
}
if rt.Changed("uuid") {
params["uuid"] = rt.Str("uuid")
}
return rt.CallMCP("send_ding_by_message", params)
[]contract.ParamDecl{
{Name: "group", Property: "openConversationId"}, {Name: "message-id", Property: "openMessageId"},
{Name: "users", Property: "receiverOpenDingTalkIds", InterfaceType: "array"},
{Name: "type", Property: "remindType"}, {Name: "uuid", Property: "uuid"},
},
"dws ding +send-by-message --group <GROUP_ID> --message-id <MESSAGE_ID> --users <VALUES>",
)
var RecallPersonal = compatibilityDingWrite(
"+recall-personal", "撤回本人发起的 DING",
"仅为历史 CLI 兼容保留:明确确认后按稳定 openDingId 撤回;因终态与残留通知尚不可查询验证,不进入 Agent 公共发现。",
shortcut.RiskHighWrite, true,
[]shortcut.Flag{{Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}},
[]contract.ParamDecl{{Name: "id", Property: "id"}},
"dws ding +recall-personal --id <DING_ID>",
func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("recall_personal_ding", map[string]any{"openDingId": rt.Str("id")})
},
)
func compatibilityDingWrite(command, description, intent string, risk shortcut.Risk, destructive bool, flags []shortcut.Flag, params []contract.ParamDecl, example string, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut {
declaration := dingContract(command, description, intent, true, nil, nil, params, example)
declaration.Interface.Reason = dingCompatibilityWriteReason
return shortcut.Shortcut{
Service: "ding", Command: command, Product: "im",
Description: description, Intent: intent, Risk: risk,
Safety: dingWriteSafety(destructive), OutputRollout: output.RolloutUnifiedActive,
Contract: declaration,
Flags: flags, Tips: []string{example}, Execute: execute,
}
}
var RecallPersonal = shortcut.Shortcut{
Service: "ding", Command: "+recall-personal", Product: "im",
Description: "撤回本人发起的 DING", Intent: "当你以本人身份发出的某条 DING 发错人或内容有误、想收回时使用(对应 send-personal/send-by-message 发出的 DING);需提供该 DING 的 openDingId,会真实撤回它,接收人将不再看到该提醒。", Risk: shortcut.RiskHighWrite,
Safety: contract.SafetySpec{
Effect: "destructive", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "ding",
Name: "shortcut_recall_personal",
CanonicalPath: "ding.shortcut_recall_personal",
CLIPath: "ding +recall-personal",
PrimaryCLIPath: "ding +recall-personal",
func unavailableDingWrite(command, description, intent string, risk shortcut.Risk, destructive bool, flags []shortcut.Flag, params []contract.ParamDecl, example string) shortcut.Shortcut {
return shortcut.Shortcut{
Service: "ding", Command: command, Product: "im",
Description: description, Intent: intent, Risk: risk,
Safety: dingWriteSafety(destructive), OutputRollout: output.RolloutUnifiedActive,
Contract: dingContract(command, description, intent, false, nil, nil, params, example),
Flags: flags, Tips: []string{example},
Execute: func(*shortcut.RuntimeContext) error {
return dingUnavailable("ding/" + command)
},
Description: "撤回本人发起的 DING",
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: "撤回本人发起的 DING",
UseWhen: []string{"当你以本人身份发出的某条 DING 发错人或内容有误、想收回时使用(对应 send-personal/send-by-message 发出的 DING);需提供该 DING 的 openDingId,会真实撤回它,接收人将不再看到该提醒。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws ding +recall-personal --id <ID>"},
},
},
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true},
},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("recall_personal_ding", map[string]any{
"openDingId": rt.Str("id"),
})
},
}
}
func dingPersonalRemindType(value string) (string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "app":
return "APP", nil
case "sms":
return "SMS", nil
case "call":
return "PHONE", nil
default:
return "", apperrors.NewValidation("--type 必须是 app、sms 或 call")
}
}
func init() {
shortcut.Register(
List,
ReceiverStatus,
SendPersonal,
SendByMessage,
RecallPersonal,
)
shortcut.Register(List, ReceiverStatus, SendPersonal, SendByMessage, RecallPersonal)
}
@@ -0,0 +1,434 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package ding
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"math"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type dingCoverageCaller struct {
responses map[string][]string
failures map[string]error
history []string
arguments []map[string]any
}
func (caller *dingCoverageCaller) CallTool(_ context.Context, _, tool string, arguments map[string]any) (*edition.ToolResult, error) {
caller.history = append(caller.history, tool)
caller.arguments = append(caller.arguments, arguments)
if err := caller.failures[tool]; err != nil {
return nil, err
}
queue := caller.responses[tool]
if len(queue) == 0 {
return nil, errors.New("missing DING fake response for " + tool)
}
caller.responses[tool] = queue[1:]
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil
}
func (*dingCoverageCaller) Format() string { return "json" }
func (*dingCoverageCaller) DryRun() bool { return false }
func (*dingCoverageCaller) Fields() string { return "" }
func (*dingCoverageCaller) JQ() string { return "" }
func runDingCoverage(t *testing.T, declaration shortcut.Shortcut, caller *dingCoverageCaller, args ...string) (*cobra.Command, error) {
t.Helper()
helpers.InitDepsForTest(t, caller)
cmd := corecmd.New(shortcut.FromShortcut(declaration))
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
return cmd, cmd.Execute()
}
func runDingCoverageOutput(t *testing.T, declaration shortcut.Shortcut, caller *dingCoverageCaller, args ...string) ([]byte, error) {
t.Helper()
helpers.InitDepsForTest(t, caller)
cmd := corecmd.New(shortcut.FromShortcut(declaration))
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
return stdout.Bytes(), err
}
func TestCrossPlatformCoverageDINGContractsAreStrictTypedAndUnified(t *testing.T) {
for _, declaration := range []shortcut.Shortcut{List, ReceiverStatus, SendPersonal, SendByMessage, RecallPersonal} {
if declaration.Contract.Empty() {
t.Errorf("%s lacks Contract", declaration.Command)
}
if declaration.Safety.Effect == "" || declaration.Safety.Confirmation == "" {
t.Errorf("%s lacks Safety", declaration.Command)
}
if declaration.OutputRollout != output.RolloutUnifiedActive {
t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout)
}
}
for _, declaration := range []shortcut.Shortcut{List, ReceiverStatus} {
if declaration.Contract.Result == nil {
t.Errorf("%s lacks Result", declaration.Command)
}
if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" {
t.Errorf("%s is not interface-available", declaration.Command)
}
}
for _, declaration := range []shortcut.Shortcut{SendPersonal, RecallPersonal} {
if declaration.Safety.Confirmation != "user_required" || declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" {
t.Errorf("%s compatibility write safety/interface drift", declaration.Command)
}
}
if SendByMessage.Safety.Confirmation != "user_required" || SendByMessage.Contract.Interface == nil || SendByMessage.Contract.Interface.Availability != "unavailable" {
t.Errorf("%s unavailable write safety/interface drift", SendByMessage.Command)
}
if List.Contract.Pagination == nil {
t.Fatal("+list lacks cursor pagination")
}
}
func TestCrossPlatformCoverageDINGListResponseMatrix(t *testing.T) {
valid := map[string]any{
"success": true,
"result": map[string]any{
"dingMessages": []any{map[string]any{"openDingId": "ding-1", "dingContent": "fixture"}},
"hasMore": true,
"nextCursor": float64(2),
},
}
messages, page, err := dingProjectMessages(valid, "im/list_ding_messages")
if err != nil || len(messages) != 1 || !page.HasMore || page.Next != "2" || messages[0]["content"] != "fixture" {
t.Fatalf("valid list projection messages=%#v page=%+v err=%v", messages, page, err)
}
empty := map[string]any{"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": false, "nextCursor": nil}}
if messages, page, err := dingProjectMessages(empty, "im/list_ding_messages"); err != nil || len(messages) != 0 || page.HasMore {
t.Fatalf("explicit terminal empty page messages=%#v page=%+v err=%v", messages, page, err)
}
fixtures := map[string]map[string]any{
"empty": {},
"missing success": {"result": map[string]any{"dingMessages": []any{}, "hasMore": false}},
"wrong success": {"success": "true", "result": map[string]any{"dingMessages": []any{}, "hasMore": false}},
"false success": {"success": false, "errorMsg": "fixture failure", "result": map[string]any{"dingMessages": []any{}, "hasMore": false}},
"missing result": {"success": true},
"missing collection": {"success": true, "result": map[string]any{"hasMore": false}},
"wrong collection": {"success": true, "result": map[string]any{"dingMessages": map[string]any{}, "hasMore": false}},
"bad item": {"success": true, "result": map[string]any{"dingMessages": []any{"bad"}, "hasMore": false}},
"missing item id": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"dingContent": "fixture"}}, "hasMore": false}},
"duplicate item id": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "same"}, map[string]any{"openDingId": "same"}}, "hasMore": false}},
"missing pagination": {"success": true, "result": map[string]any{"dingMessages": []any{}}},
"wrong pagination": {"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": "false"}},
"empty continuation": {"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": true, "nextCursor": float64(2)}},
"missing next cursor": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "ding-1"}}, "hasMore": true}},
"wrong next cursor": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "ding-1"}}, "hasMore": true, "nextCursor": "2"}},
"conflicting terminal": {"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": false, "nextCursor": float64(2)}},
"wrong optional content": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "ding-1", "dingContent": float64(1)}}, "hasMore": false}},
}
for name, fixture := range fixtures {
if projected, _, projectErr := dingProjectMessages(fixture, "im/list_ding_messages"); projectErr == nil {
t.Errorf("%s returned success: %#v", name, projected)
}
}
if _, _, err := dingProjectMessages(map[string]any{"success": false, "errorMsg": "fixture failure"}, "im/list_ding_messages"); err == nil || !strings.Contains(err.Error(), "fixture failure") {
t.Fatalf("remote failure message was not preserved: %v", err)
}
}
func TestCrossPlatformCoverageDINGIntegerRepresentations(t *testing.T) {
for name, test := range map[string]struct {
value any
want int64
}{
"float64": {value: float64(1), want: 1},
"int": {value: int(2), want: 2},
"int64": {value: int64(3), want: 3},
"json number": {value: json.Number("4"), want: 4},
} {
t.Run(name, func(t *testing.T) {
if got, ok := dingInteger(test.value); !ok || got != test.want {
t.Fatalf("dingInteger(%T(%v))=(%d,%t), want (%d,true)", test.value, test.value, got, ok, test.want)
}
})
}
for name, value := range map[string]any{
"nan": math.NaN(),
"fraction": 1.5,
"overflow": math.MaxFloat64,
"bad json number": json.Number("not-an-integer"),
"wrong type": "1",
} {
t.Run(name, func(t *testing.T) {
if got, ok := dingInteger(value); ok {
t.Fatalf("dingInteger(%T(%v))=(%d,true), want invalid", value, value, got)
}
})
}
}
func TestCrossPlatformCoverageDINGReceiverResponseMatrix(t *testing.T) {
valid := map[string]any{"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": float64(1), "receiverNick": "fixture"}}}}
items, err := dingProjectReceivers(valid, "im/list_ding_receiver_status", "ding-1")
if err != nil || len(items) != 1 || items[0]["confirmedStatus"] != int64(1) {
t.Fatalf("valid receiver projection=%#v err=%v", items, err)
}
for name, fixture := range map[string]map[string]any{
"empty": {},
"missing collection": {"success": true, "result": map[string]any{}},
"empty collection": {"success": true, "result": map[string]any{"receivers": []any{}}},
"bad item": {"success": true, "result": map[string]any{"receivers": []any{"bad"}}},
"missing item id": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"confirmedStatus": float64(1), "receiverNick": "fixture"}}}},
"identity mismatch": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "other", "confirmedStatus": float64(1), "receiverNick": "fixture"}}}},
"wrong status": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": "1", "receiverNick": "fixture"}}}},
"missing receiver": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": float64(1)}}}},
} {
if projected, projectErr := dingProjectReceivers(fixture, "im/list_ding_receiver_status", "ding-1"); projectErr == nil {
t.Errorf("%s returned success: %#v", name, projected)
}
}
}
func TestCrossPlatformCoverageDINGListExecutionFailuresPaginationAndLegacy(t *testing.T) {
t.Run("negative cursor validates before call", func(t *testing.T) {
caller := &dingCoverageCaller{responses: map[string][]string{}}
if _, err := runDingCoverage(t, List, caller, "--cursor", "-1"); err == nil || len(caller.history) != 0 {
t.Fatalf("negative cursor error=%v calls=%v", err, caller.history)
}
})
t.Run("transport failure", func(t *testing.T) {
caller := &dingCoverageCaller{
responses: map[string][]string{},
failures: map[string]error{"list_ding_messages": errors.New("fixture transport failure")},
}
if _, err := runDingCoverage(t, List, caller); err == nil || strings.Join(caller.history, ",") != "list_ding_messages" {
t.Fatalf("transport error=%v calls=%v", err, caller.history)
}
})
t.Run("projection failure", func(t *testing.T) {
caller := &dingCoverageCaller{responses: map[string][]string{
"list_ding_messages": {`{"success":true,"result":{"dingMessages":[],"hasMore":true,"nextCursor":2}}`},
}}
if _, err := runDingCoverage(t, List, caller); err == nil {
t.Fatal("malformed empty continuation returned success")
}
})
t.Run("stalled cursor", func(t *testing.T) {
caller := &dingCoverageCaller{responses: map[string][]string{
"list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1"}],"hasMore":true,"nextCursor":2}}`},
}}
if _, err := runDingCoverage(t, List, caller, "--cursor", "2"); err == nil || !strings.Contains(err.Error(), "nextCursor") {
t.Fatalf("stalled cursor error=%v", err)
}
})
t.Run("advancing cursor legacy output", func(t *testing.T) {
legacy := List
legacy.OutputRollout = output.RolloutLegacyOnly
caller := &dingCoverageCaller{responses: map[string][]string{
"list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1"}],"hasMore":true,"nextCursor":3}}`},
}}
stdout, err := runDingCoverageOutput(t, legacy, caller, "--cursor", "2")
if err != nil {
t.Fatalf("legacy advancing page: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout, &payload); err != nil || payload["nextCursor"] != "3" || payload["complete"] != false {
t.Fatalf("legacy payload=%#v decode=%v", payload, err)
}
if len(caller.arguments) != 1 || caller.arguments[0]["cursor"] != 2 {
t.Fatalf("legacy cursor arguments=%#v", caller.arguments)
}
})
t.Run("invalid unified page evidence", func(t *testing.T) {
cmd := corecmd.New(shortcut.FromShortcut(List))
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
rt := shortcut.RuntimeContextForTest(cmd, List)
if err := outputDingPage(rt, nil, dingPageEvidence{HasMore: true}); err == nil {
t.Fatal("continuation without next cursor returned success")
}
})
}
func TestCrossPlatformCoverageDINGReceiverExecutionFailuresAndLegacy(t *testing.T) {
t.Run("transport failure", func(t *testing.T) {
caller := &dingCoverageCaller{
responses: map[string][]string{},
failures: map[string]error{"list_ding_receiver_status": errors.New("fixture transport failure")},
}
if _, err := runDingCoverage(t, ReceiverStatus, caller, "--ding-id", "ding-1"); err == nil {
t.Fatal("receiver transport failure returned success")
}
})
t.Run("projection failure", func(t *testing.T) {
caller := &dingCoverageCaller{responses: map[string][]string{
"list_ding_receiver_status": {`{"success":true,"result":{"receivers":[]}}`},
}}
if _, err := runDingCoverage(t, ReceiverStatus, caller, "--ding-id", "ding-1"); err == nil {
t.Fatal("empty receiver response returned success")
}
})
t.Run("legacy output", func(t *testing.T) {
legacy := ReceiverStatus
legacy.OutputRollout = output.RolloutLegacyOnly
caller := &dingCoverageCaller{responses: map[string][]string{
"list_ding_receiver_status": {`{"success":true,"result":{"receivers":[{"openDingId":"ding-1","confirmedStatus":1,"receiverNick":"fixture"}]}}`},
}}
stdout, err := runDingCoverageOutput(t, legacy, caller, "--ding-id", "ding-1")
if err != nil {
t.Fatalf("receiver legacy output: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout, &payload); err != nil || payload["count"] != float64(1) {
t.Fatalf("receiver legacy payload=%#v decode=%v", payload, err)
}
if len(caller.arguments) != 1 || caller.arguments[0]["openDingId"] != "ding-1" {
t.Fatalf("receiver arguments=%#v", caller.arguments)
}
})
}
func TestCrossPlatformCoverageDINGExactReadShortcutsProjectUnifiedData(t *testing.T) {
listCaller := &dingCoverageCaller{responses: map[string][]string{
"list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1","dingContent":"fixture"}],"hasMore":false,"nextCursor":null}}`},
}}
cmd, err := runDingCoverage(t, List, listCaller, "--type", "ALL")
if err != nil {
t.Fatal(err)
}
var stdout bytes.Buffer
cmd.SetOut(&stdout)
if code, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted || code != 0 {
t.Fatalf("emit=(%d,%t,%v)", code, emitted, emitErr)
}
var envelope map[string]any
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
data := envelope["data"].(map[string]any)
if _, leaked := data["result"]; leaked || data["count"] != float64(1) || data["complete"] != true {
t.Fatalf("list unified projection=%#v", data)
}
if strings.Join(listCaller.history, ",") != "list_ding_messages" {
t.Fatalf("list call history=%v", listCaller.history)
}
receiverCaller := &dingCoverageCaller{responses: map[string][]string{
"list_ding_receiver_status": {`{"success":true,"result":{"receivers":[{"openDingId":"ding-1","confirmedStatus":1,"receiverNick":"fixture"}]}}`},
}}
if _, err := runDingCoverage(t, ReceiverStatus, receiverCaller, "--ding-id", "ding-1"); err != nil {
t.Fatal(err)
}
if strings.Join(receiverCaller.history, ",") != "list_ding_receiver_status" {
t.Fatalf("receiver call history=%v", receiverCaller.history)
}
}
func TestCrossPlatformCoverageDINGUnavailableWritesNeverCallMCP(t *testing.T) {
args := []string{"--group", "cid-fixture", "--message-id", "mid-fixture", "--users", "D-fixture"}
unconfirmed := &dingCoverageCaller{responses: map[string][]string{}}
err := runDingRoot(t, SendByMessage, unconfirmed, false, args...)
if err == nil || len(unconfirmed.history) != 0 {
t.Fatalf("unconfirmed error=%v calls=%v", err, unconfirmed.history)
}
confirmed := &dingCoverageCaller{responses: map[string][]string{}}
err = runDingRoot(t, SendByMessage, confirmed, true, args...)
if err == nil || !strings.Contains(err.Error(), "当前不可执行") || len(confirmed.history) != 0 {
t.Fatalf("confirmed unavailable error=%v calls=%v", err, confirmed.history)
}
}
func TestCrossPlatformCoverageDINGCompatibilityWritesRequireConfirmationAndExecute(t *testing.T) {
for input, want := range map[string]string{"app": "APP", " sms ": "SMS", "call": "PHONE"} {
got, err := dingPersonalRemindType(input)
if err != nil || got != want {
t.Errorf("dingPersonalRemindType(%q)=(%q,%v), want (%q,nil)", input, got, err, want)
}
}
sendArgs := []string{"--users", "D-fixture", "--content", "fixture", "--type", "call", "--uuid", "fixture-uuid"}
unconfirmedSend := &dingCoverageCaller{responses: map[string][]string{}}
if err := runDingRoot(t, SendPersonal, unconfirmedSend, false, sendArgs...); err == nil || len(unconfirmedSend.history) != 0 {
t.Fatalf("unconfirmed send error=%v calls=%v", err, unconfirmedSend.history)
}
confirmedSend := &dingCoverageCaller{responses: map[string][]string{
"send_personal_ding": {`{"success":true,"result":{"openDingId":"ding-fixture"}}`},
}}
if err := runDingRoot(t, SendPersonal, confirmedSend, true, sendArgs...); err != nil {
t.Fatalf("confirmed send failed: %v", err)
}
if strings.Join(confirmedSend.history, ",") != "send_personal_ding" || len(confirmedSend.arguments) != 1 {
t.Fatalf("confirmed send calls=%v args=%v", confirmedSend.history, confirmedSend.arguments)
}
sendParams := confirmedSend.arguments[0]
users, _ := sendParams["receiverOpenDingTalkIds"].([]string)
if strings.Join(users, ",") != "D-fixture" || sendParams["content"] != "fixture" || sendParams["remindType"] != "PHONE" || sendParams["uuid"] != "fixture-uuid" {
t.Fatalf("confirmed send params=%#v", sendParams)
}
invalidType := &dingCoverageCaller{responses: map[string][]string{}}
if err := runDingRoot(t, SendPersonal, invalidType, true, "--users", "D-fixture", "--content", "fixture", "--type", "other"); err == nil || len(invalidType.history) != 0 {
t.Fatalf("invalid type error=%v calls=%v", err, invalidType.history)
}
recallArgs := []string{"--id", "ding-fixture"}
unconfirmedRecall := &dingCoverageCaller{responses: map[string][]string{}}
if err := runDingRoot(t, RecallPersonal, unconfirmedRecall, false, recallArgs...); err == nil || len(unconfirmedRecall.history) != 0 {
t.Fatalf("unconfirmed recall error=%v calls=%v", err, unconfirmedRecall.history)
}
confirmedRecall := &dingCoverageCaller{responses: map[string][]string{
"recall_personal_ding": {`{"success":true,"result":true}`},
}}
if err := runDingRoot(t, RecallPersonal, confirmedRecall, true, recallArgs...); err != nil {
t.Fatalf("confirmed recall failed: %v", err)
}
if strings.Join(confirmedRecall.history, ",") != "recall_personal_ding" || confirmedRecall.arguments[0]["openDingId"] != "ding-fixture" {
t.Fatalf("confirmed recall calls=%v args=%v", confirmedRecall.history, confirmedRecall.arguments)
}
}
func runDingRoot(t *testing.T, declaration shortcut.Shortcut, caller *dingCoverageCaller, yes bool, args ...string) error {
t.Helper()
helpers.InitDepsForTest(t, caller)
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
ctx, _ := output.WithResultStore(context.Background())
root.SetContext(ctx)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
service := &cobra.Command{Use: "ding"}
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
root.AddCommand(service)
argv := []string{"ding", declaration.Command}
argv = append(argv, args...)
if yes {
argv = append(argv, "--yes")
}
root.SetArgs(argv)
return root.Execute()
}
+2 -1
View File
@@ -33,7 +33,8 @@ func RequireWriteAcknowledgement(operation string, data map[string]any) error {
// RequirePermissionMutationAcknowledgement validates the target-level payload
// returned by add/remove_member_permission. The backend's top-level
// success=true is not sufficient: historically it also accompanied a
// resultMap that merely echoed nonexistent task UUIDs.
// resultMap that merely echoed nonexistent task UUIDs, and it does not prove
// that every requested task/member pair was changed.
func RequirePermissionMutationAcknowledgement(operation string, taskUUIDs, memberUIDs []string, data map[string]any) error {
if err := validateEnvelope(data); err != nil {
return err
@@ -26,6 +26,26 @@ func TestCrossPlatformCoverageMinutesWorkflowShapesRejectUnknownE2E(t *testing.T
}
}
func TestCrossPlatformCoverageMinutesPermissionMutationRequiresExactResultMap(t *testing.T) {
for _, data := range []map[string]any{
{"success": true, "result": map[string]any{}},
{"success": true, "result": map[string]any{"resultMap": map[string]any{}}},
{"success": true, "result": map[string]any{"resultMap": map[string]any{"u1": []any{}}}},
{"success": true, "result": map[string]any{"resultMap": map[string]any{"other": []any{"m1"}}}},
} {
if err := RequirePermissionMutationAcknowledgement("unshare", []string{"u1"}, []string{"m1"}, data); err == nil {
t.Fatalf("incomplete permission acknowledgement accepted: %#v", data)
}
}
valid := map[string]any{
"success": true,
"result": map[string]any{"resultMap": map[string]any{"u1": []any{"m1"}}},
}
if err := RequirePermissionMutationAcknowledgement("unshare", []string{"u1"}, []string{"m1"}, valid); err != nil {
t.Fatalf("valid permission acknowledgement rejected: %v", err)
}
}
func TestCrossPlatformCoverageMinutesWorkflowObservedShapesE2E(t *testing.T) {
record, err := RecordResult("pause", "u1", map[string]any{"success": true, "result": map[string]any{"cmd": "pause", "uuid": "u1"}})
if err != nil || record["cmd"] != "pause" {
+415
View File
@@ -0,0 +1,415 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package oa
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const oaCompositeReason = "Reviewed OA Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable identity checks, truthful pagination, output projection, and confirmation; no single MCP interface represents the complete command contract."
type oaPageEvidence struct {
Known bool
HasMore bool
Next string
}
func oaCollectionResult(collection, description string) *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(fmt.Sprintf(
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前响应中的有效审批记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格验证后的 OA 审批业务记录","additionalProperties":true}},"complete":{"type":"boolean","description":"服务端分页证据是否证明结果完整"}},"required":["count",%q,"complete"],"additionalProperties":true}`,
description, collection, description, collection,
)),
SensitivePaths: []string{collection + ".title", collection + ".originatorName", collection + ".formValueVOS", collection + ".userId"},
}
}
func oaObjectResult(description string) *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(fmt.Sprintf(
`{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格验证且身份匹配的 OA 审批对象","additionalProperties":true}},"required":["value"],"additionalProperties":false}`,
description,
)),
SensitivePaths: []string{"value.title", "value.processInstanceTitle", "value.formValueVOS", "value.originatorUserid", "value.operationRecords", "value.userId"},
}
}
func oaWriteResult(description string) *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(fmt.Sprintf(
`{"type":"object","description":%q,"properties":{"processInstanceId":{"type":"string","description":"被处理审批实例的稳定 ID"},"taskId":{"type":"string","description":"被处理审批任务的稳定 ID"},"verified":{"type":"boolean","description":"写后精确任务读回是否证明目标任务不再待处理"}},"required":["processInstanceId","taskId","verified"],"additionalProperties":false}`,
description,
)),
SensitivePaths: []string{"processInstanceId", "taskId"},
}
}
func oaReadSafety() contract.SafetySpec {
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
}
func oaWriteSafety() contract.SafetySpec {
return contract.SafetySpec{Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"}
}
func oaContract(command, description, intent string, available bool, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
cliPath := "oa " + command
availability := contract.InterfaceUnavailable
interfaceReason := strings.TrimSpace(intent)
if available {
availability = contract.InterfaceAvailable
interfaceReason = oaCompositeReason
}
return corecmd.ContractDecl{
Description: description,
Result: result,
Pagination: pagination,
Parameters: params,
Identity: contract.ToolIdentitySpec{
ProductID: "oa", Name: name, CanonicalPath: "oa." + name,
CLIPath: cliPath, PrimaryCLIPath: cliPath,
},
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: availability, Reason: interfaceReason},
Selection: contract.SelectionSpec{
AgentSummary: description,
UseWhen: []string{intent},
AvoidWhen: []string{"只需原始 OA MCP 响应时使用 oa approval 原子命令;缺少稳定实例、任务或表单身份时不要猜测"},
Examples: examples,
},
}
}
func oaPagePagination(parameter string) *contract.PaginationSpec {
return &contract.PaginationSpec{
Kind: contract.PaginationKindCursor,
CursorParameter: parameter,
MetaPath: contract.PaginationMetaPath,
EndpointExhaustedPath: contract.PaginationExhaustedPath,
NextTokenPath: contract.PaginationNextTokenPath,
}
}
func oaResponseError(operation, reason, message string) error {
return apperrors.NewAPI(message,
apperrors.WithOperation(operation),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("response_validation"),
apperrors.WithRetryable(false),
apperrors.WithReason(reason),
)
}
func oaPostWriteError(operation, reason, message string) error {
return apperrors.NewAPI(message,
apperrors.WithOperation(operation),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("write_verification"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithReason(reason),
)
}
// oaRequireSuccess accepts only the two success encodings observed from the
// OA backend. Missing, false, null, and all other encodings fail closed.
func oaRequireSuccess(data map[string]any, operation string) error {
if len(data) == 0 {
return oaResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明 OA 调用成功或结果确实为空")
}
switch value := data["success"].(type) {
case bool:
if value {
return nil
}
case string:
if value == "true" {
return nil
}
}
if _, present := data["success"]; !present {
return oaResponseError(operation, "missing_success", "OA 响应缺少 success 业务状态")
}
message := oaFirstString(data, "errorMessage", "errorMsg", "message", "error")
if message == "" {
message = "OA 服务未明确返回成功状态"
}
return oaResponseError(operation, "remote_failure", message)
}
func oaLookup(object map[string]any, path string) (any, bool) {
var current any = object
for _, segment := range strings.Split(path, ".") {
mapped, ok := current.(map[string]any)
if !ok {
return nil, false
}
current, ok = mapped[segment]
if !ok {
return nil, false
}
}
return current, true
}
func oaRequireObject(data map[string]any, operation, path string) (map[string]any, error) {
if err := oaRequireSuccess(data, operation); err != nil {
return nil, err
}
value, present := oaLookup(data, path)
if !present || value == nil {
return nil, oaResponseError(operation, "missing_result", fmt.Sprintf("成功响应缺少非空 %s 对象", path))
}
object, ok := value.(map[string]any)
if !ok || len(object) == 0 {
return nil, oaResponseError(operation, "malformed_result", fmt.Sprintf("响应 %s 应为非空对象,实际为 %T", path, value))
}
return object, nil
}
func oaRequireCollection(data map[string]any, operation, path string) ([]map[string]any, error) {
if err := oaRequireSuccess(data, operation); err != nil {
return nil, err
}
value, present := oaLookup(data, path)
if !present {
return nil, oaResponseError(operation, "missing_collection", fmt.Sprintf("成功响应缺少 %s 数组;不能把未知响应结构当作空结果", path))
}
raw, ok := value.([]any)
if !ok {
return nil, oaResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value))
}
items := make([]map[string]any, 0, len(raw))
for index, item := range raw {
object, ok := item.(map[string]any)
if !ok || len(object) == 0 {
return nil, oaResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index))
}
items = append(items, object)
}
return items, nil
}
func oaFirstString(object map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := object[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func oaScalarString(value any) string {
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
case json.Number:
return typed.String()
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
case int:
return strconv.Itoa(typed)
case int64:
return strconv.FormatInt(typed, 10)
default:
return ""
}
}
func oaIdentity(object map[string]any, keys ...string) string {
for _, key := range keys {
if value := oaScalarString(object[key]); value != "" {
return value
}
}
return ""
}
func oaRequireIdentity(object map[string]any, operation, expected string, keys ...string) error {
actual := oaIdentity(object, keys...)
if actual == "" {
return oaResponseError(operation, "missing_stable_id", "OA 业务对象缺少稳定 ID")
}
if expected != "" && actual != expected {
return oaResponseError(operation, "identity_mismatch", "OA 业务对象 ID 与请求目标不一致")
}
return nil
}
func oaProjectForms(data map[string]any, operation, path string) ([]map[string]any, error) {
items, err := oaRequireCollection(data, operation, path)
if err != nil {
return nil, err
}
forms := make([]map[string]any, 0, len(items))
for index, item := range items {
code := oaIdentity(item, "processCode")
name := oaFirstString(item, "processName", "name")
if code == "" || name == "" {
return nil, oaResponseError(operation, "malformed_item", fmt.Sprintf("表单结果第 %d 项缺少 processCode 或名称", index))
}
row := map[string]any{"processCode": code, "name": name}
if icon := oaFirstString(item, "processIconUrl", "iconUrl"); icon != "" {
row["iconUrl"] = icon
}
forms = append(forms, row)
}
return forms, nil
}
func oaProjectInstances(data map[string]any, operation, path string) ([]map[string]any, error) {
items, err := oaRequireCollection(data, operation, path)
if err != nil {
return nil, err
}
instances := make([]map[string]any, 0, len(items))
for index, item := range items {
id := oaIdentity(item, "processInstanceId")
if id == "" {
return nil, oaResponseError(operation, "missing_item_identity", fmt.Sprintf("审批结果第 %d 项缺少 processInstanceId", index))
}
row := map[string]any{"processInstanceId": id}
if title := oaFirstString(item, "title", "processInstanceTitle"); title != "" {
row["title"] = title
}
if status := oaScalarString(item["status"]); status != "" {
row["status"] = status
}
if businessID := oaIdentity(item, "businessId"); businessID != "" {
row["businessId"] = businessID
}
if originator := oaFirstString(item, "originatorName", "originatorUserName"); originator != "" {
row["originatorName"] = originator
}
for _, key := range []string{"processCreateTime", "createTime"} {
if value, present := item[key]; present && value != nil {
row["createTime"] = value
break
}
}
instances = append(instances, row)
}
return instances, nil
}
func oaProjectTasks(data map[string]any, operation string) ([]map[string]any, error) {
items, err := oaRequireCollection(data, operation, "result.taskIdList")
if err != nil {
return nil, err
}
tasks := make([]map[string]any, 0, len(items))
seen := make(map[string]struct{}, len(items))
for index, item := range items {
id := oaIdentity(item, "taskId")
if id == "" {
return nil, oaResponseError(operation, "missing_item_identity", fmt.Sprintf("审批任务第 %d 项缺少 taskId", index))
}
if _, duplicate := seen[id]; duplicate {
return nil, oaResponseError(operation, "duplicate_item_identity", "审批任务响应包含重复 taskId")
}
seen[id] = struct{}{}
tasks = append(tasks, map[string]any{"taskId": id})
}
return tasks, nil
}
func oaCursorPage(result map[string]any, operation string, current int) (oaPageEvidence, error) {
raw, present := result["hasMore"]
if !present {
return oaPageEvidence{}, oaResponseError(operation, "missing_pagination", "游标响应缺少 hasMore;不能把当前页当作完整结果")
}
hasMore, ok := raw.(bool)
if !ok {
return oaPageEvidence{}, oaResponseError(operation, "malformed_pagination", "OA 响应 hasMore 应为布尔值")
}
page := oaPageEvidence{Known: true, HasMore: hasMore}
next := oaScalarString(result["nextCursor"])
if !hasMore {
if next != "" {
return oaPageEvidence{}, oaResponseError(operation, "conflicting_pagination", "hasMore=false 但 nextCursor 非空")
}
return page, nil
}
if next == "" {
return oaPageEvidence{}, oaResponseError(operation, "missing_next_cursor", "hasMore=true 但缺少 nextCursor")
}
nextValue, err := strconv.Atoi(next)
if err != nil {
return oaPageEvidence{}, oaResponseError(operation, "malformed_pagination", "nextCursor 应为整数")
}
if nextValue <= current {
return oaPageEvidence{}, oaResponseError(operation, "stalled_cursor", "nextCursor 没有严格前进")
}
page.Next = next
return page, nil
}
func oaHasMorePage(result map[string]any, operation string, currentPage int) (oaPageEvidence, error) {
raw, present := result["hasMore"]
if !present {
return oaPageEvidence{}, oaResponseError(operation, "missing_pagination", "审批列表缺少 hasMore,无法证明结果完整或提供续页凭据")
}
hasMore, ok := raw.(bool)
if !ok {
return oaPageEvidence{}, oaResponseError(operation, "malformed_pagination", "OA 响应 hasMore 应为布尔值")
}
page := oaPageEvidence{Known: true, HasMore: hasMore}
if hasMore {
if currentPage <= 0 {
return oaPageEvidence{}, oaResponseError(operation, "invalid_page", "当前页码无效,无法生成下一页凭据")
}
page.Next = strconv.Itoa(currentPage + 1)
}
return page, nil
}
func outputOAPage(rt *shortcut.RuntimeContext, collection string, items []map[string]any, page oaPageEvidence) error {
if !page.Known {
return oaResponseError("oa/pagination", "missing_pagination", "响应缺少分页终态证据")
}
payload := map[string]any{"count": len(items), collection: items, "complete": !page.HasMore}
if !output.UsesUnifiedResult(rt.Command()) {
if page.HasMore {
payload["nextPage"] = page.Next
}
return rt.Output(payload)
}
pagination, err := output.NewPagination(!page.HasMore, page.Next)
if err != nil {
return oaResponseError("oa/pagination", "invalid_pagination", err.Error())
}
meta := &output.Meta{Count: output.NewCount(len(items)), Pagination: pagination}
return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta)))
}
func outputOACompleteCollection(rt *shortcut.RuntimeContext, collection string, items []map[string]any) error {
payload := map[string]any{"count": len(items), collection: items, "complete": true}
if !output.UsesUnifiedResult(rt.Command()) {
return rt.Output(payload)
}
meta := &output.Meta{Count: output.NewCount(len(items))}
return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta)))
}
func validateOAPage(page, limit int) error {
if page <= 0 {
return apperrors.NewValidation("--page 必须大于 0")
}
if limit <= 0 || limit > 100 {
return apperrors.NewValidation("--limit 必须在 1 到 100 之间")
}
return nil
}
@@ -1,82 +0,0 @@
// 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.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package oa
import (
"encoding/json"
"testing"
)
// TestListFormsProjectProcessCodeListShape guards against the projection-data-loss
// class: list_user_visible_process nests the forms under result.processCodeList.
// The resolver MUST probe that exact key — otherwise the whole list silently
// projects to empty (exit 0, no error envelope) while the backend has data.
func TestListFormsProjectProcessCodeListShape(t *testing.T) {
// Faithful list_user_visible_process shape (as returned by the backend).
const raw = `{"result":{"processCodeList":[
{"processCode":"PROC-1","processName":"leave","dirName":"attendance"},
{"processCode":"PROC-2","processName":"check-in fix","dirName":"attendance"},
{"processCode":"PROC-3","processName":"overtime","dirName":"attendance"}
],"totalCount":-1},"success":true}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
forms := listFormsProject(data)
if len(forms) != 3 {
t.Fatalf("lower/upper mismatch: 3 forms in backend, projection returned %d (forms=%v)", len(forms), forms)
}
for _, f := range forms {
if f["processCode"] == nil || f["name"] == nil {
t.Fatalf("projected form missing processCode/name: %v", f)
}
}
}
// TestListFormsProjectBareArrayShape ensures a bare top-level array still works.
func TestListFormsProjectBareArrayShape(t *testing.T) {
const raw = `{"result":[
{"processCode":"PROC-9","processName":"generic approval"}
]}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if forms := listFormsProject(data); len(forms) != 1 {
t.Fatalf("bare array shape: want 1 form, got %d (%v)", len(forms), forms)
}
}
// TestOAInstanceResolveListValuesShape guards the shared resolver behind
// +list-pending / +list-executed / +list-submitted / +list-cc. Those approval
// instance tools nest the list under result.values; the resolver must probe
// "values" or all four commands silently return empty despite backend records.
func TestOAInstanceResolveListValuesShape(t *testing.T) {
const raw = `{"result":{"hasMore":false,"values":[
{"processInstanceId":"i-1","title":"user leave"},
{"processInstanceId":"i-2","title":"user reimbursement"}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if got := oaInstanceResolveList(data); len(got) != 2 {
t.Fatalf("lower/upper mismatch: result.values has 2 entries, resolver returned %d", len(got))
}
// End-to-end through a real projection that uses the shared resolver.
if instances := listSubmittedProject(data); len(instances) != 2 {
t.Fatalf("listSubmittedProject: want 2, got %d (%v)", len(instances), instances)
}
}
+188 -537
View File
@@ -1,59 +1,73 @@
// 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.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Licensed under the Apache License, Version 2.0
// Package oa registers declarative shortcuts for the DingTalk OA approval
// service, wrapping the raw MCP tools exposed by the dws oa helper.
// Package oa registers strict declarative shortcuts for DingTalk OA approval.
package oa
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"fmt"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
// ListPending — 查询待我处理的审批 (list_pending_approvals)
func parseOAStringPage(rt *shortcut.RuntimeContext, name string, fallback int) (int, error) {
raw := rt.Str(name)
if raw == "" {
return fallback, nil
}
value, err := strconv.Atoi(raw)
if err != nil {
return 0, apperrors.NewValidation(fmt.Sprintf("--%s 必须是整数", name))
}
return value, nil
}
func oaInstancePage(rt *shortcut.RuntimeContext, tool string, params map[string]any, page int) error {
operation := "oa/" + tool
data, err := rt.CallMCPData("oa", tool, params)
if err != nil {
return err
}
instances, err := oaProjectInstances(data, operation, "result.values")
if err != nil {
return err
}
result, _ := data["result"].(map[string]any)
evidence, err := oaHasMorePage(result, operation, page)
if err != nil {
return err
}
return outputOAPage(rt, "instances", instances, evidence)
}
var ListPending = shortcut.Shortcut{
Service: "oa",
Command: "+list-pending",
Product: "oa",
Description: "查询待我处理的审批(时间范围为 epoch 毫秒)",
Intent: "当你想知道自己当前有哪些审批还没处理、需要清理审批待办或按时间段/关键字盘点待审批单时使用;传入起止时间(epoch 毫秒,可选关键字与分页),返回待我审批的实例列表,是后续 +get 查详情、+approve/+reject 处理的入口。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "shortcut_list_pending",
CanonicalPath: "oa.shortcut_list_pending",
CLIPath: "oa +list-pending",
PrimaryCLIPath: "oa +list-pending",
Service: "oa", Command: "+list-pending", Product: "oa",
Description: "查询待我处理的审批(时间范围为 epoch 毫秒)",
Intent: "按 epoch 毫秒时间范围查询当前用户待处理审批;只有显式成功、严格实例数组与可续页证据齐全时才返回结果。",
Risk: shortcut.RiskRead,
Safety: oaReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: oaContract(
"+list-pending",
"查询待我处理的审批(时间范围为 epoch 毫秒)",
"需要按时间范围读取待我审批的实例,并取得稳定 processInstanceId 时使用;没有安全非空待办 fixture 前不会进入公开发现。",
true,
oaCollectionResult("instances", "严格验证的待处理审批实例页"),
oaPagePagination("page"),
[]contract.ParamDecl{
{Name: "start", Property: "start"},
{Name: "end", Property: "end"},
{Name: "page", Property: "page"},
{Name: "limit", Property: "limit"},
{Name: "query", Property: "query"},
},
Description: "查询待我处理的审批(时间范围为 epoch 毫秒)",
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: "查询待我处理的审批(时间范围为 epoch 毫秒)",
UseWhen: []string{"当你想知道自己当前有哪些审批还没处理、需要清理审批待办或按时间段/关键字盘点待审批单时使用;传入起止时间(epoch 毫秒,可选关键字与分页),返回待我审批的实例列表,是后续 +get 查详情、+approve/+reject 处理的入口。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws oa +list-pending --start 1741536000000 --end 1741622399000 --query 报销"},
},
},
"dws oa +list-pending --start 1741536000000 --end 1741622399000 --page 1 --limit 20",
),
Flags: []shortcut.Flag{
{Name: "start", Type: shortcut.FlagInt, Desc: "开始时间(epoch 毫秒)", Required: true},
{Name: "end", Type: shortcut.FlagInt, Desc: "结束时间(epoch 毫秒)", Required: true},
@@ -61,12 +75,25 @@ var ListPending = shortcut.Shortcut{
{Name: "limit", Type: shortcut.FlagString, Desc: "每页大小"},
{Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"},
},
Tips: []string{`dws oa +list-pending --start 1741536000000 --end 1741622399000 --query 报销`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"starTime": rt.Int("start"),
"endTime": rt.Int("end"),
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end", "page", "limit"}, Description: "--start/--end 必须是递增的正整数 epoch 毫秒;--page 必须大于 0;--limit 必须在 1-100"}},
Tips: []string{`dws oa +list-pending --start 1741536000000 --end 1741622399000 --page 1 --limit 20`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("start") <= 0 || rt.Int("end") <= rt.Int("start") {
return apperrors.NewValidation("--start/--end 必须是递增的正整数 epoch 毫秒范围")
}
page, err := parseOAStringPage(rt, "page", 1)
if err != nil {
return err
}
limit, err := parseOAStringPage(rt, "limit", 20)
if err != nil {
return err
}
return validateOAPage(page, limit)
},
Execute: func(rt *shortcut.RuntimeContext) error {
page, _ := parseOAStringPage(rt, "page", 1)
params := map[string]any{"starTime": rt.Int("start"), "endTime": rt.Int("end")}
if rt.Changed("page") {
params["pageNum"] = rt.Str("page")
}
@@ -76,530 +103,154 @@ var ListPending = shortcut.Shortcut{
if rt.Changed("query") {
params["query"] = rt.Str("query")
}
data, err := rt.CallMCPData("oa", "list_pending_approvals", params)
if err != nil {
return err
}
instances := listPendingProject(data)
return rt.Output(map[string]any{"count": len(instances), "instances": instances})
return oaInstancePage(rt, "list_pending_approvals", params, page)
},
}
// listPendingProject reshapes the raw list_pending_approvals response into the
// same clean {processInstanceId, title, status, createTime} approval list as
// +list-executed/+list-submitted, so all approval-instance listings project
// identically. It reuses the shared oaInstance* defensive probes, tolerating
// response-shape and key-spelling drift.
func listPendingProject(data map[string]any) []map[string]any {
raw := oaInstanceResolveList(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
if row := oaInstanceProjectItem(m); len(row) > 0 {
out = append(out, row)
}
}
return out
}
// Detail — 获取审批实例详情 (get_processInstance_detail)
// Approve — 同意审批 (approve_processInstance)
// Reject — 拒绝审批 (reject_processInstance)
// Revoke — 撤销已发起的审批 (revoke_processInstance)
// Records — 获取审批操作记录 (get_processInstance_records)
// ListInitiated — 查询审批模板下已发起的审批记录 (list_initiated_instances)
// ListTasks — 查询待我审批的任务 ID (list_pending_tasks)
// ListForms — 获取当前用户可见的审批表单列表 (list_user_visible_process)
var ListForms = shortcut.Shortcut{
Service: "oa",
Command: "+list-forms",
Product: "oa",
Description: "获取当前用户可见的审批表单列表",
Intent: "当你想浏览自己有权限发起哪些审批模板、或需要为 +list-initiated 等操作枚举 processCode 时使用;无需关键字,按游标分页返回当前用户可见的全部审批表单,适合不确定表单名称时先整体看一遍。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "shortcut_list_forms",
CanonicalPath: "oa.shortcut_list_forms",
CLIPath: "oa +list-forms",
PrimaryCLIPath: "oa +list-forms",
},
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{"当你想浏览自己有权限发起哪些审批模板、或需要为 +list-initiated 等操作枚举 processCode 时使用;无需关键字,按游标分页返回当前用户可见的全部审批表单,适合不确定表单名称时先整体看一遍。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws oa +list-forms --cursor 0 --limit 100"},
},
},
Service: "oa", Command: "+list-forms", Product: "oa",
Description: "获取当前用户可见的审批表单列表",
Intent: "按服务端游标读取当前用户可发起的审批表单;缺少 hasMore/nextCursor 或游标不前进时失败,不把重复首页宣称为完整列表。",
Risk: shortcut.RiskRead,
Safety: oaReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: oaContract(
"+list-forms", "获取当前用户可见的审批表单列表",
"需要枚举可发起审批定义并取得稳定 processCode 时使用;当前下游不返回可验证 continuation,故不进入 Agent 公开发现。",
true,
oaCollectionResult("forms", "严格验证的可见审批表单页"), oaPagePagination("cursor"),
[]contract.ParamDecl{{Name: "cursor", Property: "cursor"}, {Name: "limit", Property: "limit"}},
"dws oa +list-forms --cursor 0 --limit 100",
),
Flags: []shortcut.Flag{
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"},
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页大小,最大 100"},
},
Tips: []string{`dws oa +list-forms --cursor 0 --limit 100`},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"cursor", "limit"}, Description: "--cursor 不能小于 0;--limit 必须在 1-100"}},
Tips: []string{`dws oa +list-forms --cursor 0 --limit 100`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("cursor") < 0 {
return apperrors.NewValidation("--cursor 不能小于 0")
}
if rt.Int("limit") <= 0 || rt.Int("limit") > 100 {
return apperrors.NewValidation("--limit 必须在 1 到 100 之间")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
data, err := rt.CallMCPData("oa", "list_user_visible_process", map[string]any{
"cursor": rt.Int("cursor"),
"pageSize": rt.Int("limit"),
})
const operation = "oa/list_user_visible_process"
data, err := rt.CallMCPData("oa", "list_user_visible_process", map[string]any{"cursor": rt.Int("cursor"), "pageSize": rt.Int("limit")})
if err != nil {
return err
}
forms := listFormsProject(data)
return rt.Output(map[string]any{"count": len(forms), "forms": forms})
forms, err := oaProjectForms(data, operation, "result.processCodeList")
if err != nil {
return err
}
result, _ := data["result"].(map[string]any)
page, err := oaCursorPage(result, operation, rt.Int("cursor"))
if err != nil {
return err
}
return outputOAPage(rt, "forms", forms, page)
},
}
// listFormsProject reshapes the raw list_user_visible_process response into a
// clean approval-form list ({processCode, name, iconUrl}) — the output-projection
// fidelity the framework applies to every list command. The list container and
// per-item field names are probed defensively across candidate keys so the
// projection tolerates response-shape drift rather than crashing or fabricating.
func listFormsProject(data map[string]any) []map[string]any {
raw := oaFormResolveList(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
if row := oaFormProjectItem(m); len(row) > 0 {
out = append(out, row)
}
}
return out
}
// oaFormResolveList locates the form list inside a response, tolerating a bare
// top-level array or nesting one level under a common envelope key.
func oaFormResolveList(data map[string]any) []any {
if data == nil {
return []any{}
}
// list_user_visible_process nests the forms under result.processCodeList;
// probing must include that exact key (both at the top level in case the
// envelope is already unwrapped, and one level deeper under result/data) or
// the whole list silently projects to empty.
for _, key := range []string{"result", "data", "list", "items", "processList", "processCodeList", "forms"} {
v, ok := data[key]
if !ok {
continue
}
if arr, ok := v.([]any); ok {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "processList", "processCodeList", "forms", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
}
}
}
return []any{}
}
// oaFormProjectItem picks the stable identity/label fields of a single approval
// form, probing candidate key spellings so it survives snake/camel drift.
func oaFormProjectItem(m map[string]any) map[string]any {
row := map[string]any{}
if v, ok := oaFormFirst(m, "processCode", "process_code", "code"); ok {
row["processCode"] = v
}
if v, ok := oaFormFirst(m, "name", "processName", "process_name", "flowTitle"); ok {
row["name"] = v
}
if v, ok := oaFormFirst(m, "iconUrl", "icon_url", "iconName"); ok {
row["iconUrl"] = v
}
return row
}
// oaFormFirst returns the first present candidate key's value.
func oaFormFirst(m map[string]any, keys ...string) (any, bool) {
for _, k := range keys {
if v, ok := m[k]; ok {
return v, true
}
}
return nil, false
}
// SearchForms — 按关键字模糊搜索可见审批表单 (search_form)
var SearchForms = shortcut.Shortcut{
Service: "oa",
Command: "+search-forms",
Product: "oa",
Description: "按关键字模糊搜索当前用户可见的审批表单",
Intent: "当你已知想找的审批大致名称(如「报销」「请假」)、想快速定位对应表单及其 processCode 时使用,比 +list-forms 全量列举更高效;传入关键字,返回名称或 processCode 匹配的表单,供后续 +list-initiated 按模板查询。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
Service: "oa", Command: "+search-forms", Product: "oa",
Description: "按关键字模糊搜索当前用户可见的审批表单",
Intent: "已知审批定义关键字,需要取得一个或多个稳定 processCode 时使用;要无条件遍历全部定义不要使用本命令。",
Risk: shortcut.RiskRead,
Safety: oaReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: oaContract(
"+search-forms", "按关键字模糊搜索当前用户可见的审批表单",
"已知审批定义关键字,需要取得一个或多个稳定 processCode 时使用;要无条件遍历全部定义不要使用本命令。",
true,
oaCollectionResult("forms", "严格验证的审批表单搜索结果"), nil,
[]contract.ParamDecl{{Name: "query", Property: "query"}},
"dws oa +search-forms --query 报销",
),
Flags: []shortcut.Flag{{Name: "query", Type: shortcut.FlagString, Desc: "关键字(匹配 processCode 或表单名称);去除空白后不能为空", Required: true}},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "--query 去除空白后不能为空"}},
Tips: []string{`dws oa +search-forms --query 报销`},
Validate: func(rt *shortcut.RuntimeContext) error {
if strings.TrimSpace(rt.Str("query")) == "" {
return apperrors.NewValidation("--query 不能为空")
}
return nil
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "shortcut_search_forms",
CanonicalPath: "oa.shortcut_search_forms",
CLIPath: "oa +search-forms",
PrimaryCLIPath: "oa +search-forms",
},
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{"当你已知想找的审批大致名称(如「报销」「请假」)、想快速定位对应表单及其 processCode 时使用,比 +list-forms 全量列举更高效;传入关键字,返回名称或 processCode 匹配的表单,供后续 +list-initiated 按模板查询。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws oa +search-forms --query 报销"},
},
},
Flags: []shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Desc: "关键字(匹配 processCode 或表单名称)", Required: true},
},
Tips: []string{`dws oa +search-forms --query 报销`},
Execute: func(rt *shortcut.RuntimeContext) error {
data, err := rt.CallMCPData("oa", "search_form", map[string]any{
"query": rt.Str("query"),
})
const operation = "oa/search_form"
data, err := rt.CallMCPData("oa", "search_form", map[string]any{"query": strings.TrimSpace(rt.Str("query"))})
if err != nil {
return err
}
forms := searchFormsProject(data)
return rt.Output(map[string]any{"count": len(forms), "forms": forms})
},
}
// searchFormsProject reshapes the raw search_form response into the same clean
// {processCode, name, iconUrl} list as +list-forms, so both approval-form
// listings project identically. It reuses the shared oaForm* defensive probes,
// tolerating response-shape and key-spelling drift.
func searchFormsProject(data map[string]any) []map[string]any {
raw := oaFormResolveList(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
if row := oaFormProjectItem(m); len(row) > 0 {
out = append(out, row)
}
}
return out
}
// DingInfo — 获取审批任务的被催办人 userId (oa_ding_user)
// ListExecuted — 获取当前用户已处理过的审批单列表 (get_done_tasks)
var ListExecuted = shortcut.Shortcut{
Service: "oa",
Command: "+list-executed",
Product: "oa",
Description: "获取当前用户已经处理过的审批单列表",
Intent: "当你想回顾自己历史上审批过(已同意/拒绝等)的单子、做复盘或查找某条已办审批时使用,区别于 +list-pending 的待办;按页码/关键字分页返回我已处理的审批单。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "shortcut_list_executed",
CanonicalPath: "oa.shortcut_list_executed",
CLIPath: "oa +list-executed",
PrimaryCLIPath: "oa +list-executed",
},
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{"当你想回顾自己历史上审批过(已同意/拒绝等)的单子、做复盘或查找某条已办审批时使用,区别于 +list-pending 的待办;按页码/关键字分页返回我已处理的审批单。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws oa +list-executed --limit 20 --page 1 --query 报销"},
},
},
Flags: []shortcut.Flag{
{Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"},
{Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"},
{Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"},
},
Tips: []string{`dws oa +list-executed --limit 20 --page 1 --query 报销`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"pageNumber": rt.Str("page"),
"pageSize": rt.Str("limit"),
}
if rt.Changed("query") {
params["query"] = rt.Str("query")
}
data, err := rt.CallMCPData("oa", "get_done_tasks", params)
forms, err := oaProjectForms(data, operation, "result")
if err != nil {
return err
}
instances := listExecutedProject(data)
return rt.Output(map[string]any{"count": len(instances), "instances": instances})
return outputOACompleteCollection(rt, "forms", forms)
},
}
// listExecutedProject reshapes the raw get_done_tasks response into a clean
// approval-instance list ({processInstanceId, title, status, createTime}) — the
// the clean output projection applied to every list command. The
// list container and per-item field names are probed defensively across
// candidate keys so the projection tolerates response-shape drift rather than
// crashing or fabricating data.
func listExecutedProject(data map[string]any) []map[string]any {
raw := oaInstanceResolveList(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
if row := oaInstanceProjectItem(m); len(row) > 0 {
out = append(out, row)
}
}
return out
}
// oaInstanceResolveList locates the approval-instance list inside a response,
// tolerating a bare top-level array or nesting one level under a common
// envelope key.
func oaInstanceResolveList(data map[string]any) []any {
if data == nil {
return []any{}
}
// The approval instance tools (list_pending_approvals, get_done_tasks,
// get_submitted_instances, get_noticed_instances) all nest the instance list
// under result.values; "values" MUST be in the probe set or every one of
// +list-pending / +list-executed / +list-submitted / +list-cc silently
// projects to empty despite the backend returning records.
for _, key := range []string{"result", "data", "list", "items", "values", "instances", "tasks"} {
v, ok := data[key]
if !ok {
continue
}
if arr, ok := v.([]any); ok {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "values", "instances", "tasks", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
}
}
}
return []any{}
}
// oaInstanceProjectItem picks the stable identity/label fields of a single
// approval instance, probing candidate key spellings so it survives
// snake/camel drift.
func oaInstanceProjectItem(m map[string]any) map[string]any {
row := map[string]any{}
if v, ok := oaFormFirst(m, "processInstanceId", "process_instance_id", "instanceId", "id"); ok {
row["processInstanceId"] = v
}
if v, ok := oaFormFirst(m, "title", "processTitle", "process_title", "name"); ok {
row["title"] = v
}
if v, ok := oaFormFirst(m, "status", "result", "processResult"); ok {
row["status"] = v
}
if v, ok := oaFormFirst(m, "createTime", "create_time", "gmtCreate", "createdTime"); ok {
row["createTime"] = v
}
return row
}
// ListSubmitted — 获取当前用户已发起的审批单列表 (get_submitted_instances)
var ListSubmitted = shortcut.Shortcut{
Service: "oa",
Command: "+list-submitted",
Product: "oa",
Description: "获取当前用户已发起的审批单列表",
Intent: "当你想查看自己提交发起的审批单及其审批进度(如某笔报销/请假审到哪一步)时使用;按页码/关键字分页返回我发起的审批单,可据此决定是否 +revoke 撤销或催办。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "shortcut_list_submitted",
CanonicalPath: "oa.shortcut_list_submitted",
CLIPath: "oa +list-submitted",
PrimaryCLIPath: "oa +list-submitted",
func oaNumberedInstanceShortcut(command, tool, description, intent string) shortcut.Shortcut {
declaration := shortcut.Shortcut{
Service: "oa", Command: command, Product: "oa",
Description: description, Intent: intent, Risk: shortcut.RiskRead,
Safety: oaReadSafety(), OutputRollout: output.RolloutUnifiedActive,
Contract: oaContract(command, description, intent, true,
oaCollectionResult("instances", description), oaPagePagination("page"),
[]contract.ParamDecl{{Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}, {Name: "query", Property: "query"}},
"dws oa "+command+" --page 1 --limit 20"),
Flags: []shortcut.Flag{
{Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码;--page 必须大于 0"},
{Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小;--limit 必须在 1-100"},
{Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"},
},
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.",
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100"},
},
Selection: contract.SelectionSpec{
AgentSummary: "获取当前用户已发起的审批单列表",
UseWhen: []string{"当你想查看自己提交发起的审批单及其审批进度(如某笔报销/请假审到哪一步)时使用;按页码/关键字分页返回我发起的审批单,可据此决定是否 +revoke 撤销或催办。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws oa +list-submitted --limit 20 --page 1 --query 报销"},
},
},
Flags: []shortcut.Flag{
{Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"},
{Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"},
{Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"},
},
Tips: []string{`dws oa +list-submitted --limit 20 --page 1 --query 报销`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"pageNumber": rt.Str("page"),
"pageSize": rt.Str("limit"),
}
if rt.Changed("query") {
params["query"] = rt.Str("query")
}
data, err := rt.CallMCPData("oa", "get_submitted_instances", params)
Tips: []string{"dws oa " + command + " --page 1 --limit 20"},
}
declaration.Validate = func(rt *shortcut.RuntimeContext) error {
page, err := parseOAStringPage(rt, "page", 1)
if err != nil {
return err
}
instances := listSubmittedProject(data)
return rt.Output(map[string]any{"count": len(instances), "instances": instances})
},
}
// listSubmittedProject reshapes the raw get_submitted_instances response into
// the same clean {processInstanceId, title, status, createTime} approval list
// as +list-executed, so both instance listings project identically. It reuses
// the shared oaInstance* defensive probes, tolerating response-shape and
// key-spelling drift.
func listSubmittedProject(data map[string]any) []map[string]any {
raw := oaInstanceResolveList(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
if row := oaInstanceProjectItem(m); len(row) > 0 {
out = append(out, row)
}
}
return out
}
// ListCc — 获取抄送当前用户的审批单列表 (get_noticed_instances)
var ListCc = shortcut.Shortcut{
Service: "oa",
Command: "+list-cc",
Product: "oa",
Description: "获取抄送当前用户的审批单列表",
Intent: "当你想查看抄送给自己、需要知悉但无需审批的单子时使用;按页码/关键字分页返回抄送我的审批单列表,适合了解与自己相关但不用自己动手处理的审批动态。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "shortcut_list_cc",
CanonicalPath: "oa.shortcut_list_cc",
CLIPath: "oa +list-cc",
PrimaryCLIPath: "oa +list-cc",
},
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{"当你想查看抄送给自己、需要知悉但无需审批的单子时使用;按页码/关键字分页返回抄送我的审批单列表,适合了解与自己相关但不用自己动手处理的审批动态。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws oa +list-cc --limit 20 --page 1 --query 报销"},
},
},
Flags: []shortcut.Flag{
{Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"},
{Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"},
{Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"},
},
Tips: []string{`dws oa +list-cc --limit 20 --page 1 --query 报销`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"pageNumber": rt.Str("page"),
"pageSize": rt.Str("limit"),
}
if rt.Changed("query") {
params["query"] = rt.Str("query")
}
data, err := rt.CallMCPData("oa", "get_noticed_instances", params)
limit, err := parseOAStringPage(rt, "limit", 20)
if err != nil {
return err
}
instances := listCcProject(data)
return rt.Output(map[string]any{"count": len(instances), "instances": instances})
},
}
// listCcProject reshapes the raw get_noticed_instances response into the same
// clean {processInstanceId, title, status, createTime} approval list as the
// other instance listings, so all approval-instance listings project
// identically. It reuses the shared oaInstance* defensive probes, tolerating
// response-shape and key-spelling drift.
func listCcProject(data map[string]any) []map[string]any {
raw := oaInstanceResolveList(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
if row := oaInstanceProjectItem(m); len(row) > 0 {
out = append(out, row)
}
return validateOAPage(page, limit)
}
return out
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
page, _ := parseOAStringPage(rt, "page", 1)
params := map[string]any{"pageNumber": rt.Str("page"), "pageSize": rt.Str("limit")}
if query := strings.TrimSpace(rt.Str("query")); query != "" {
params["query"] = query
}
return oaInstancePage(rt, tool, params, page)
}
return declaration
}
// RedirectTask — 转交审批任务给其他人 (redirect_task)
// Comment — 对审批实例添加评论 (dingflow_comments)
// CcNotice — 对审批实例进行抄送 (oa_cc_noticer)
// AppendTask — 对审批任务进行加签 (append_task)
// RevertActivities — 获取审批任务可回退的节点信息 (get_inst_revert_activities)
// RevertTask — 退回审批任务到指定节点 (revert_task)
var ListExecuted = oaNumberedInstanceShortcut(
"+list-executed", "get_done_tasks", "获取当前用户已经处理过的审批单列表",
"需要回顾当前用户已同意或拒绝过的审批实例时使用;与待办、已发起和抄送列表分开。",
)
var ListSubmitted = oaNumberedInstanceShortcut(
"+list-submitted", "get_submitted_instances", "获取当前用户已发起的审批单列表",
"需要查看当前用户发起的审批实例和当前状态时使用;返回稳定 processInstanceId 与可续页证据。",
)
var ListCc = oaNumberedInstanceShortcut(
"+list-cc", "get_noticed_instances", "获取抄送当前用户的审批单列表",
"需要查看抄送给当前用户的审批实例时使用;没有安全非空抄送 fixture 前不会进入公开发现。",
)
func init() {
shortcut.Register(
ListPending,
ListForms,
SearchForms,
ListExecuted,
ListSubmitted,
ListCc,
)
shortcut.Register(ListPending, ListForms, SearchForms, ListExecuted, ListSubmitted, ListCc)
}
@@ -0,0 +1,728 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package oa
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type oaCoverageCaller struct {
responses map[string][]string
history []string
arguments []map[string]any
}
func (caller *oaCoverageCaller) CallTool(_ context.Context, _, tool string, arguments map[string]any) (*edition.ToolResult, error) {
caller.history = append(caller.history, tool)
caller.arguments = append(caller.arguments, arguments)
queue := caller.responses[tool]
if len(queue) == 0 {
return nil, errors.New("missing OA fake response for " + tool)
}
caller.responses[tool] = queue[1:]
if queue[0] == "__ERROR__" {
return nil, errors.New("injected OA failure")
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil
}
func (*oaCoverageCaller) Format() string { return "json" }
func (*oaCoverageCaller) DryRun() bool { return false }
func (*oaCoverageCaller) Fields() string { return "" }
func (*oaCoverageCaller) JQ() string { return "" }
func runOACoverage(t *testing.T, declaration shortcut.Shortcut, caller *oaCoverageCaller, args ...string) (*cobra.Command, error) {
t.Helper()
helpers.InitDepsForTest(t, caller)
cmd := corecmd.New(shortcut.FromShortcut(declaration))
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
return cmd, cmd.Execute()
}
func runOAConfirmedCoverage(t *testing.T, caller *oaCoverageCaller, args ...string) error {
t.Helper()
helpers.InitDepsForTest(t, caller)
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
ctx, _ := output.WithResultStore(context.Background())
root.SetContext(ctx)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
service := &cobra.Command{Use: "oa"}
service.AddCommand(corecmd.New(shortcut.FromShortcut(Approve)))
root.AddCommand(service)
root.SetArgs(append([]string{"oa", "+approve-by"}, args...))
return root.Execute()
}
func TestCrossPlatformCoverageOAContractsAreTypedAndUnified(t *testing.T) {
for _, declaration := range []shortcut.Shortcut{
ListPending, ListForms, SearchForms, ListExecuted, ListSubmitted,
ListCc, PendingApprovals, DoneApprovals, Approve, MyInitiated,
} {
if declaration.Contract.Empty() || declaration.Contract.Result == nil {
t.Errorf("%s lacks Contract.Result", declaration.Command)
}
if declaration.Safety.Effect == "" || declaration.Safety.Confirmation == "" {
t.Errorf("%s lacks Safety", declaration.Command)
}
if declaration.OutputRollout != output.RolloutUnifiedActive {
t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout)
}
}
if Approve.Safety.Confirmation != "user_required" || Approve.Risk != shortcut.RiskHighWrite {
t.Fatal("+approve-by must require explicit high-risk confirmation")
}
unavailable := oaContract("+fixture-unavailable", "fixture unavailable", "reviewed fixture is unavailable", false, oaCollectionResult("items", "fixture items"), nil, nil)
if unavailable.Interface == nil || unavailable.Interface.Availability != "unavailable" || unavailable.Interface.Reason != "reviewed fixture is unavailable" {
t.Fatalf("unavailable OA contract interface=%+v", unavailable.Interface)
}
for _, declaration := range []shortcut.Shortcut{ListPending, ListForms, ListExecuted, ListSubmitted, ListCc, MyInitiated} {
if declaration.Contract.Pagination == nil {
t.Errorf("%s lacks Pagination", declaration.Command)
}
}
}
func TestCrossPlatformCoverageOAStrictResponseMatrix(t *testing.T) {
validEmpty := map[string]any{"success": "true", "result": map[string]any{"values": []any{}}}
items, err := oaProjectInstances(validEmpty, "oa/test", "result.values")
if err != nil || len(items) != 0 {
t.Fatalf("explicit empty must succeed: items=%v err=%v", items, err)
}
for name, fixture := range map[string]map[string]any{
"empty": {},
"missing success": {"result": map[string]any{"values": []any{}}},
"false success": {"success": false, "result": map[string]any{"values": []any{}}},
"missing collection": {"success": true, "result": map[string]any{}},
"wrong collection": {"success": true, "result": map[string]any{"values": map[string]any{}}},
"bad item": {"success": true, "result": map[string]any{"values": []any{"bad"}}},
"missing identity": {"success": true, "result": map[string]any{"values": []any{map[string]any{"title": "fixture"}}}},
} {
if projected, err := oaProjectInstances(fixture, "oa/test", "result.values"); err == nil {
t.Errorf("%s returned success: %#v", name, projected)
}
}
forms := map[string]any{"success": true, "result": map[string]any{"processCodeList": []any{map[string]any{"processCode": "p", "processName": "fixture"}}}}
if projected, err := oaProjectForms(forms, "oa/forms", "result.processCodeList"); err != nil || len(projected) != 1 {
t.Fatalf("strict forms projection=%#v err=%v", projected, err)
}
badForm := map[string]any{"success": true, "result": map[string]any{"processCodeList": []any{map[string]any{"processName": "fixture"}}}}
if projected, err := oaProjectForms(badForm, "oa/forms", "result.processCodeList"); err == nil {
t.Fatalf("form without processCode returned success: %#v", projected)
}
search := map[string]any{"success": true, "result": []any{map[string]any{"processCode": "p", "processName": "fixture"}}}
if projected, err := oaProjectForms(search, "oa/search", "result"); err != nil || len(projected) != 1 {
t.Fatalf("strict search projection=%#v err=%v", projected, err)
}
}
func TestCrossPlatformCoverageOAPaginationFailsClosed(t *testing.T) {
if _, err := oaHasMorePage(map[string]any{}, "oa/page", 1); err == nil {
t.Fatal("numbered page without hasMore was accepted")
}
if _, err := oaHasMorePage(map[string]any{"hasMore": "false"}, "oa/page", 1); err == nil {
t.Fatal("wrong hasMore type was accepted")
}
page, err := oaHasMorePage(map[string]any{"hasMore": true}, "oa/page", 2)
if err != nil || !page.HasMore || page.Next != "3" {
t.Fatalf("numbered continuation=%+v err=%v", page, err)
}
if _, err := oaCursorPage(map[string]any{}, "oa/cursor", 0); err == nil {
t.Fatal("cursor response without pagination was accepted")
}
if _, err := oaCursorPage(map[string]any{"hasMore": true}, "oa/cursor", 0); err == nil {
t.Fatal("hasMore without nextCursor was accepted")
}
if _, err := oaCursorPage(map[string]any{"hasMore": true, "nextCursor": float64(1)}, "oa/cursor", 1); err == nil {
t.Fatal("stalled cursor was accepted")
}
if _, err := oaCursorPage(map[string]any{"hasMore": true, "nextCursor": float64(1)}, "oa/cursor", 2); err == nil {
t.Fatal("backward cursor was accepted")
}
if _, err := oaCursorPage(map[string]any{"hasMore": true, "nextCursor": "not-a-number"}, "oa/cursor", 1); err == nil {
t.Fatal("non-integer cursor was accepted")
}
page, err = oaCursorPage(map[string]any{"hasMore": true, "nextCursor": float64(2)}, "oa/cursor", 1)
if err != nil || page.Next != "2" {
t.Fatalf("cursor continuation=%+v err=%v", page, err)
}
}
func TestCrossPlatformCoverageMyInitiatedNeverFallsBackToRawResponse(t *testing.T) {
caller := &oaCoverageCaller{responses: map[string][]string{
"get_submitted_instances": {`{"success":"true","result":{"values":[],"hasMore":false}}`},
}}
cmd, err := runOACoverage(t, MyInitiated, caller, "--page", "1", "--limit", "20")
if err != nil {
t.Fatal(err)
}
var stdout bytes.Buffer
cmd.SetOut(&stdout)
code, emitted, err := output.EmitStoredResult(cmd)
if err != nil || !emitted || code != 0 {
t.Fatalf("emit code=%d emitted=%v err=%v", code, emitted, err)
}
var envelope struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
if _, raw := envelope.Data["result"]; raw {
t.Fatalf("raw result leaked: %s", stdout.String())
}
initiated, ok := envelope.Data["initiated"].([]any)
if !ok || len(initiated) != 0 || envelope.Data["complete"] != true {
t.Fatalf("strict empty initiated payload=%#v", envelope.Data)
}
}
func TestCrossPlatformCoverageOAApproveConfirmationAndReadback(t *testing.T) {
unconfirmed := &oaCoverageCaller{responses: map[string][]string{}}
helpers.InitDepsForTest(t, unconfirmed)
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
ctx, _ := output.WithResultStore(context.Background())
root.SetContext(ctx)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
service := &cobra.Command{Use: "oa"}
service.AddCommand(corecmd.New(shortcut.FromShortcut(Approve)))
root.AddCommand(service)
root.SetArgs([]string{"oa", "+approve-by", "--keyword", "fixture"})
if err := root.Execute(); err == nil {
t.Fatal("unconfirmed approval unexpectedly succeeded")
}
if len(unconfirmed.history) != 0 {
t.Fatalf("unconfirmed approval made calls: %v", unconfirmed.history)
}
confirmed := &oaCoverageCaller{responses: map[string][]string{
"list_pending_approvals": {`{"success":true,"result":{"hasMore":false,"values":[{"processInstanceId":"instance-1","title":"fixture"}]}}`},
"list_pending_tasks": {
`{"success":true,"result":{"taskIdList":[{"taskId":1}]}}`,
`{"success":true,"result":{"taskIdList":[]}}`,
},
"approve_processInstance": {`{"success":true,"result":{"accepted":true}}`},
}}
helpers.InitDepsForTest(t, confirmed)
confirmedRoot := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
confirmedRoot.PersistentFlags().Bool("yes", false, "")
confirmedRoot.PersistentFlags().Bool("dry-run", false, "")
confirmedRoot.PersistentFlags().String("format", "json", "")
confirmedCtx, _ := output.WithResultStore(context.Background())
confirmedRoot.SetContext(confirmedCtx)
confirmedRoot.SetOut(io.Discard)
confirmedRoot.SetErr(io.Discard)
confirmedService := &cobra.Command{Use: "oa"}
confirmedService.AddCommand(corecmd.New(shortcut.FromShortcut(Approve)))
confirmedRoot.AddCommand(confirmedService)
confirmedRoot.SetArgs([]string{"oa", "+approve-by", "--keyword", "fixture", "--yes"})
if err := confirmedRoot.Execute(); err != nil {
t.Fatal(err)
}
if got := strings.Join(confirmed.history, ","); got != "list_pending_approvals,list_pending_tasks,approve_processInstance,list_pending_tasks" {
t.Fatalf("call history=%s", got)
}
if len(confirmed.arguments) != 4 || confirmed.arguments[2]["processInstanceId"] != "instance-1" || confirmed.arguments[3]["processInstanceId"] != "instance-1" {
t.Fatalf("identity was not preserved: %#v", confirmed.arguments)
}
}
func TestCrossPlatformCoverageOACommonHelperBranches(t *testing.T) {
if oaObjectResult("fixture") == nil || oaPostWriteError("oa/test", "fixture", "fixture") == nil {
t.Fatal("typed object result and post-write error must be constructed")
}
for name, fixture := range map[string]map[string]any{
"false with backend message": {"success": false, "errorMessage": "fixture"},
"false without message": {"success": false},
"null success": {"success": nil},
"wrong success string": {"success": "TRUE"},
} {
if err := oaRequireSuccess(fixture, "oa/test"); err == nil {
t.Errorf("%s was accepted", name)
}
}
if _, ok := oaLookup(map[string]any{"result": "bad"}, "result.values"); ok {
t.Fatal("lookup traversed a non-object")
}
if _, ok := oaLookup(map[string]any{"result": map[string]any{}}, "result.values"); ok {
t.Fatal("lookup accepted a missing segment")
}
for name, fixture := range map[string]map[string]any{
"nil object": {"success": true, "result": nil},
"wrong object": {"success": true, "result": "bad"},
"empty object": {"success": true, "result": map[string]any{}},
} {
if value, err := oaRequireObject(fixture, "oa/test", "result"); err == nil {
t.Errorf("%s returned object %#v", name, value)
}
}
if value, err := oaRequireObject(map[string]any{"success": false}, "oa/test", "result"); err == nil {
t.Fatalf("failed envelope returned object %#v", value)
}
object, err := oaRequireObject(map[string]any{"success": true, "result": map[string]any{"id": "fixture"}}, "oa/test", "result")
if err != nil || object["id"] != "fixture" {
t.Fatalf("valid object=%#v err=%v", object, err)
}
for name, value := range map[string]any{
"string": " fixture ",
"json number": json.Number("42"),
"float": float64(42),
"int": int(42),
"int64": int64(42),
"unsupported": true,
} {
got := oaScalarString(value)
if name == "unsupported" && got != "" {
t.Errorf("unsupported scalar=%q", got)
}
if name != "unsupported" && got == "" {
t.Errorf("%s scalar was empty", name)
}
}
identityFixture := map[string]any{"id": int64(42)}
if err := oaRequireIdentity(identityFixture, "oa/test", "42", "id"); err != nil {
t.Fatal(err)
}
if err := oaRequireIdentity(map[string]any{}, "oa/test", "", "id"); err == nil {
t.Fatal("missing identity was accepted")
}
if err := oaRequireIdentity(identityFixture, "oa/test", "43", "id"); err == nil {
t.Fatal("mismatched identity was accepted")
}
formsFixture := map[string]any{"success": true, "result": []any{map[string]any{
"processCode": "p", "name": "fixture", "iconUrl": "icon",
}}}
forms, err := oaProjectForms(formsFixture, "oa/forms", "result")
if err != nil || forms[0]["iconUrl"] != "icon" {
t.Fatalf("fallback form fields=%#v err=%v", forms, err)
}
instanceFixture := map[string]any{"success": true, "result": map[string]any{"values": []any{map[string]any{
"processInstanceId": "i", "processInstanceTitle": "title", "status": json.Number("2"),
"businessId": 7, "originatorUserName": "owner", "createTime": int64(9),
}}}}
instances, err := oaProjectInstances(instanceFixture, "oa/instances", "result.values")
if err != nil {
t.Fatal(err)
}
wantInstance := map[string]any{"processInstanceId": "i", "title": "title", "status": "2", "businessId": "7", "originatorName": "owner", "createTime": int64(9)}
if !reflect.DeepEqual(instances[0], wantInstance) {
t.Fatalf("projected instance=%#v want=%#v", instances[0], wantInstance)
}
for name, fixture := range map[string]map[string]any{
"task projection error": {"success": true, "result": map[string]any{}},
"missing task id": {"success": true, "result": map[string]any{"taskIdList": []any{map[string]any{"x": 1}}}},
"duplicate task id": {"success": true, "result": map[string]any{"taskIdList": []any{
map[string]any{"taskId": 1}, map[string]any{"taskId": 1},
}}},
} {
if tasks, err := oaProjectTasks(fixture, "oa/tasks"); err == nil {
t.Errorf("%s returned %#v", name, tasks)
}
}
for name, fixture := range map[string]map[string]any{
"wrong cursor hasMore": {"hasMore": "true"},
"terminal conflict": {"hasMore": false, "nextCursor": 1},
} {
if page, err := oaCursorPage(fixture, "oa/cursor", 0); err == nil {
t.Errorf("%s returned %+v", name, page)
}
}
terminal, err := oaCursorPage(map[string]any{"hasMore": false}, "oa/cursor", 0)
if err != nil || !terminal.Known || terminal.HasMore || terminal.Next != "" {
t.Fatalf("terminal cursor=%+v err=%v", terminal, err)
}
if _, err := oaHasMorePage(map[string]any{"hasMore": true}, "oa/page", 0); err == nil {
t.Fatal("continuation from invalid current page was accepted")
}
terminal, err = oaHasMorePage(map[string]any{"hasMore": false}, "oa/page", 1)
if err != nil || !terminal.Known || terminal.HasMore {
t.Fatalf("terminal numbered page=%+v err=%v", terminal, err)
}
if err := validateOAPage(0, 1); err == nil {
t.Fatal("zero page was accepted")
}
if err := validateOAPage(1, 0); err == nil {
t.Fatal("zero limit was accepted")
}
if err := validateOAPage(1, 101); err == nil {
t.Fatal("oversized limit was accepted")
}
}
func TestCrossPlatformCoverageOAOutputBranches(t *testing.T) {
legacyPage := shortcut.Shortcut{
Service: "oa", Command: "+coverage-legacy-page", Product: "oa", Description: "fixture", Risk: shortcut.RiskRead,
Execute: func(rt *shortcut.RuntimeContext) error {
return outputOAPage(rt, "items", []map[string]any{{"id": "1"}}, oaPageEvidence{Known: true, HasMore: true, Next: "2"})
},
}
_, err := runOACoverage(t, legacyPage, &oaCoverageCaller{responses: map[string][]string{}})
if err != nil {
t.Fatal(err)
}
unknownPage := legacyPage
unknownPage.Command = "+coverage-unknown-page"
unknownPage.Execute = func(rt *shortcut.RuntimeContext) error {
return outputOAPage(rt, "items", nil, oaPageEvidence{})
}
if _, err := runOACoverage(t, unknownPage, &oaCoverageCaller{responses: map[string][]string{}}); err == nil {
t.Fatal("unknown pagination was accepted")
}
legacyComplete := shortcut.Shortcut{
Service: "oa", Command: "+coverage-legacy-complete", Product: "oa", Description: "fixture", Risk: shortcut.RiskRead,
Execute: func(rt *shortcut.RuntimeContext) error { return outputOACompleteCollection(rt, "items", nil) },
}
if _, err := runOACoverage(t, legacyComplete, &oaCoverageCaller{responses: map[string][]string{}}); err != nil {
t.Fatal(err)
}
unifiedComplete := legacyComplete
unifiedComplete.Command = "+coverage-unified-complete"
unifiedComplete.OutputRollout = output.RolloutUnifiedActive
if _, err := runOACoverage(t, unifiedComplete, &oaCoverageCaller{responses: map[string][]string{}}); err != nil {
t.Fatal(err)
}
badUnifiedPage := legacyPage
badUnifiedPage.Command = "+coverage-unified-bad-page"
badUnifiedPage.OutputRollout = output.RolloutUnifiedActive
badUnifiedPage.Execute = func(rt *shortcut.RuntimeContext) error {
return outputOAPage(rt, "items", nil, oaPageEvidence{Known: true, HasMore: true})
}
if _, err := runOACoverage(t, badUnifiedPage, &oaCoverageCaller{responses: map[string][]string{}}); err == nil {
t.Fatal("unified continuation without token was accepted")
}
}
func TestCrossPlatformCoverageOAReadShortcutBranches(t *testing.T) {
expectError := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller {
t.Helper()
caller := &oaCoverageCaller{responses: responses}
if _, err := runOACoverage(t, declaration, caller, args...); err == nil {
t.Fatalf("%s unexpectedly succeeded with args %v", declaration.Command, args)
}
return caller
}
expectSuccess := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller {
t.Helper()
caller := &oaCoverageCaller{responses: responses}
if _, err := runOACoverage(t, declaration, caller, args...); err != nil {
t.Fatalf("%s failed with args %v: %v", declaration.Command, args, err)
}
return caller
}
validPage := `{"success":true,"result":{"hasMore":false,"values":[{"processInstanceId":"i","title":"fixture"}]}}`
for name, args := range map[string][]string{
"bad interval": {"--start", "2", "--end", "1"},
"bad page": {"--start", "1", "--end", "2", "--page", "bad"},
"bad limit": {"--start", "1", "--end", "2", "--limit", "bad"},
"zero page": {"--start", "1", "--end", "2", "--page", "0"},
} {
t.Run("list-pending validate "+name, func(t *testing.T) {
caller := expectError(t, ListPending, map[string][]string{}, args...)
if len(caller.history) != 0 {
t.Fatalf("validation made calls: %v", caller.history)
}
})
}
t.Run("list-pending call failure", func(t *testing.T) {
expectError(t, ListPending, map[string][]string{"list_pending_approvals": {"__ERROR__"}}, "--start", "1", "--end", "2")
})
t.Run("list-pending projection failure", func(t *testing.T) {
expectError(t, ListPending, map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"hasMore":false}}`}}, "--start", "1", "--end", "2")
})
t.Run("list-pending null result fails closed", func(t *testing.T) {
expectError(t, ListPending, map[string][]string{"list_pending_approvals": {`{"success":true,"result":null}`}}, "--start", "1", "--end", "2")
})
t.Run("list-pending pagination failure", func(t *testing.T) {
expectError(t, ListPending, map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"values":[]}}`}}, "--start", "1", "--end", "2")
})
t.Run("list-pending full params", func(t *testing.T) {
caller := expectSuccess(t, ListPending, map[string][]string{"list_pending_approvals": {validPage}},
"--start", "1", "--end", "2", "--page", "2", "--limit", "3", "--query", "fixture")
want := map[string]any{"starTime": 1, "endTime": 2, "pageNum": "2", "pageSize": "3", "query": "fixture"}
if !reflect.DeepEqual(caller.arguments[0], want) {
t.Fatalf("list pending params=%#v want=%#v", caller.arguments[0], want)
}
})
for name, args := range map[string][]string{
"negative cursor": {"--cursor", "-1"},
"zero limit": {"--limit", "0"},
"oversized limit": {"--limit", "101"},
} {
t.Run("list-forms validate "+name, func(t *testing.T) {
expectError(t, ListForms, map[string][]string{}, args...)
})
}
t.Run("list-forms call failure", func(t *testing.T) {
expectError(t, ListForms, map[string][]string{"list_user_visible_process": {"__ERROR__"}})
})
t.Run("list-forms projection failure", func(t *testing.T) {
expectError(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":{"hasMore":false}}`}})
})
t.Run("list-forms null result fails closed", func(t *testing.T) {
expectError(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":null}`}})
})
t.Run("list-forms pagination failure", func(t *testing.T) {
expectError(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":{"processCodeList":[]}}`}})
})
t.Run("list-forms success", func(t *testing.T) {
expectSuccess(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":{"hasMore":true,"nextCursor":2,"processCodeList":[{"processCode":"p","processName":"fixture"}]}}`}}, "--cursor", "1", "--limit", "2")
})
t.Run("search empty query", func(t *testing.T) {
caller := expectError(t, SearchForms, map[string][]string{}, "--query", " ")
if len(caller.history) != 0 {
t.Fatalf("blank query made calls: %v", caller.history)
}
})
t.Run("search call failure", func(t *testing.T) {
expectError(t, SearchForms, map[string][]string{"search_form": {"__ERROR__"}}, "--query", "fixture")
})
t.Run("search projection failure", func(t *testing.T) {
expectError(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":{}}`}}, "--query", "fixture")
})
t.Run("search known nonempty", func(t *testing.T) {
caller := expectSuccess(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":[{"processCode":"p","processName":"fixture"}]}`}}, "--query", " fixture ")
if got := caller.arguments[0]["query"]; got != "fixture" {
t.Fatalf("normalized query=%#v, want fixture", got)
}
})
t.Run("search legitimate empty", func(t *testing.T) {
expectSuccess(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":[]}`}}, "--query", "guaranteed-zero-fixture")
})
for _, declaration := range []shortcut.Shortcut{ListExecuted, ListSubmitted, ListCc} {
declaration := declaration
tool := map[string]string{"+list-executed": "get_done_tasks", "+list-submitted": "get_submitted_instances", "+list-cc": "get_noticed_instances"}[declaration.Command]
t.Run(declaration.Command+" invalid page", func(t *testing.T) {
expectError(t, declaration, map[string][]string{}, "--page", "bad")
})
t.Run(declaration.Command+" invalid limit", func(t *testing.T) {
expectError(t, declaration, map[string][]string{}, "--limit", "bad")
})
t.Run(declaration.Command+" call failure", func(t *testing.T) {
expectError(t, declaration, map[string][]string{tool: {"__ERROR__"}})
})
t.Run(declaration.Command+" full params", func(t *testing.T) {
caller := expectSuccess(t, declaration, map[string][]string{tool: {validPage}}, "--page", "2", "--limit", "3", "--query", " fixture ")
want := map[string]any{"pageNumber": "2", "pageSize": "3", "query": "fixture"}
if !reflect.DeepEqual(caller.arguments[0], want) {
t.Fatalf("params=%#v want=%#v", caller.arguments[0], want)
}
})
}
}
func TestCrossPlatformCoverageOACompatibilityReadBranches(t *testing.T) {
expectError := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller {
t.Helper()
caller := &oaCoverageCaller{responses: responses}
if _, err := runOACoverage(t, declaration, caller, args...); err == nil {
t.Fatalf("%s unexpectedly succeeded with args %v", declaration.Command, args)
}
return caller
}
expectSuccess := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller {
t.Helper()
caller := &oaCoverageCaller{responses: responses}
if _, err := runOACoverage(t, declaration, caller, args...); err != nil {
t.Fatalf("%s failed with args %v: %v", declaration.Command, args, err)
}
return caller
}
terminal := `{"success":true,"result":{"hasMore":false,"values":[{"processInstanceId":"i","title":"fixture"}]}}`
for _, declaration := range []shortcut.Shortcut{PendingApprovals, DoneApprovals} {
declaration := declaration
tool := map[string]string{"+pending": "list_pending_approvals", "+done-approvals": "get_done_tasks"}[declaration.Command]
t.Run(declaration.Command+" invalid limit", func(t *testing.T) {
caller := expectError(t, declaration, map[string][]string{}, "--limit", "0")
if len(caller.history) != 0 {
t.Fatalf("validation made calls: %v", caller.history)
}
})
t.Run(declaration.Command+" call failure", func(t *testing.T) {
expectError(t, declaration, map[string][]string{tool: {"__ERROR__"}})
})
t.Run(declaration.Command+" projection failure", func(t *testing.T) {
expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":{"hasMore":false}}`}})
})
t.Run(declaration.Command+" null result fails closed", func(t *testing.T) {
expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":null}`}})
})
t.Run(declaration.Command+" pagination failure", func(t *testing.T) {
expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":{"values":[]}}`}})
})
t.Run(declaration.Command+" refuses incomplete first page", func(t *testing.T) {
expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":{"hasMore":true,"values":[]}}`}})
})
t.Run(declaration.Command+" default limit", func(t *testing.T) {
expectSuccess(t, declaration, map[string][]string{tool: {terminal}})
})
t.Run(declaration.Command+" explicit limit", func(t *testing.T) {
caller := expectSuccess(t, declaration, map[string][]string{tool: {terminal}}, "--limit", "3")
if caller.arguments[0]["pageSize"] != float64(3) {
t.Fatalf("explicit limit params=%#v", caller.arguments[0])
}
})
}
for name, args := range map[string][]string{
"bad page": {"--page", "0"},
"bad limit": {"--limit", "101"},
} {
t.Run("my-initiated validate "+name, func(t *testing.T) {
expectError(t, MyInitiated, map[string][]string{}, args...)
})
}
t.Run("my-initiated call failure", func(t *testing.T) {
expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {"__ERROR__"}})
})
t.Run("my-initiated projection failure", func(t *testing.T) {
expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":{"hasMore":false}}`}})
})
t.Run("my-initiated null result fails closed", func(t *testing.T) {
expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":null}`}})
})
t.Run("my-initiated pagination failure", func(t *testing.T) {
expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":{"values":[]}}`}})
})
t.Run("my-initiated query and continuation", func(t *testing.T) {
caller := expectSuccess(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":{"hasMore":true,"values":[]}}`}}, "--query", "fixture", "--page", "2", "--limit", "3")
want := map[string]any{"pageNumber": float64(2), "pageSize": float64(3), "query": "fixture"}
if !reflect.DeepEqual(caller.arguments[0], want) {
t.Fatalf("my initiated params=%#v want=%#v", caller.arguments[0], want)
}
})
}
func TestCrossPlatformCoverageOARequiredFlagValidationClosures(t *testing.T) {
for _, tc := range []struct {
name string
flagName string
validate func(*shortcut.RuntimeContext) error
}{
{name: "search forms", flagName: "query", validate: SearchForms.Validate},
{name: "approve", flagName: "keyword", validate: Approve.Validate},
} {
t.Run(tc.name, func(t *testing.T) {
declaration := shortcut.Shortcut{
Service: "oa", Command: "+coverage-" + strings.ReplaceAll(tc.name, " ", "-"), Product: "oa", Description: "fixture", Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{{Name: tc.flagName, Type: shortcut.FlagString}},
Execute: tc.validate,
}
caller := &oaCoverageCaller{responses: map[string][]string{}}
if _, err := runOACoverage(t, declaration, caller); err == nil {
t.Fatal("empty required semantic value was accepted")
}
if len(caller.history) != 0 {
t.Fatalf("validation made calls: %v", caller.history)
}
})
}
}
func TestCrossPlatformCoverageOAApproveFailureLedger(t *testing.T) {
pending := func(values string, hasMore bool) string {
return `{"success":true,"result":{"hasMore":` + map[bool]string{true: "true", false: "false"}[hasMore] + `,"values":` + values + `}}`
}
tasks := func(values string) string {
return `{"success":true,"result":{"taskIdList":` + values + `}}`
}
validPending := pending(`[{"processInstanceId":"instance-1","title":"fixture"}]`, false)
validTasks := tasks(`[{"taskId":1}]`)
cases := []struct {
name string
responses map[string][]string
}{
{name: "pending call failure", responses: map[string][]string{"list_pending_approvals": {"__ERROR__"}}},
{name: "pending projection failure", responses: map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"hasMore":false}}`}}},
{name: "pending null result", responses: map[string][]string{"list_pending_approvals": {`{"success":true,"result":null}`}}},
{name: "pending pagination failure", responses: map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"values":[]}}`}}},
{name: "pending is incomplete", responses: map[string][]string{"list_pending_approvals": {pending(`[{"processInstanceId":"instance-1","title":"fixture"}]`, true)}}},
{name: "zero matches", responses: map[string][]string{"list_pending_approvals": {pending(`[{"processInstanceId":"instance-1","title":"other"}]`, false)}}},
{name: "task call failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {"__ERROR__"}}},
{name: "task projection failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {`{"success":true,"result":{}}`}}},
{name: "task count is not one", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {tasks(`[]`)}}},
{name: "task identity is not numeric", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {tasks(`[{"taskId":"not-numeric"}]`)}}},
{name: "write call failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {"__ERROR__"}}},
{name: "write business failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {`{"success":false}`}}},
{name: "write missing success", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {`{"result":{"accepted":true}}`}}},
{name: "write null success", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {`{"success":null}`}}},
{name: "readback call failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks, "__ERROR__"}, "approve_processInstance": {`{"success":true}`}}},
{name: "readback malformed", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks, `{"success":true,"result":{}}`}, "approve_processInstance": {`{"success":true}`}}},
{name: "write not observed", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks, validTasks}, "approve_processInstance": {`{"success":true}`}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
caller := &oaCoverageCaller{responses: tc.responses}
if err := runOAConfirmedCoverage(t, caller, "--keyword", "fixture", "--yes"); err == nil {
t.Fatalf("failure scenario succeeded; calls=%v", caller.history)
}
})
}
t.Run("empty keyword validates before calls", func(t *testing.T) {
caller := &oaCoverageCaller{responses: map[string][]string{}}
if err := runOAConfirmedCoverage(t, caller, "--keyword", " ", "--yes"); err == nil {
t.Fatal("blank keyword succeeded")
}
if len(caller.history) != 0 {
t.Fatalf("blank keyword made calls: %v", caller.history)
}
})
t.Run("comment is sent only after confirmation", func(t *testing.T) {
caller := &oaCoverageCaller{responses: map[string][]string{
"list_pending_approvals": {validPending},
"list_pending_tasks": {validTasks, tasks(`[]`)},
"approve_processInstance": {`{"success":true}`},
}}
if err := runOAConfirmedCoverage(t, caller, "--keyword", "fixture", "--comment", "approved", "--yes"); err != nil {
t.Fatal(err)
}
if caller.arguments[2]["remark"] != "approved" {
t.Fatalf("write args=%#v", caller.arguments[2])
}
})
}
+272
View File
@@ -0,0 +1,272 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package oa
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func oaFirstPageOnly(rt *shortcut.RuntimeContext, tool, collection string, params map[string]any) error {
operation := "oa/" + tool
data, err := rt.CallMCPData("oa", tool, params)
if err != nil {
return err
}
instances, err := oaProjectInstances(data, operation, "result.values")
if err != nil {
return err
}
result, _ := data["result"].(map[string]any)
page, err := oaHasMorePage(result, operation, 1)
if err != nil {
return err
}
if page.HasMore {
return oaResponseError(operation, "unavailable_continuation", "兼容 Shortcut 没有页码参数且结果仍有后续页;请改用对应可分页命令")
}
return outputOACompleteCollection(rt, collection, instances)
}
var PendingApprovals = shortcut.Shortcut{
Service: "oa", Command: "+pending", Product: "oa",
Description: "只读列出待我审批的审批任务并投影为可读列表(只看不批)",
Intent: "兼容入口:读取近三个月待处理审批的首个完整页;无法证明完整或缺少非空 fixture 时不进入 Agent 公开发现。",
Risk: shortcut.RiskRead,
Safety: oaReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: oaContract(
"+pending", "只读列出待我审批的审批任务并投影为可读列表(只看不批)",
"兼容旧的待审批摘要入口;新调用优先使用支持显式时间和页码的 +list-pending。",
true,
oaCollectionResult("pending", "严格验证的待审批摘要"), nil,
[]contract.ParamDecl{{Name: "limit", Property: "limit"}}, "dws oa +pending --limit 10",
),
Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式 --limit 必须在 1-100"}},
Tips: []string{`dws oa +pending --limit 10`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Changed("limit") && (rt.Int("limit") <= 0 || rt.Int("limit") > 100) {
return apperrors.NewValidation("--limit 必须在 1 到 100 之间")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
now := time.Now()
params := map[string]any{"starTime": float64(now.AddDate(0, 0, -90).UnixMilli()), "endTime": float64(now.UnixMilli())}
if rt.Changed("limit") {
params["pageSize"] = float64(rt.Int("limit"))
}
return oaFirstPageOnly(rt, "list_pending_approvals", "pending", params)
},
}
var DoneApprovals = shortcut.Shortcut{
Service: "oa", Command: "+done-approvals", Product: "oa",
Description: "只读列出我已处理过的审批任务(审批历史)并投影为可读列表",
Intent: "兼容入口:读取我已处理审批的首个完整页;新调用优先使用可分页的 +list-executed。",
Risk: shortcut.RiskRead,
Safety: oaReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: oaContract(
"+done-approvals", "只读列出我已处理过的审批任务(审批历史)并投影为可读列表",
"兼容旧的已处理审批摘要入口;需要翻页或搜索时使用 +list-executed。",
true,
oaCollectionResult("done", "严格验证的已处理审批摘要"), nil,
[]contract.ParamDecl{{Name: "limit", Property: "limit"}}, "dws oa +done-approvals --limit 10",
),
Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式 --limit 必须在 1-100"}},
Tips: []string{`dws oa +done-approvals --limit 10`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Changed("limit") && (rt.Int("limit") <= 0 || rt.Int("limit") > 100) {
return apperrors.NewValidation("--limit 必须在 1 到 100 之间")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
limit := 20
if rt.Changed("limit") {
limit = rt.Int("limit")
}
return oaFirstPageOnly(rt, "get_done_tasks", "done", map[string]any{"pageNumber": float64(1), "pageSize": float64(limit)})
},
}
var MyInitiated = shortcut.Shortcut{
Service: "oa", Command: "+my-initiated", Product: "oa",
Description: "列出我发起(提交)的审批单据",
Intent: "需要兼容旧的 initiated 输出字段时使用;一般列表与分页可直接使用 +list-submitted。",
Risk: shortcut.RiskRead,
Safety: oaReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: oaContract(
"+my-initiated", "列出我发起(提交)的审批单据",
"需要兼容旧的 initiated 输出字段时使用;一般列表与分页可直接使用 +list-submitted。",
true,
oaCollectionResult("initiated", "严格验证的已发起审批实例页"), oaPagePagination("page"),
[]contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}},
"dws oa +my-initiated --page 1 --limit 20", "dws oa +my-initiated --query 报销",
),
Flags: []shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索(可选)"},
{Name: "page", Type: shortcut.FlagInt, Desc: "分页页码(可选,默认 1);--page 必须大于 0", Default: "1"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页大小(可选,默认 20);--limit 必须在 1-100", Default: "20"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100"},
},
Tips: []string{`dws oa +my-initiated`, `dws oa +my-initiated --query 报销`, `dws oa +my-initiated --page 2 --limit 50`},
Validate: func(rt *shortcut.RuntimeContext) error { return validateOAPage(rt.Int("page"), rt.Int("limit")) },
Execute: func(rt *shortcut.RuntimeContext) error {
const operation = "oa/get_submitted_instances"
params := map[string]any{"pageNumber": float64(rt.Int("page")), "pageSize": float64(rt.Int("limit"))}
if query := rt.Str("query"); query != "" {
params["query"] = query
}
data, err := rt.CallMCPData("oa", "get_submitted_instances", params)
if err != nil {
return err
}
items, err := oaProjectInstances(data, operation, "result.values")
if err != nil {
return err
}
result, _ := data["result"].(map[string]any)
page, err := oaHasMorePage(result, operation, rt.Int("page"))
if err != nil {
return err
}
return outputOAPage(rt, "initiated", items, page)
},
}
type oaApprovalMatch struct {
id string
title string
}
func oaMatchApprovals(items []map[string]any, keyword string) []oaApprovalMatch {
needle := strings.ToLower(strings.TrimSpace(keyword))
matches := make([]oaApprovalMatch, 0)
for _, item := range items {
id := oaIdentity(item, "processInstanceId")
title := oaFirstString(item, "title", "processInstanceTitle")
businessID := oaIdentity(item, "businessId")
if strings.Contains(strings.ToLower(title), needle) || strings.Contains(strings.ToLower(businessID), needle) || strings.EqualFold(id, needle) {
matches = append(matches, oaApprovalMatch{id: id, title: title})
}
}
return matches
}
var Approve = shortcut.Shortcut{
Service: "oa", Command: "+approve-by", Product: "oa",
Description: "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)",
Intent: "高风险兼容编排:完整读取并唯一匹配待办、唯一解析 taskId、确认后同意,再精确读回该 taskId 已不在待处理集合;没有安全 fixture 前不进入 Agent 公开发现。",
Risk: shortcut.RiskHighWrite,
Safety: oaWriteSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: oaContract(
"+approve-by", "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)",
"仅在用户明确确认同意、关键词唯一定位实例且任务读回可验证时使用;任何歧义、分页不完整或读回失败都会阻止成功。",
true,
oaWriteResult("同意审批并通过精确任务读回验证"), nil,
[]contract.ParamDecl{{Name: "keyword", Property: "query"}, {Name: "comment", Property: "remark"}},
"dws oa +approve-by --keyword 报销",
),
Flags: []shortcut.Flag{
{Name: "keyword", Type: shortcut.FlagString, Desc: "待审批单据的单号或标题关键词", Required: true},
{Name: "comment", Type: shortcut.FlagString, Desc: "审批意见(可选)"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"keyword"}, Description: "--keyword 去除空白后不能为空,且必须唯一匹配完整待办集合中的一条实例"}},
Tips: []string{`dws oa +approve-by --keyword 报销`, `dws oa +approve-by --keyword 出差单 --comment "同意"`},
Validate: func(rt *shortcut.RuntimeContext) error {
keyword := strings.TrimSpace(rt.Str("keyword"))
if keyword == "" {
return apperrors.NewValidation("--keyword 不能为空")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
keyword := strings.TrimSpace(rt.Str("keyword"))
now := time.Now()
pending, err := rt.CallMCPData("oa", "list_pending_approvals", map[string]any{
"starTime": float64(now.AddDate(0, 0, -90).UnixMilli()), "endTime": float64(now.UnixMilli()), "query": keyword,
})
if err != nil {
return err
}
items, err := oaProjectInstances(pending, "oa/list_pending_approvals", "result.values")
if err != nil {
return err
}
result, _ := pending["result"].(map[string]any)
page, err := oaHasMorePage(result, "oa/list_pending_approvals", 1)
if err != nil {
return err
}
if page.HasMore {
return oaResponseError("oa/list_pending_approvals", "ambiguous_incomplete_search", "待办搜索仍有后续页,无法证明关键词唯一")
}
matches := oaMatchApprovals(items, keyword)
if len(matches) != 1 {
return apperrors.NewValidation(fmt.Sprintf("关键词必须唯一匹配一条待审批实例,当前匹配 %d 条", len(matches)))
}
instanceID := matches[0].id
tasksData, err := rt.CallMCPData("oa", "list_pending_tasks", map[string]any{"processInstanceId": instanceID})
if err != nil {
return err
}
tasks, err := oaProjectTasks(tasksData, "oa/list_pending_tasks")
if err != nil {
return err
}
if len(tasks) != 1 {
return apperrors.NewValidation(fmt.Sprintf("审批实例必须唯一对应一条待处理任务,当前为 %d 条", len(tasks)))
}
taskID := tasks[0]["taskId"].(string)
numericTaskID, err := strconv.ParseFloat(taskID, 64)
if err != nil {
return oaResponseError("oa/list_pending_tasks", "malformed_task_identity", "taskId 不是可写入审批接口的数字")
}
writeArgs := map[string]any{"processInstanceId": instanceID, "taskId": numericTaskID}
if comment := rt.Str("comment"); comment != "" {
writeArgs["remark"] = comment
}
receipt, err := rt.CallMCPWriteDataStrict("oa", "approve_processInstance", writeArgs)
if err != nil {
return err
}
if err := oaRequireSuccess(receipt, "oa/approve_processInstance"); err != nil {
return err
}
readback, err := rt.CallMCPData("oa", "list_pending_tasks", map[string]any{"processInstanceId": instanceID})
if err != nil {
return oaPostWriteError("oa/list_pending_tasks", "readback_failed", "审批写入后无法读取任务状态;远端效果未知")
}
remaining, err := oaProjectTasks(readback, "oa/list_pending_tasks")
if err != nil {
return oaPostWriteError("oa/list_pending_tasks", "readback_malformed", "审批写入后的任务读回无法验证;远端效果未知")
}
for _, task := range remaining {
if task["taskId"] == taskID {
return oaPostWriteError("oa/list_pending_tasks", "write_not_observed", "审批任务写后读回仍处于待处理集合")
}
}
return rt.Output(map[string]any{"processInstanceId": instanceID, "taskId": taskID, "verified": true})
},
}
func init() {
shortcut.Register(PendingApprovals, DoneApprovals, Approve, MyInitiated)
}
@@ -265,10 +265,7 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
"devapp\u0000+version-status": {},
"devapp\u0000+webapp-config": {},
"devapp\u0000+webapp-get": {},
"ding\u0000+list": {},
"ding\u0000+recall-personal": {},
"ding\u0000+receiver-status": {},
"ding\u0000+send-personal": {},
"doc\u0000+access-change": {},
"doc\u0000+access-grant": {},
"doc\u0000+access-revoke": {},
@@ -377,15 +374,11 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
"minutes\u0000+update": {},
"minutes\u0000+upload": {},
"minutes\u0000+upload-and-analyze": {},
"oa\u0000+list-cc": {},
"oa\u0000+list-executed": {},
"oa\u0000+list-forms": {},
"oa\u0000+list-pending": {},
"oa\u0000+list-submitted": {},
"oa\u0000+my-initiated": {},
"oa\u0000+search-forms": {},
"report\u0000+inbox-list": {},
"report\u0000+outbox-list": {},
"report\u0000+report-latest": {},
"report\u0000+template-search": {},
"sheet\u0000+list-sheets": {},
"sheet\u0000+read": {},
"todo\u0000+assign": {},
+477
View File
@@ -0,0 +1,477 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package report
import (
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const reportCompositeReason = "Reviewed Report Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable report/template identities, truthful pagination, local filtering, output projection, and multi-step detail verification."
type reportPageEvidence struct {
HasMore bool
Next string
}
func reportReadSafety() contract.SafetySpec {
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
}
func reportCollectionResult(collection, description string) *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(fmt.Sprintf(
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前页严格验证的日志数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"具有稳定 reportId 的日志摘要","properties":{"reportId":{"type":"string","description":"稳定日志 ID"},"templateName":{"type":"string","description":"日志模板名称"},"creatorName":{"type":"string","description":"日志创建人显示名"},"creatorUserId":{"type":"string","description":"日志创建人稳定用户 ID"},"createTime":{"type":"integer","description":"日志创建时间毫秒值"},"modifiedTime":{"type":"integer","description":"日志修改时间毫秒值"}},"required":["reportId"],"additionalProperties":false}},"complete":{"type":"boolean","description":"服务端分页证据是否证明当前查询已结束"}},"required":["count",%q,"complete"],"additionalProperties":false}`,
description, collection, description, collection,
)),
SensitivePaths: []string{collection + ".templateName", collection + ".creatorName", collection + ".creatorUserId"},
}
}
func reportTemplateSearchResult() *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"严格验证的日志模板搜索结果","properties":{"count":{"type":"integer","description":"匹配模板数量"},"templates":{"type":"array","description":"名称匹配且具有稳定 ID 的模板","items":{"type":"object","description":"一条日志模板摘要","properties":{"templateId":{"type":"string","description":"稳定模板 ID"},"name":{"type":"string","description":"模板名称"},"lastModifiedTime":{"type":"integer","description":"模板最后修改时间毫秒值"}},"required":["templateId","name"],"additionalProperties":false}}},"required":["count","templates"],"additionalProperties":false}`),
SensitivePaths: []string{"templates.name"},
}
}
func reportLatestResult() *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"严格验证的最近日志详情","properties":{"report":{"type":"object","description":"稳定身份匹配的日志详情","properties":{"reportId":{"type":"string","description":"稳定日志 ID"},"title":{"type":"string","description":"日志标题"},"templateName":{"type":"string","description":"日志模板名称"},"creatorName":{"type":"string","description":"日志创建人显示名"},"createTime":{"type":"integer","description":"日志创建时间毫秒值"},"modifiedTime":{"type":"integer","description":"日志修改时间毫秒值"},"fields":{"type":"array","description":"严格验证的日志字段","items":{"type":"object","description":"日志字段值","properties":{"key":{"type":"string","description":"字段名称"},"value":{"type":"string","description":"字段内容"},"sort":{"type":"integer","description":"字段排序值"},"type":{"type":"integer","description":"字段类型码"}},"required":["key","value","sort","type"],"additionalProperties":false}}},"required":["reportId","fields"],"additionalProperties":false}},"required":["report"],"additionalProperties":false}`),
SensitivePaths: []string{"report.title", "report.templateName", "report.creatorName", "report.fields.value"},
}
}
func reportPagination() *contract.PaginationSpec {
return &contract.PaginationSpec{
Kind: contract.PaginationKindCursor,
CursorParameter: "cursor",
MetaPath: contract.PaginationMetaPath,
EndpointExhaustedPath: contract.PaginationExhaustedPath,
NextTokenPath: contract.PaginationNextTokenPath,
}
}
func reportContract(command, description, intent string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
cliPath := "report " + command
return corecmd.ContractDecl{
Description: description,
Result: result,
Pagination: pagination,
Parameters: params,
Identity: contract.ToolIdentitySpec{
ProductID: "report", Name: name, CanonicalPath: "report." + name,
CLIPath: cliPath, PrimaryCLIPath: cliPath,
},
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: reportCompositeReason},
Selection: contract.SelectionSpec{
AgentSummary: description,
UseWhen: []string{intent},
AvoidWhen: []string{"钉钉在线文档使用 doc,待办使用 todo,OA 审批使用 oa;缺少稳定日志或模板身份时不要猜测"},
Examples: examples,
},
}
}
func reportResponseError(operation, reason, message string) error {
return apperrors.NewAPI(message,
apperrors.WithOperation(operation),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("response_validation"),
apperrors.WithRetryable(false),
apperrors.WithReason(reason),
)
}
func reportRequireSuccess(data map[string]any, operation string) error {
if len(data) == 0 {
return reportResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明 Report 调用成功或结果确实为空")
}
raw, present := data["success"]
if !present {
return reportResponseError(operation, "missing_success", "Report 响应缺少 success 业务状态")
}
success, ok := raw.(bool)
if !ok {
return reportResponseError(operation, "invalid_success_type", fmt.Sprintf("Report success 应为布尔值,实际为 %T", raw))
}
if success {
return nil
}
message := "Report 服务明确返回失败"
for _, key := range []string{"errorMsg", "errorMessage", "message"} {
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
message = strings.TrimSpace(value)
break
}
}
return reportResponseError(operation, "remote_failure", message)
}
func reportObjectCollection(value any, operation, path string) ([]map[string]any, error) {
raw, ok := value.([]any)
if !ok {
return nil, reportResponseError(operation, "malformed_collection", fmt.Sprintf("Report %s 应为数组,实际为 %T", path, value))
}
items := make([]map[string]any, 0, len(raw))
for index, item := range raw {
object, ok := item.(map[string]any)
if !ok || len(object) == 0 {
return nil, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report %s[%d] 不是非空对象", path, index))
}
items = append(items, object)
}
return items, nil
}
func reportListCollection(data map[string]any, operation string) ([]map[string]any, map[string]any, error) {
if err := reportRequireSuccess(data, operation); err != nil {
return nil, nil, err
}
result, present := data["result"]
if !present {
return nil, nil, reportResponseError(operation, "missing_collection", "Report 成功响应缺少 result 日志集合")
}
if _, ok := result.([]any); ok {
items, err := reportObjectCollection(result, operation, "result")
return items, data, err
}
container, ok := result.(map[string]any)
if !ok || container == nil {
return nil, nil, reportResponseError(operation, "malformed_collection", fmt.Sprintf("Report result 应为数组或带 report_list 的对象,实际为 %T", result))
}
raw, present := container["report_list"]
if !present {
return nil, nil, reportResponseError(operation, "missing_collection", "Report 成功响应缺少 result.report_list 数组")
}
items, err := reportObjectCollection(raw, operation, "result.report_list")
return items, container, err
}
func reportRequiredString(item map[string]any, operation string, index int, keys ...string) (string, error) {
var selected string
for _, key := range keys {
raw, present := item[key]
if !present || raw == nil {
continue
}
value, ok := raw.(string)
value = strings.TrimSpace(value)
if !ok || value == "" {
return "", reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 结果第 %d 项的 %s 必须是非空字符串", index, key))
}
if selected != "" && selected != value {
return "", reportResponseError(operation, "conflicting_item_identity", fmt.Sprintf("Report 结果第 %d 项的身份字段冲突", index))
}
selected = value
}
if selected == "" {
return "", reportResponseError(operation, "missing_item_identity", fmt.Sprintf("Report 结果第 %d 项缺少稳定身份", index))
}
return selected, nil
}
func reportOptionalString(item map[string]any, operation string, index int, keys ...string) (string, error) {
for _, key := range keys {
raw, present := item[key]
if !present || raw == nil {
continue
}
value, ok := raw.(string)
if !ok {
return "", reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 结果第 %d 项的 %s 应为字符串,实际为 %T", index, key, raw))
}
return strings.TrimSpace(value), nil
}
return "", nil
}
func reportInteger(value any) (int64, bool) {
switch typed := value.(type) {
case float64:
if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed || typed > math.MaxInt64 || typed < math.MinInt64 {
return 0, false
}
return int64(typed), true
case int:
return int64(typed), true
case int64:
return typed, true
case json.Number:
parsed, err := typed.Int64()
return parsed, err == nil
default:
return 0, false
}
}
func reportOptionalInteger(item map[string]any, operation string, index int, keys ...string) (int64, bool, error) {
for _, key := range keys {
raw, present := item[key]
if !present || raw == nil {
continue
}
value, ok := reportInteger(raw)
if !ok {
return 0, false, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 结果第 %d 项的 %s 应为整数", index, key))
}
return value, true, nil
}
return 0, false, nil
}
func reportProjectEntries(data map[string]any, operation string) ([]map[string]any, reportPageEvidence, error) {
items, pageContainer, err := reportListCollection(data, operation)
if err != nil {
return nil, reportPageEvidence{}, err
}
entries := make([]map[string]any, 0, len(items))
seen := make(map[string]struct{}, len(items))
for index, item := range items {
id, identityErr := reportRequiredString(item, operation, index, "reportId", "report_id", "report_Id")
if identityErr != nil {
return nil, reportPageEvidence{}, identityErr
}
if _, duplicate := seen[id]; duplicate {
return nil, reportPageEvidence{}, reportResponseError(operation, "duplicate_item_identity", "当前 Report 页包含重复 reportId")
}
seen[id] = struct{}{}
projected := map[string]any{"reportId": id}
for target, aliases := range map[string][]string{
"templateName": {"templateName", "template_name", "reportTemplateName", "report_template_name"},
"creatorName": {"creatorName", "creator_name", "creatorUserName", "senderName", "authorName"},
"creatorUserId": {"creatorUserId", "creator_user_id", "creatorId", "senderUserId", "userId"},
} {
value, valueErr := reportOptionalString(item, operation, index, aliases...)
if valueErr != nil {
return nil, reportPageEvidence{}, valueErr
}
if value != "" {
projected[target] = value
}
}
for target, aliases := range map[string][]string{
"createTime": {"createTime", "create_time", "gmtCreate", "sendTime"},
"modifiedTime": {"modifiedTime", "modified_time", "gmtModified", "modifyTime"},
} {
value, present, valueErr := reportOptionalInteger(item, operation, index, aliases...)
if valueErr != nil {
return nil, reportPageEvidence{}, valueErr
}
if present {
projected[target] = value
}
}
entries = append(entries, projected)
}
page, err := reportPage(data, pageContainer, operation, len(entries))
if err != nil {
return nil, reportPageEvidence{}, err
}
return entries, page, nil
}
func reportPage(data, result map[string]any, operation string, count int) (reportPageEvidence, error) {
raw, present := data["hasMore"]
if nested, exists := result["hasMore"]; exists {
if present && !sameReportScalar(raw, nested) {
return reportPageEvidence{}, reportResponseError(operation, "conflicting_pagination", "Report 顶层与 result.hasMore 冲突")
}
raw, present = nested, true
}
if !present {
return reportPageEvidence{}, reportResponseError(operation, "missing_pagination", "Report 列表缺少 hasMore,不能把当前页宣称为完整结果")
}
hasMore, ok := raw.(bool)
if !ok {
return reportPageEvidence{}, reportResponseError(operation, "malformed_pagination", "Report hasMore 应为布尔值")
}
page := reportPageEvidence{HasMore: hasMore}
next, nextPresent, err := reportNextCursor(data, result, operation)
if err != nil {
return reportPageEvidence{}, err
}
if !hasMore {
// The Report service echoes an integer cursor on terminal pages. It is a
// page receipt, not a continuation: hasMore=false is authoritative and
// the unified projection intentionally omits next_token. Parsing above
// still rejects wrong types and conflicting cursor fields.
return page, nil
}
if count == 0 {
return reportPageEvidence{}, reportResponseError(operation, "empty_page_with_continuation", "Report 空页仍声明 hasMore=true")
}
if !nextPresent || next <= 0 {
return reportPageEvidence{}, reportResponseError(operation, "missing_next_cursor", "hasMore=true 时必须返回正整数 continuation cursor")
}
page.Next = strconv.FormatInt(next, 10)
return page, nil
}
func reportNextCursor(data, result map[string]any, operation string) (int64, bool, error) {
var selected int64
found := false
for _, object := range []map[string]any{data, result} {
for _, key := range []string{"nextCursor", "cursor"} {
raw, present := object[key]
if !present || raw == nil {
continue
}
value, ok := reportInteger(raw)
if !ok {
return 0, false, reportResponseError(operation, "malformed_pagination", fmt.Sprintf("Report %s 应为整数", key))
}
if found && selected != value {
return 0, false, reportResponseError(operation, "conflicting_pagination", "Report continuation cursor 字段冲突")
}
selected, found = value, true
}
}
return selected, found, nil
}
func sameReportScalar(left, right any) bool {
leftJSON, leftErr := json.Marshal(left)
rightJSON, rightErr := json.Marshal(right)
return leftErr == nil && rightErr == nil && string(leftJSON) == string(rightJSON)
}
func reportProjectTemplates(data map[string]any, operation string) ([]map[string]any, error) {
if err := reportRequireSuccess(data, operation); err != nil {
return nil, err
}
raw, present := data["items"]
if !present {
return nil, reportResponseError(operation, "missing_collection", "Report 模板响应缺少 items 数组")
}
items, err := reportObjectCollection(raw, operation, "items")
if err != nil {
return nil, err
}
templates := make([]map[string]any, 0, len(items))
seen := make(map[string]struct{}, len(items))
for index, item := range items {
id, identityErr := reportRequiredString(item, operation, index, "report_template_id")
if identityErr != nil {
return nil, identityErr
}
name, nameErr := reportRequiredString(item, operation, index, "report_template_name")
if nameErr != nil {
return nil, nameErr
}
if _, duplicate := seen[id]; duplicate {
return nil, reportResponseError(operation, "duplicate_item_identity", "Report 模板响应包含重复 templateId")
}
seen[id] = struct{}{}
projected := map[string]any{"templateId": id, "name": name}
modified, present, modifiedErr := reportOptionalInteger(item, operation, index, "last_modified_time")
if modifiedErr != nil {
return nil, modifiedErr
}
if present {
projected["lastModifiedTime"] = modified
}
templates = append(templates, projected)
}
return templates, nil
}
func reportProjectDetail(data map[string]any, operation, expectedID string) (map[string]any, error) {
if err := reportRequireSuccess(data, operation); err != nil {
return nil, err
}
result, ok := data["result"].(map[string]any)
if !ok || len(result) == 0 {
return nil, reportResponseError(operation, "missing_result", "Report 详情成功响应缺少非空 result 对象")
}
id, err := reportRequiredString(result, operation, 0, "report_Id", "reportId", "report_id")
if err != nil {
return nil, err
}
if id != expectedID {
return nil, reportResponseError(operation, "identity_mismatch", "Report 详情 reportId 与请求目标不一致")
}
projected := map[string]any{"reportId": id}
for target, aliases := range map[string][]string{
"title": {"report_name", "reportName", "title"},
"templateName": {"report_template_name", "templateName"},
"creatorName": {"creatorName", "senderName"},
} {
value, valueErr := reportOptionalString(result, operation, 0, aliases...)
if valueErr != nil {
return nil, valueErr
}
if value != "" {
projected[target] = value
}
}
for target, aliases := range map[string][]string{
"createTime": {"createTime", "create_time"}, "modifiedTime": {"modifiedTime", "modified_time"},
} {
value, present, valueErr := reportOptionalInteger(result, operation, 0, aliases...)
if valueErr != nil {
return nil, valueErr
}
if present {
projected[target] = value
}
}
rawFields, present := result["report_content"]
if !present {
return nil, reportResponseError(operation, "missing_collection", "Report 详情缺少 result.report_content 数组")
}
fields, err := reportObjectCollection(rawFields, operation, "result.report_content")
if err != nil {
return nil, err
}
projectedFields := make([]map[string]any, 0, len(fields))
for index, field := range fields {
key, keyErr := reportRequiredString(field, operation, index, "key")
if keyErr != nil {
return nil, keyErr
}
rawValue, valuePresent := field["value"]
value, valueOK := rawValue.(string)
if !valuePresent || !valueOK {
return nil, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 详情字段第 %d 项的 value 必须是字符串", index))
}
sortValue, sortOK := reportInteger(field["sort"])
typeValue, typeOK := reportInteger(field["type"])
if !sortOK || !typeOK {
return nil, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 详情字段第 %d 项的 sort/type 必须是整数", index))
}
projectedFields = append(projectedFields, map[string]any{"key": key, "value": value, "sort": sortValue, "type": typeValue})
}
projected["fields"] = projectedFields
return projected, nil
}
func outputReportPage(rt *shortcut.RuntimeContext, collection string, entries []map[string]any, page reportPageEvidence) error {
payload := map[string]any{"count": len(entries), collection: entries, "complete": !page.HasMore}
if !output.UsesUnifiedResult(rt.Command()) {
if page.HasMore {
payload["nextCursor"] = page.Next
}
return rt.Output(payload)
}
pagination, err := output.NewPagination(!page.HasMore, page.Next)
if err != nil {
return reportResponseError("report/pagination", "invalid_pagination", err.Error())
}
meta := &output.Meta{Count: output.NewCount(len(entries)), Pagination: pagination}
return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta)))
}
+130
View File
@@ -0,0 +1,130 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package report
import (
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
var ReportLatest = shortcut.Shortcut{
Service: "report", Command: "+report-latest", Product: "report",
Description: "读取我最近提交的一篇日志详情",
Intent: "只想查看明确 20 天内自己提交的最新日志详情时使用;默认最近 20 天,也可成对指定创建时间窗,完整验证候选后按精确 reportId 读回详情。",
Risk: shortcut.RiskRead,
Safety: reportReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: reportContract(
"+report-latest", "读取我最近提交的一篇日志详情",
"只想查看明确 20 天内自己提交的最新日志详情时使用;默认最近 20 天,也可成对指定创建时间窗,完整验证候选后按精确 reportId 读回详情。",
reportLatestResult(), nil,
[]contract.ParamDecl{
{Name: "keyword", Property: "report_template_name"},
{Name: "start", Property: "startTime"}, {Name: "end", Property: "endTime"},
},
"dws report +report-latest", `dws report +report-latest --start "2026-03-01T00:00:00+08:00" --end "2026-03-20T00:00:00+08:00"`,
),
Flags: []shortcut.Flag{
{Name: "keyword", Type: shortcut.FlagString, Desc: "按日志模板名称精确过滤"},
{Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601;--start 与 --end 必须同时提供,且创建时间范围必须有效并不得超过 20 天"},
{Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601;--start 与 --end 必须同时提供,且创建时间范围必须有效并不得超过 20 天"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start 与 --end 必须同时提供,且创建时间范围必须有效并不得超过 20 天"},
},
Tips: []string{"dws report +report-latest", `dws report +report-latest --start "2026-03-01T00:00:00+08:00" --end "2026-03-20T00:00:00+08:00"`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Changed("start") != rt.Changed("end") {
return apperrors.NewValidation("--start 与 --end 必须同时提供", apperrors.WithReason("incomplete_creation_range"))
}
if rt.Changed("start") {
_, _, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumOutboxDays)
return err
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
const listOperation = "report/get_send_report_list"
now := time.Now()
end := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location())
start := end.Add(-reportMaximumOutboxDays * 24 * time.Hour)
if rt.Changed("start") {
startMillis, endMillis, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumOutboxDays)
if err != nil {
return err
}
start, end = time.UnixMilli(startMillis), time.UnixMilli(endMillis)
}
params := map[string]any{
"cursor": 0, "size": 20,
"startTime": start.UnixMilli(), "endTime": end.UnixMilli(),
}
if keyword := strings.TrimSpace(rt.Str("keyword")); keyword != "" {
params["report_template_name"] = keyword
}
data, err := rt.CallMCPData("report", "get_send_report_list", params)
if err != nil {
return err
}
entries, page, err := reportProjectEntries(data, listOperation)
if err != nil {
return err
}
if page.HasMore {
return reportResponseError(listOperation, "incomplete_latest_candidates", "发件箱仍有后续页,不能从不完整集合宣称最新日志")
}
if len(entries) == 0 {
return apperrors.NewValidation("最近 20 天没有可验证的已发送日志", apperrors.WithReason("no_sent_report_fixture"))
}
latestID, err := reportLatestEntryID(entries, listOperation)
if err != nil {
return err
}
const detailOperation = "report/get_report_entry_details"
detailData, err := rt.CallMCPData("report", "get_report_entry_details", map[string]any{"report_id": latestID})
if err != nil {
return err
}
detail, err := reportProjectDetail(detailData, detailOperation, latestID)
if err != nil {
return err
}
return rt.Output(map[string]any{"report": detail})
},
}
func reportLatestEntryID(entries []map[string]any, operation string) (string, error) {
var latestID string
var latestTime int64
latestCount := 0
for index, entry := range entries {
created, ok := entry["createTime"].(int64)
if !ok || created <= 0 {
return "", reportResponseError(operation, "missing_latest_order", "发件箱项目缺少正整数 createTime,不能证明哪一篇最新")
}
id, ok := entry["reportId"].(string)
if !ok || strings.TrimSpace(id) == "" {
return "", reportResponseError(operation, "missing_item_identity", "发件箱项目缺少稳定 reportId")
}
if index == 0 || created > latestTime {
latestID, latestTime = id, created
latestCount = 1
} else if created == latestTime {
latestCount++
}
}
if latestCount != 1 {
return "", reportResponseError(operation, "ambiguous_latest_order", "多篇日志具有相同的最高 createTime,不能确定唯一最新日志")
}
return latestID, nil
}
func init() {
shortcut.Register(ReportLatest)
}
+230 -235
View File
@@ -1,103 +1,126 @@
// 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.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Licensed under the Apache License, Version 2.0
// Package report declares declarative shortcuts for the DingTalk
// "报告/日志" (OA 周报应用) MCP tools. Each shortcut is a thin wrapper over a
// single MCP tool call; tool names and parameter keys mirror the DWS report
// helper (internal/helpers/report.go) verbatim.
// Package report registers strict declarative shortcuts for DingTalk reports.
package report
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
// reportParseISOMillis parses an ISO-8601 timestamp (e.g. 2026-03-10T00:00:00+08:00)
// into Unix milliseconds, matching the helper's parseISOTimeToMillis behavior.
func reportParseISOMillis(name, s string) (int64, error) {
s = strings.TrimSpace(s)
t, err := time.Parse(time.RFC3339, s)
const (
reportMaximumInboxDays = 180
reportMaximumOutboxDays = 20
)
func reportParseISOMillis(name, value string) (int64, error) {
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value))
if err != nil {
return 0, fmt.Errorf("--%s 时间格式无效,需 ISO-8601(如 2026-03-10T00:00:00+08:00): %v", name, err)
return 0, apperrors.NewValidation(
fmt.Sprintf("--%s 时间格式无效,需 ISO-8601(如 2026-03-10T00:00:00+08:00)", name),
apperrors.WithReason("invalid_time_format"),
)
}
return t.UnixMilli(), nil
return parsed.UnixMilli(), nil
}
func reportValidatePage(cursor, size int) error {
if cursor < 0 {
return apperrors.NewValidation("--cursor 不能小于 0", apperrors.WithReason("invalid_cursor"))
}
if size < 1 || size > 20 {
return apperrors.NewValidation("--size 必须在 1 到 20 之间", apperrors.WithReason("invalid_page_size"))
}
return nil
}
func reportValidateRange(startName, startValue, endName, endValue string, maximumDays int) (int64, int64, error) {
start, err := reportParseISOMillis(startName, startValue)
if err != nil {
return 0, 0, err
}
end, err := reportParseISOMillis(endName, endValue)
if err != nil {
return 0, 0, err
}
if end <= start {
return 0, 0, apperrors.NewValidation("结束时间必须晚于开始时间", apperrors.WithReason("invalid_time_range"))
}
if maximumDays > 0 && end-start > int64(maximumDays)*int64(24*time.Hour/time.Millisecond) {
return 0, 0, apperrors.NewValidation(
fmt.Sprintf("时间范围不能超过 %d 天", maximumDays),
apperrors.WithReason("time_range_too_large"),
)
}
return start, end, nil
}
func reportValidateContinuation(page reportPageEvidence, current int, operation string) error {
if !page.HasMore {
return nil
}
next, err := strconv.ParseInt(page.Next, 10, 64)
if err != nil || next <= int64(current) {
return reportResponseError(operation, "stalled_cursor", "Report continuation cursor 没有严格前进")
}
return nil
}
// TemplateList lists the report templates available to the current user.
// TemplateGet reads a single report template's field definitions by name.
// InboxList lists reports received by the current user within a time window.
var InboxList = shortcut.Shortcut{
Service: "report",
Command: "+inbox-list",
Product: "report",
Description: "列出我收到的日报(按时间范围分页)",
Intent: "当你要查看下属或同事发给自己的日报周报、想在某个时间段内浏览或审阅收到的汇报时使用;输入起止时间(ISO-8601),可按发送人 staffId 过滤,分页返回收到的日报列表及其 reportId,供后续 +entry-get 读正文。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "report",
Name: "shortcut_inbox_list",
CanonicalPath: "report.shortcut_inbox_list",
CLIPath: "report +inbox-list",
PrimaryCLIPath: "report +inbox-list",
Service: "report", Command: "+inbox-list", Product: "report",
Description: "列出我收到的日志",
Intent: "需要按明确时间范围读取别人发给我的日志摘要并取得稳定 reportId 时使用;后端必须提供可验证的终止或严格前进 cursor。",
Risk: shortcut.RiskRead,
Safety: reportReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: reportContract(
"+inbox-list", "列出我收到的日志",
"需要按明确时间范围读取别人发给我的日志摘要并取得稳定 reportId 时使用;后端必须提供可验证的终止或严格前进 cursor。",
reportCollectionResult("reports", "严格验证的收件箱日志页"), reportPagination(),
[]contract.ParamDecl{
{Name: "start", Property: "start"}, {Name: "end", Property: "end"},
{Name: "cursor", Property: "cursor"}, {Name: "size", Property: "size"},
{Name: "sender-user-ids", Property: "senderUserIds", InterfaceType: "array"},
},
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{"当你要查看下属或同事发给自己的日报周报、想在某个时间段内浏览或审阅收到的汇报时使用;输入起止时间(ISO-8601),可按发送人 staffId 过滤,分页返回收到的日报列表及其 reportId,供后续 +entry-get 读正文。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws report +inbox-list --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --cursor 0 --size 20"},
},
},
"dws report +inbox-list --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --cursor 0 --size 20",
),
Flags: []shortcut.Flag{
{Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)", Required: true},
{Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)", Required: true},
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"},
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,最大 20"},
{Name: "sender-user-ids", Type: shortcut.FlagStringSlice, Desc: "发送人 staffId 列表,逗号分隔,过滤指定发送人"},
{Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601;结束时间必须晚于开始时间,跨度不得超过 180 天", Required: true},
{Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601;结束时间必须晚于开始时间,跨度不得超过 180 天", Required: true},
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标;--cursor 不能小于 0,续页 cursor 必须严格前进"},
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数;--size 必须在 1 到 20 之间"},
{Name: "sender-user-ids", Type: shortcut.FlagStringSlice, Desc: "发送人 staffId 列表"},
},
Tips: []string{
`dws report +inbox-list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --cursor 0 --size 20`,
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "结束时间必须晚于开始时间,跨度不得超过 180 天"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"cursor"}, Description: "--cursor 不能小于 0,续页 cursor 必须严格前进"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "--size 必须在 1 到 20 之间"},
},
Execute: func(rt *shortcut.RuntimeContext) error {
startMs, err := reportParseISOMillis("start", rt.Str("start"))
if err != nil {
Tips: []string{`dws report +inbox-list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --cursor 0 --size 20`},
Validate: func(rt *shortcut.RuntimeContext) error {
if err := reportValidatePage(rt.Int("cursor"), rt.Int("size")); err != nil {
return err
}
endMs, err := reportParseISOMillis("end", rt.Str("end"))
_, _, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumInboxDays)
return err
},
Execute: func(rt *shortcut.RuntimeContext) error {
const operation = "report/get_received_report_list"
start, end, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumInboxDays)
if err != nil {
return err
}
params := map[string]any{
"startTime": startMs,
"endTime": endMs,
"cursor": rt.Int("cursor"),
"size": rt.Int("size"),
"startTime": start, "endTime": end,
"cursor": rt.Int("cursor"), "size": rt.Int("size"),
}
if rt.Changed("sender-user-ids") {
params["senderUserIds"] = rt.StrSlice("sender-user-ids")
@@ -106,192 +129,164 @@ var InboxList = shortcut.Shortcut{
if err != nil {
return err
}
reports := reportEntryListProject(data)
return rt.Output(map[string]any{"count": len(reports), "reports": reports})
reports, page, err := reportProjectEntries(data, operation)
if err != nil {
return err
}
if err := reportValidateContinuation(page, rt.Int("cursor"), operation); err != nil {
return err
}
return outputReportPage(rt, "reports", reports, page)
},
}
// reportEntryListProject reshapes the raw report-list responses
// (get_received_report_list / get_send_report_list) into a clean, stable list
// of report summaries — the output-projection fidelity the framework applies to
// every list command. Both the list container and the per-item field names are
// probed defensively across candidate keys so the projection tolerates
// response-shape drift and never fabricates data: an empty/unknown shape yields
// an empty list rather than a crash.
func reportEntryListProject(data map[string]any) []map[string]any {
raw := reportEntryListResolveList(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
row := map[string]any{}
if v, ok := reportEntryListFirst(m, "reportId", "report_id", "id"); ok {
row["reportId"] = v
}
if v, ok := reportEntryListFirst(m, "templateName", "template_name", "reportTemplateName"); ok {
row["templateName"] = v
}
if v, ok := reportEntryListFirst(m, "creatorName", "creator_name", "creatorUserName", "senderName", "authorName"); ok {
row["creatorName"] = v
}
if v, ok := reportEntryListFirst(m, "creatorUserId", "creator_user_id", "creatorId", "senderUserId", "userId"); ok {
row["creatorUserId"] = v
}
if v, ok := reportEntryListFirst(m, "createTime", "create_time", "gmtCreate", "sendTime"); ok {
row["createTime"] = v
}
if v, ok := reportEntryListFirst(m, "modifiedTime", "modified_time", "gmtModified", "modifyTime"); ok {
row["modifiedTime"] = v
}
if len(row) > 0 {
out = append(out, row)
}
}
return out
}
// reportEntryListResolveList locates the list payload inside the response,
// tolerating a bare top-level array or nesting one level under a common
// envelope key.
func reportEntryListResolveList(data map[string]any) []any {
if data == nil {
return []any{}
}
// get_received_report_list / get_send_report_list nest the list under
// result.report_list (snake_case); "report_list" MUST be probed — the
// camelCase "reportList" alone leaves +inbox-list / +outbox-list silently
// empty despite the backend returning reports.
for _, key := range []string{"result", "data", "list", "items", "report_list", "reportList", "records"} {
v, ok := data[key]
if !ok {
continue
}
if arr, ok := v.([]any); ok {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "report_list", "reportList", "records", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
}
}
}
return []any{}
}
// reportEntryListFirst returns the first present candidate key's value.
func reportEntryListFirst(m map[string]any, keys ...string) (any, bool) {
for _, k := range keys {
if v, ok := m[k]; ok {
return v, true
}
}
return nil, false
}
// OutboxList lists reports sent/created by the current user.
var OutboxList = shortcut.Shortcut{
Service: "report",
Command: "+outbox-list",
Product: "report",
Description: "列出我发出的日报(可选时间/模版名过滤)",
Intent: "当你要回顾自己写过、提交过的日报周报,比如确认某天是否已交、找回历史汇报内容或统计提交情况时使用;可按创建/修改时间范围和模版名过滤,分页返回自己发出的日报列表及 reportId,供后续 +entry-get 查看正文。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "report",
Name: "shortcut_outbox_list",
CanonicalPath: "report.shortcut_outbox_list",
CLIPath: "report +outbox-list",
PrimaryCLIPath: "report +outbox-list",
Service: "report", Command: "+outbox-list", Product: "report",
Description: "列出我发出的日志",
Intent: "需要按创建或修改时间回顾自己提交的日志并取得稳定 reportId 时使用;每次创建或修改时间窗不得超过 20 天。",
Risk: shortcut.RiskRead,
Safety: reportReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: reportContract(
"+outbox-list", "列出我发出的日志",
"需要按创建或修改时间回顾自己提交的日志并取得稳定 reportId 时使用;每次创建或修改时间窗不得超过 20 天。",
reportCollectionResult("reports", "严格验证的发件箱日志页"), reportPagination(),
[]contract.ParamDecl{
{Name: "cursor", Property: "cursor"}, {Name: "size", Property: "size"},
{Name: "start", Property: "start"}, {Name: "end", Property: "end"},
{Name: "modified-start", Property: "modifiedStart"}, {Name: "modified-end", Property: "modifiedEnd"},
{Name: "template-name", Property: "templateName"},
},
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{"当你要回顾自己写过、提交过的日报周报,比如确认某天是否已交、找回历史汇报内容或统计提交情况时使用;可按创建/修改时间范围和模版名过滤,分页返回自己发出的日报列表及 reportId,供后续 +entry-get 查看正文。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{
"dws report +outbox-list --cursor 0 --size 20",
"dws report +outbox-list --cursor 0 --size 20 --template-name \"日报\"",
},
},
},
"dws report +outbox-list --cursor 0 --size 20",
),
Flags: []shortcut.Flag{
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"},
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,最大 20"},
{Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601 (可选,服务端单次跨度上限 20 天)"},
{Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601 (可选)"},
{Name: "modified-start", Type: shortcut.FlagString, Desc: "修改开始时间 ISO-8601 (可选)"},
{Name: "modified-end", Type: shortcut.FlagString, Desc: "修改结束时间 ISO-8601 (可选)"},
{Name: "template-name", Type: shortcut.FlagString, Desc: "日志模板名称 (可选,不传查全部)"},
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标;--cursor 不能小于 0,续页 cursor 必须严格前进"},
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数;--size 必须在 1 到 20 之间"},
{Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601;创建时间范围必须有效且不得超过 20 天"},
{Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601;创建时间范围必须有效且不得超过 20 天"},
{Name: "modified-start", Type: shortcut.FlagString, Desc: "修改开始时间 ISO-8601;修改时间必须成对提供、范围有效且不得超过 20 天"},
{Name: "modified-end", Type: shortcut.FlagString, Desc: "修改结束时间 ISO-8601;修改时间必须成对提供、范围有效且不得超过 20 天"},
{Name: "template-name", Type: shortcut.FlagString, Desc: "日志模板名称"},
},
Tips: []string{
`dws report +outbox-list --cursor 0 --size 20`,
`dws report +outbox-list --cursor 0 --size 20 --template-name "日报"`,
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"cursor"}, Description: "--cursor 不能小于 0,续页 cursor 必须严格前进"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "--size 必须在 1 到 20 之间"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "创建时间范围必须有效且不得超过 20 天"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"modified-start", "modified-end"}, Description: "修改时间必须成对提供、范围有效且不得超过 20 天"},
},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"cursor": rt.Int("cursor"),
"size": rt.Int("size"),
Tips: []string{"dws report +outbox-list --cursor 0 --size 20"},
Validate: func(rt *shortcut.RuntimeContext) error {
if err := reportValidatePage(rt.Int("cursor"), rt.Int("size")); err != nil {
return err
}
if rt.Changed("start") {
ms, err := reportParseISOMillis("start", rt.Str("start"))
if err != nil {
if rt.Changed("start") && rt.Changed("end") {
if _, _, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumOutboxDays); err != nil {
return err
}
params["startTime"] = ms
}
if rt.Changed("end") {
ms, err := reportParseISOMillis("end", rt.Str("end"))
if err != nil {
return err
}
params["endTime"] = ms
if rt.Changed("modified-start") != rt.Changed("modified-end") {
return apperrors.NewValidation("--modified-start 与 --modified-end 必须同时提供", apperrors.WithReason("incomplete_modified_range"))
}
if rt.Changed("modified-start") {
ms, err := reportParseISOMillis("modified-start", rt.Str("modified-start"))
if err != nil {
return err
}
params["modifiedStartTime"] = ms
_, _, err := reportValidateRange("modified-start", rt.Str("modified-start"), "modified-end", rt.Str("modified-end"), reportMaximumOutboxDays)
return err
}
if rt.Changed("modified-end") {
ms, err := reportParseISOMillis("modified-end", rt.Str("modified-end"))
if err != nil {
return err
}
params["modifiedEndTime"] = ms
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
const operation = "report/get_send_report_list"
now := time.Now()
defaultEnd := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location())
startValue := defaultEnd.Add(-reportMaximumOutboxDays * 24 * time.Hour).Format(time.RFC3339)
endValue := defaultEnd.Format(time.RFC3339)
if rt.Changed("start") {
startValue = rt.Str("start")
}
if rt.Changed("template-name") {
params["report_template_name"] = rt.Str("template-name")
if rt.Changed("end") {
endValue = rt.Str("end")
}
start, end, err := reportValidateRange("start", startValue, "end", endValue, reportMaximumOutboxDays)
if err != nil {
return err
}
params := map[string]any{
"cursor": rt.Int("cursor"), "size": rt.Int("size"),
"startTime": start, "endTime": end,
}
if rt.Changed("modified-start") {
modifiedStart, modifiedEnd, rangeErr := reportValidateRange("modified-start", rt.Str("modified-start"), "modified-end", rt.Str("modified-end"), reportMaximumOutboxDays)
if rangeErr != nil {
return rangeErr
}
params["modifiedStartTime"], params["modifiedEndTime"] = modifiedStart, modifiedEnd
}
if name := strings.TrimSpace(rt.Str("template-name")); name != "" {
params["report_template_name"] = name
}
data, err := rt.CallMCPData("report", "get_send_report_list", params)
if err != nil {
return err
}
reports := reportEntryListProject(data)
return rt.Output(map[string]any{"count": len(reports), "reports": reports})
reports, page, err := reportProjectEntries(data, operation)
if err != nil {
return err
}
if err := reportValidateContinuation(page, rt.Int("cursor"), operation); err != nil {
return err
}
return outputReportPage(rt, "reports", reports, page)
},
}
// EntryGet reads the full body of a single report by id.
// EntryStats reads the read-receipt statistics of a single report.
// EntrySubmit submits a new report against a template.
var TemplateSearch = shortcut.Shortcut{
Service: "report", Command: "+template-search", Product: "report",
Description: "按名称搜索可用日志模板",
Intent: "需要从当前用户全部可用日志模板中按名称查找稳定 templateId,或在提交日志前确认模板是否存在时使用。",
Risk: shortcut.RiskRead,
Safety: reportReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: reportContract(
"+template-search", "按名称搜索可用日志模板",
"需要从当前用户全部可用日志模板中按名称查找稳定 templateId,或在提交日志前确认模板是否存在时使用。",
reportTemplateSearchResult(), nil,
[]contract.ParamDecl{{Name: "query", Property: "report_template_name"}},
"dws report +template-search --query 周报",
),
Flags: []shortcut.Flag{{Name: "query", Type: shortcut.FlagString, Desc: "模板名称关键词,不区分大小写;--query 不能为空", Required: true}},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "--query 不能为空"}},
Tips: []string{"dws report +template-search --query 周报"},
Validate: func(rt *shortcut.RuntimeContext) error {
if strings.TrimSpace(rt.Str("query")) == "" {
return apperrors.NewValidation("--query 不能为空", apperrors.WithReason("empty_query"))
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
const operation = "report/get_available_report_templates"
data, err := rt.CallMCPData("report", "get_available_report_templates", map[string]any{})
if err != nil {
return err
}
templates, err := reportProjectTemplates(data, operation)
if err != nil {
return err
}
query := strings.ToLower(strings.TrimSpace(rt.Str("query")))
matches := make([]map[string]any, 0)
for _, template := range templates {
if strings.Contains(strings.ToLower(template["name"].(string)), query) {
matches = append(matches, template)
}
}
payload := map[string]any{"count": len(matches), "templates": matches}
if !output.UsesUnifiedResult(rt.Command()) {
return rt.Output(payload)
}
meta := &output.Meta{Count: output.NewCount(len(matches))}
return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta)))
},
}
func init() {
shortcut.Register(
InboxList,
OutboxList,
)
shortcut.Register(InboxList, OutboxList, TemplateSearch)
}
@@ -1,38 +0,0 @@
// 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.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package report
import (
"encoding/json"
"testing"
)
// TestReportEntryListResolveListSnakeShape guards against projection-data-loss:
// get_received_report_list / get_send_report_list nest the list under
// result.report_list (snake_case); the resolver must probe "report_list" or
// +inbox-list / +outbox-list silently return empty despite the backend having
// reports.
func TestReportEntryListResolveListSnakeShape(t *testing.T) {
const raw = `{"result":{"report_list":[
{"reportId":"r1","templateName":"daily report"},
{"reportId":"r2","templateName":"weekly report"}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if got := reportEntryListResolveList(data); len(got) != 2 {
t.Fatalf("lower/upper mismatch: result.report_list has 2 entries, resolver returned %d", len(got))
}
}
@@ -0,0 +1,625 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package report
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"math"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type reportCoverageCall struct {
tool string
args map[string]any
}
type reportCoverageCaller struct {
responses map[string][]string
history []reportCoverageCall
}
func (caller *reportCoverageCaller) CallTool(_ context.Context, _, tool string, args map[string]any) (*edition.ToolResult, error) {
caller.history = append(caller.history, reportCoverageCall{tool: tool, args: args})
queue := caller.responses[tool]
if len(queue) == 0 {
return nil, errors.New("missing Report fake response for " + tool)
}
caller.responses[tool] = queue[1:]
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil
}
func (*reportCoverageCaller) Format() string { return "json" }
func (*reportCoverageCaller) DryRun() bool { return false }
func (*reportCoverageCaller) Fields() string { return "" }
func (*reportCoverageCaller) JQ() string { return "" }
func runReportCoverage(t *testing.T, declaration shortcut.Shortcut, caller *reportCoverageCaller, args ...string) (*cobra.Command, error) {
t.Helper()
helpers.InitDepsForTest(t, caller)
cmd := corecmd.New(shortcut.FromShortcut(declaration))
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
return cmd, cmd.Execute()
}
func directReportRuntime(t *testing.T, declaration shortcut.Shortcut, caller *reportCoverageCaller, args ...string) *shortcut.RuntimeContext {
t.Helper()
helpers.InitDepsForTest(t, caller)
cmd := corecmd.New(shortcut.FromShortcut(declaration))
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
if err := cmd.Flags().Parse(args); err != nil {
t.Fatal(err)
}
return shortcut.RuntimeContextForTest(cmd, declaration)
}
func TestCrossPlatformCoverageReportContractsAreStrictTypedAndUnified(t *testing.T) {
declarations := []shortcut.Shortcut{InboxList, OutboxList, TemplateSearch, ReportLatest}
for _, declaration := range declarations {
if declaration.Contract.Empty() || declaration.Contract.Result == nil {
t.Errorf("%s lacks Contract/Result", declaration.Command)
}
if declaration.Safety.Effect != "read" || declaration.Safety.Confirmation != "not_required" {
t.Errorf("%s safety=%+v", declaration.Command, declaration.Safety)
}
if declaration.OutputRollout != output.RolloutUnifiedActive {
t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout)
}
if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" {
t.Errorf("%s interface=%+v", declaration.Command, declaration.Contract.Interface)
}
}
if InboxList.Contract.Pagination == nil || OutboxList.Contract.Pagination == nil {
t.Fatal("Report list shortcuts must publish cursor pagination")
}
if TemplateSearch.Contract.Pagination != nil || ReportLatest.Contract.Pagination != nil {
t.Fatal("non-paginated Report shortcuts published pagination")
}
}
func TestCrossPlatformCoverageReportListResponseMatrix(t *testing.T) {
valid := map[string]any{
"success": true,
"result": map[string]any{
"report_list": []any{map[string]any{"reportId": "report-1", "templateName": "fixture", "createTime": float64(10)}},
},
"hasMore": false,
}
entries, page, err := reportProjectEntries(valid, "report/get_received_report_list")
if err != nil || len(entries) != 1 || page.HasMore || entries[0]["createTime"] != int64(10) {
t.Fatalf("valid projection entries=%#v page=%+v err=%v", entries, page, err)
}
empty := map[string]any{"success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": false, "cursor": nil}
if entries, page, err := reportProjectEntries(empty, "report/get_received_report_list"); err != nil || len(entries) != 0 || page.HasMore {
t.Fatalf("terminal empty entries=%#v page=%+v err=%v", entries, page, err)
}
terminalReceipt := map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}, "hasMore": false, "nextCursor": float64(1)}
if entries, page, err := reportProjectEntries(terminalReceipt, "report/get_received_report_list"); err != nil || len(entries) != 1 || page.HasMore || page.Next != "" {
t.Fatalf("terminal cursor receipt entries=%#v page=%+v err=%v", entries, page, err)
}
fixtures := map[string]map[string]any{
"empty response": map[string]any{},
"missing success": map[string]any{"result": map[string]any{"report_list": []any{}}, "hasMore": false},
"wrong success": map[string]any{"success": "true", "result": map[string]any{"report_list": []any{}}, "hasMore": false},
"remote failure": map[string]any{"success": false, "result": map[string]any{"report_list": []any{}}, "hasMore": false},
"missing result": map[string]any{"success": true, "hasMore": false},
"wrong result": map[string]any{"success": true, "result": "bad", "hasMore": false},
"missing collection": map[string]any{"success": true, "result": map[string]any{}, "hasMore": false},
"wrong collection": map[string]any{"success": true, "result": map[string]any{"report_list": map[string]any{}}, "hasMore": false},
"bad item": map[string]any{"success": true, "result": map[string]any{"report_list": []any{"bad"}}, "hasMore": false},
"empty item": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{}}}, "hasMore": false},
"missing identity": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"createTime": float64(1)}}}, "hasMore": false},
"wrong identity": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": float64(1)}}}, "hasMore": false},
"duplicate identity": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "same"}, map[string]any{"reportId": "same"}}}, "hasMore": false},
"wrong optional": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1", "createTime": "1"}}}, "hasMore": false},
"missing pagination": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}},
"wrong pagination": map[string]any{"success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": "false"},
"empty continuation": map[string]any{"success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": true, "nextCursor": float64(2)},
"missing continuation": map[string]any{
"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}, "hasMore": true,
},
"wrong continuation": map[string]any{
"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}, "hasMore": true, "nextCursor": "2",
},
"wrong terminal cursor": map[string]any{
"success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": false, "nextCursor": "2",
},
"conflicting has more": map[string]any{
"success": true, "result": map[string]any{"report_list": []any{}, "hasMore": true}, "hasMore": false,
},
"conflicting continuation": map[string]any{
"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}, "cursor": float64(3)}, "hasMore": true, "nextCursor": float64(2),
},
}
for name, fixture := range fixtures {
if projected, _, projectErr := reportProjectEntries(fixture, "report/get_received_report_list"); projectErr == nil {
t.Errorf("%s returned success: %#v", name, projected)
}
}
if err := reportValidateContinuation(reportPageEvidence{HasMore: true, Next: "2"}, 2, "report/list"); err == nil {
t.Fatal("stalled continuation returned success")
}
}
func TestCrossPlatformCoverageReportTemplateResponseMatrix(t *testing.T) {
valid := map[string]any{"success": true, "items": []any{
map[string]any{"report_template_id": "template-1", "report_template_name": "Fixture Weekly", "last_modified_time": float64(2)},
map[string]any{"report_template_id": "template-2", "report_template_name": "Fixture Daily"},
}}
templates, err := reportProjectTemplates(valid, "report/get_available_report_templates")
if err != nil || len(templates) != 2 || templates[0]["lastModifiedTime"] != int64(2) {
t.Fatalf("valid templates=%#v err=%v", templates, err)
}
for name, fixture := range map[string]map[string]any{
"empty response": {},
"missing success": {"items": []any{}},
"wrong success": {"success": float64(1), "items": []any{}},
"missing collection": {"success": true},
"wrong collection": {"success": true, "items": map[string]any{}},
"bad item": {"success": true, "items": []any{"bad"}},
"missing id": {"success": true, "items": []any{map[string]any{"report_template_name": "fixture"}}},
"missing name": {"success": true, "items": []any{map[string]any{"report_template_id": "template-1"}}},
"wrong name": {"success": true, "items": []any{map[string]any{"report_template_id": "template-1", "report_template_name": float64(1)}}},
"duplicate id": {"success": true, "items": []any{map[string]any{"report_template_id": "same", "report_template_name": "one"}, map[string]any{"report_template_id": "same", "report_template_name": "two"}}},
"wrong modified": {"success": true, "items": []any{map[string]any{"report_template_id": "template-1", "report_template_name": "fixture", "last_modified_time": "2"}}},
} {
if projected, projectErr := reportProjectTemplates(fixture, "report/get_available_report_templates"); projectErr == nil {
t.Errorf("%s returned success: %#v", name, projected)
}
}
if projected, err := reportProjectTemplates(map[string]any{"success": true, "items": []any{}}, "report/templates"); err != nil || len(projected) != 0 {
t.Fatalf("explicit empty template collection=%#v err=%v", projected, err)
}
}
func TestCrossPlatformCoverageReportDetailResponseMatrix(t *testing.T) {
valid := map[string]any{"success": true, "result": map[string]any{
"report_Id": "report-2", "report_name": "fixture", "createTime": float64(2),
"report_content": []any{map[string]any{"key": "field", "value": "value", "sort": float64(1), "type": float64(2)}},
}}
detail, err := reportProjectDetail(valid, "report/get_report_entry_details", "report-2")
if err != nil || detail["reportId"] != "report-2" || len(detail["fields"].([]map[string]any)) != 1 {
t.Fatalf("valid detail=%#v err=%v", detail, err)
}
for name, fixture := range map[string]map[string]any{
"empty response": {},
"missing result": {"success": true},
"empty result": {"success": true, "result": map[string]any{}},
"missing id": {"success": true, "result": map[string]any{"report_content": []any{}}},
"identity mismatch": {"success": true, "result": map[string]any{"report_Id": "other", "report_content": []any{}}},
"missing collection": {"success": true, "result": map[string]any{"report_Id": "report-2"}},
"wrong collection": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": map[string]any{}}},
"bad field": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": []any{"bad"}}},
"missing field key": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": []any{map[string]any{"value": "value", "sort": float64(1), "type": float64(2)}}}},
"wrong field type": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": []any{map[string]any{"key": "field", "value": "value", "sort": "1", "type": float64(2)}}}},
} {
if projected, projectErr := reportProjectDetail(fixture, "report/get_report_entry_details", "report-2"); projectErr == nil {
t.Errorf("%s returned success: %#v", name, projected)
}
}
}
func TestCrossPlatformCoverageReportExactShortcutsProjectUnifiedData(t *testing.T) {
templateCaller := &reportCoverageCaller{responses: map[string][]string{
"get_available_report_templates": {`{"success":true,"items":[{"report_template_id":"template-1","report_template_name":"Fixture Weekly","last_modified_time":2},{"report_template_id":"template-2","report_template_name":"Fixture Daily"}]}`},
}}
cmd, err := runReportCoverage(t, TemplateSearch, templateCaller, "--query", "weekly")
if err != nil {
t.Fatal(err)
}
var stdout bytes.Buffer
cmd.SetOut(&stdout)
if code, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted || code != 0 {
t.Fatalf("emit=(%d,%t,%v)", code, emitted, emitErr)
}
var envelope map[string]any
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
data := envelope["data"].(map[string]any)
if data["count"] != float64(1) || len(data["templates"].([]any)) != 1 {
t.Fatalf("template search data=%#v", data)
}
if _, leaked := data["items"]; leaked {
t.Fatalf("template search leaked raw collection: %#v", data)
}
inboxCaller := &reportCoverageCaller{responses: map[string][]string{
"get_received_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]},"hasMore":false,"cursor":null}`},
}}
cmd, err = runReportCoverage(t, InboxList, inboxCaller,
"--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-02T00:00:00+08:00")
if err != nil {
t.Fatal(err)
}
stdout.Reset()
cmd.SetOut(&stdout)
if _, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted {
t.Fatalf("inbox emit=(%t,%v)", emitted, emitErr)
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
data = envelope["data"].(map[string]any)
if data["count"] != float64(1) || data["complete"] != true {
t.Fatalf("inbox data=%#v", data)
}
}
func TestCrossPlatformCoverageReportLatestRequiresCompleteOrderedListAndExactReadback(t *testing.T) {
missingOrder := &reportCoverageCaller{responses: map[string][]string{
"get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1"}]},"hasMore":false}`},
}}
if _, err := runReportCoverage(t, ReportLatest, missingOrder); err == nil || len(missingOrder.history) != 1 {
t.Fatalf("missing order err=%v history=%v", err, missingOrder.history)
}
incomplete := &reportCoverageCaller{responses: map[string][]string{
"get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]},"hasMore":true,"nextCursor":20}`},
}}
if _, err := runReportCoverage(t, ReportLatest, incomplete); err == nil || len(incomplete.history) != 1 {
t.Fatalf("incomplete err=%v history=%v", err, incomplete.history)
}
tied := &reportCoverageCaller{responses: map[string][]string{
"get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":2},{"reportId":"report-2","createTime":2}]},"hasMore":false}`},
}}
if _, err := runReportCoverage(t, ReportLatest, tied); err == nil || len(tied.history) != 1 {
t.Fatalf("tied latest err=%v history=%v", err, tied.history)
}
caller := &reportCoverageCaller{responses: map[string][]string{
"get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1},{"reportId":"report-2","createTime":2}]},"hasMore":false}`},
"get_report_entry_details": {`{"success":true,"result":{"report_Id":"report-2","report_name":"fixture","createTime":2,"report_content":[{"key":"field","value":"value","sort":1,"type":2}]}}`},
}}
cmd, err := runReportCoverage(t, ReportLatest, caller,
"--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-20T00:00:00+08:00")
if err != nil {
t.Fatal(err)
}
if len(caller.history) != 2 || caller.history[0].tool != "get_send_report_list" || caller.history[1].tool != "get_report_entry_details" || caller.history[1].args["report_id"] != "report-2" {
t.Fatalf("exact call history=%#v", caller.history)
}
if caller.history[0].args["startTime"] != int64(1782835200000) || caller.history[0].args["endTime"] != int64(1784476800000) {
t.Fatalf("explicit range args=%#v", caller.history[0].args)
}
var stdout bytes.Buffer
cmd.SetOut(&stdout)
if _, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted {
t.Fatalf("latest emit=(%t,%v)", emitted, emitErr)
}
var envelope map[string]any
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
projected := envelope["data"].(map[string]any)["report"].(map[string]any)
_, leaked := projected["success"]
if projected["reportId"] != "report-2" || leaked {
t.Fatalf("latest projection=%s", stdout.String())
}
}
func TestCrossPlatformCoverageReportValidationRejectsInvalidRangesBeforeMCP(t *testing.T) {
tests := []struct {
declaration shortcut.Shortcut
args []string
}{
{InboxList, []string{"--start", "2026-07-02T00:00:00+08:00", "--end", "2026-07-01T00:00:00+08:00"}},
{InboxList, []string{"--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-02T00:00:00+08:00", "--size", "21"}},
{OutboxList, []string{"--modified-start", "2026-07-01T00:00:00+08:00"}},
{ReportLatest, []string{"--start", "2026-07-01T00:00:00+08:00"}},
{ReportLatest, []string{"--start", "2026-07-22T00:00:00+08:00", "--end", "2026-07-01T00:00:00+08:00"}},
}
for _, test := range tests {
caller := &reportCoverageCaller{responses: map[string][]string{}}
if _, err := runReportCoverage(t, test.declaration, caller, test.args...); err == nil || len(caller.history) != 0 {
t.Errorf("%s args=%v err=%v calls=%v", test.declaration.Command, test.args, err, caller.history)
}
}
}
func TestCrossPlatformCoverageReportPrimitiveStrictnessAndProjectionBranches(t *testing.T) {
operation := "report/coverage"
if err := reportRequireSuccess(map[string]any{"success": false, "errorMessage": " rejected "}, operation); err == nil {
t.Fatal("remote failure with message returned success")
}
arrayEntries, container, err := reportListCollection(map[string]any{
"success": true,
"result": []any{map[string]any{"report_id": "report-array"}},
"hasMore": false,
}, operation)
if err != nil || len(arrayEntries) != 1 || container["hasMore"] != false {
t.Fatalf("array collection entries=%#v container=%#v err=%v", arrayEntries, container, err)
}
if _, err := reportRequiredString(map[string]any{"reportId": "one", "report_id": "two"}, operation, 0, "reportId", "report_id"); err == nil {
t.Fatal("conflicting aliases returned success")
}
if _, err := reportOptionalString(map[string]any{"name": 1}, operation, 0, "name"); err == nil {
t.Fatal("non-string optional value returned success")
}
integerCases := []struct {
value any
want int64
ok bool
}{
{float64(7), 7, true}, {math.NaN(), 0, false}, {math.Inf(1), 0, false},
{1.5, 0, false}, {int(8), 8, true}, {int64(9), 9, true},
{json.Number("10"), 10, true}, {json.Number("bad"), 0, false}, {"11", 0, false},
}
for _, test := range integerCases {
got, ok := reportInteger(test.value)
if got != test.want || ok != test.ok {
t.Errorf("reportInteger(%#v)=(%d,%t), want (%d,%t)", test.value, got, ok, test.want, test.ok)
}
}
fullEntry := map[string]any{
"success": true,
"result": map[string]any{
"hasMore": true,
"nextCursor": json.Number("2"),
"report_list": []any{map[string]any{
"report_id": "report-full", "report_template_name": "Weekly",
"senderName": "sender", "senderUserId": "user", "gmtCreate": int(12), "modifyTime": int64(13),
}},
},
}
entries, page, err := reportProjectEntries(fullEntry, operation)
if err != nil || len(entries) != 1 || !page.HasMore || page.Next != "2" || entries[0]["modifiedTime"] != int64(13) {
t.Fatalf("full entry entries=%#v page=%+v err=%v", entries, page, err)
}
if err := reportValidateContinuation(page, 1, operation); err != nil {
t.Fatalf("advancing continuation: %v", err)
}
if err := reportValidateContinuation(reportPageEvidence{}, 0, operation); err != nil {
t.Fatalf("terminal continuation: %v", err)
}
if err := reportValidateContinuation(reportPageEvidence{HasMore: true, Next: "bad"}, 0, operation); err == nil {
t.Fatal("malformed continuation returned success")
}
badOptional := map[string]any{
"success": true, "hasMore": false,
"result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1", "senderName": 1}}},
}
if _, _, err := reportProjectEntries(badOptional, operation); err == nil {
t.Fatal("bad optional entry field returned success")
}
completeDetail := map[string]any{"success": true, "result": map[string]any{
"reportId": "report-detail", "reportName": "Title", "templateName": "Template", "senderName": "Creator",
"create_time": int(4), "modified_time": json.Number("5"),
"report_content": []any{map[string]any{"key": "field", "value": "", "sort": int(1), "type": int64(2)}},
}}
detail, err := reportProjectDetail(completeDetail, operation, "report-detail")
if err != nil || detail["modifiedTime"] != int64(5) || len(detail["fields"].([]map[string]any)) != 1 {
t.Fatalf("complete detail=%#v err=%v", detail, err)
}
for name, fixture := range map[string]map[string]any{
"bad optional string": {"success": true, "result": map[string]any{"reportId": "report-detail", "reportName": 1, "report_content": []any{}}},
"bad optional integer": {"success": true, "result": map[string]any{"reportId": "report-detail", "create_time": "4", "report_content": []any{}}},
"missing field value": {"success": true, "result": map[string]any{"reportId": "report-detail", "report_content": []any{map[string]any{"key": "field", "sort": 1, "type": 2}}}},
} {
if projected, projectErr := reportProjectDetail(fixture, operation, "report-detail"); projectErr == nil {
t.Errorf("%s returned success: %#v", name, projected)
}
}
}
func TestCrossPlatformCoverageReportValidationHelpersCoverAllContracts(t *testing.T) {
if _, err := reportParseISOMillis("start", "not-a-time"); err == nil {
t.Fatal("invalid ISO timestamp returned success")
}
if err := reportValidatePage(-1, 20); err == nil {
t.Fatal("negative cursor returned success")
}
if err := reportValidatePage(0, 0); err == nil {
t.Fatal("zero page size returned success")
}
if err := reportValidatePage(0, 20); err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name, start, end string
maximumDays int
}{
{"bad start", "bad", "2026-07-02T00:00:00+08:00", 20},
{"bad end", "2026-07-01T00:00:00+08:00", "bad", 20},
{"too wide", "2026-07-01T00:00:00+08:00", "2026-08-01T00:00:00+08:00", 20},
} {
if _, _, err := reportValidateRange("start", test.start, "end", test.end, test.maximumDays); err == nil {
t.Errorf("%s returned success", test.name)
}
}
if _, _, err := reportValidateRange("start", "2026-07-01T00:00:00+08:00", "end", "2026-08-01T00:00:00+08:00", 0); err != nil {
t.Fatalf("unbounded valid range: %v", err)
}
}
func TestCrossPlatformCoverageReportExecutorsSuccessErrorsAndZeroCalls(t *testing.T) {
start := "2026-07-01T00:00:00+08:00"
end := "2026-07-02T00:00:00+08:00"
validList := `{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]} ,"hasMore":false}`
inbox := &reportCoverageCaller{responses: map[string][]string{"get_received_report_list": {validList}}}
if _, err := runReportCoverage(t, InboxList, inbox, "--start", start, "--end", end, "--sender-user-ids", "user-1,user-2"); err != nil {
t.Fatal(err)
}
if len(inbox.history) != 1 || len(inbox.history[0].args["senderUserIds"].([]string)) != 2 {
t.Fatalf("inbox args/history=%#v", inbox.history)
}
for _, test := range []struct {
name string
caller *reportCoverageCaller
args []string
}{
{"call error", &reportCoverageCaller{responses: map[string][]string{}}, []string{"--start", start, "--end", end}},
{"projection error", &reportCoverageCaller{responses: map[string][]string{"get_received_report_list": {`{"success":true}`}}}, []string{"--start", start, "--end", end}},
{"stalled cursor", &reportCoverageCaller{responses: map[string][]string{"get_received_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1"}]},"hasMore":true,"nextCursor":2}`}}}, []string{"--start", start, "--end", end, "--cursor", "2"}},
} {
if _, err := runReportCoverage(t, InboxList, test.caller, test.args...); err == nil {
t.Errorf("inbox %s returned success", test.name)
}
}
directInbox := directReportRuntime(t, InboxList, &reportCoverageCaller{responses: map[string][]string{}}, "--start", "bad", "--end", end)
if err := InboxList.Execute(directInbox); err == nil {
t.Fatal("direct inbox invalid range returned success")
}
outboxAll := &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-2","createTime":2}]},"hasMore":true,"nextCursor":3}`}}}
if _, err := runReportCoverage(t, OutboxList, outboxAll,
"--cursor", "2", "--start", start, "--end", end,
"--modified-start", start, "--modified-end", end, "--template-name", " Weekly "); err != nil {
t.Fatal(err)
}
if len(outboxAll.history) != 1 || outboxAll.history[0].args["report_template_name"] != "Weekly" || outboxAll.history[0].args["modifiedStartTime"] == nil {
t.Fatalf("outbox args/history=%#v", outboxAll.history)
}
defaultOutbox := &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {validList}}}
if _, err := runReportCoverage(t, OutboxList, defaultOutbox); err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
caller *reportCoverageCaller
args []string
}{
{"call error", &reportCoverageCaller{responses: map[string][]string{}}, nil},
{"projection error", &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {`{"success":true}`}}}, nil},
{"stalled cursor", &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1"}]},"hasMore":true,"nextCursor":2}`}}}, []string{"--cursor", "2"}},
} {
if _, err := runReportCoverage(t, OutboxList, test.caller, test.args...); err == nil {
t.Errorf("outbox %s returned success", test.name)
}
}
directOutbox := directReportRuntime(t, OutboxList, &reportCoverageCaller{responses: map[string][]string{}}, "--start", "bad", "--end", end)
if err := OutboxList.Execute(directOutbox); err == nil {
t.Fatal("direct outbox bad creation range returned success")
}
directOutbox = directReportRuntime(t, OutboxList, &reportCoverageCaller{responses: map[string][]string{}},
"--modified-start", "bad", "--modified-end", end)
if err := OutboxList.Execute(directOutbox); err == nil {
t.Fatal("direct outbox bad modified range returned success")
}
for _, test := range []struct {
name string
declaration shortcut.Shortcut
args []string
}{
{"outbox invalid page", OutboxList, []string{"--size", "0"}},
{"outbox invalid creation range", OutboxList, []string{"--start", "bad", "--end", end}},
{"outbox valid modified range", OutboxList, []string{"--modified-start", start, "--modified-end", end}},
{"template empty query", TemplateSearch, []string{"--query", " "}},
} {
caller := &reportCoverageCaller{responses: map[string][]string{}}
_, err := runReportCoverage(t, test.declaration, caller, test.args...)
if test.name == "outbox valid modified range" {
if err == nil || len(caller.history) != 1 {
t.Errorf("%s err=%v history=%#v", test.name, err, caller.history)
}
continue
}
if err == nil || len(caller.history) != 0 {
t.Errorf("%s err=%v history=%#v", test.name, err, caller.history)
}
}
for name, caller := range map[string]*reportCoverageCaller{
"call error": {responses: map[string][]string{}},
"projection error": {responses: map[string][]string{"get_available_report_templates": {`{"success":true}`}}},
} {
if _, err := runReportCoverage(t, TemplateSearch, caller, "--query", "weekly"); err == nil {
t.Errorf("template %s returned success", name)
}
}
directTemplate := directReportRuntime(t, TemplateSearch, &reportCoverageCaller{responses: map[string][]string{}}, "--query", " ")
if err := TemplateSearch.Validate(directTemplate); err == nil {
t.Fatal("direct template whitespace query returned success")
}
legacyTemplate := TemplateSearch
legacyTemplate.OutputRollout = output.RolloutLegacyOnly
legacyTemplateCaller := &reportCoverageCaller{responses: map[string][]string{"get_available_report_templates": {`{"success":true,"items":[]}`}}}
if _, err := runReportCoverage(t, legacyTemplate, legacyTemplateCaller, "--query", "zero"); err != nil {
t.Fatalf("legacy template: %v", err)
}
}
func TestCrossPlatformCoverageReportLatestExecutorRemainingBranches(t *testing.T) {
validList := `{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]},"hasMore":false}`
validDetail := `{"success":true,"result":{"report_Id":"report-1","report_content":[]}}`
defaultCaller := &reportCoverageCaller{responses: map[string][]string{
"get_send_report_list": {validList}, "get_report_entry_details": {validDetail},
}}
if _, err := runReportCoverage(t, ReportLatest, defaultCaller, "--keyword", " Weekly "); err != nil {
t.Fatal(err)
}
if defaultCaller.history[0].args["report_template_name"] != "Weekly" {
t.Fatalf("latest keyword args=%#v", defaultCaller.history[0].args)
}
for name, caller := range map[string]*reportCoverageCaller{
"list call error": {responses: map[string][]string{}},
"list projection error": {responses: map[string][]string{
"get_send_report_list": {`{"success":true}`},
}},
"empty candidates": {responses: map[string][]string{
"get_send_report_list": {`{"success":true,"result":{"report_list":[]},"hasMore":false}`},
}},
"detail call error": {responses: map[string][]string{
"get_send_report_list": {validList},
}},
"detail projection error": {responses: map[string][]string{
"get_send_report_list": {validList}, "get_report_entry_details": {`{"success":true}`},
}},
} {
if _, err := runReportCoverage(t, ReportLatest, caller); err == nil {
t.Errorf("latest %s returned success", name)
}
}
directLatest := directReportRuntime(t, ReportLatest, &reportCoverageCaller{responses: map[string][]string{}},
"--start", "bad", "--end", "2026-07-02T00:00:00+08:00")
if err := ReportLatest.Execute(directLatest); err == nil {
t.Fatal("direct latest invalid range returned success")
}
if _, err := reportLatestEntryID([]map[string]any{{"createTime": int64(1), "reportId": 1}}, "report/latest"); err == nil {
t.Fatal("non-string latest report identity returned success")
}
}
func TestCrossPlatformCoverageReportOutputLegacyAndInvalidPagination(t *testing.T) {
command := &cobra.Command{Use: "report-page"}
command.SetOut(io.Discard)
command.SetContext(context.Background())
declaration := shortcut.Shortcut{Service: "report", Product: "report"}
rt := shortcut.RuntimeContextForTest(command, declaration)
output.SetCommandRollout(command, output.RolloutLegacyOnly)
if err := outputReportPage(rt, "reports", []map[string]any{{"reportId": "report-1"}}, reportPageEvidence{HasMore: true, Next: "2"}); err != nil {
t.Fatalf("legacy report page: %v", err)
}
ctx, _ := output.WithResultStore(context.Background())
command.SetContext(ctx)
output.SetCommandRollout(command, output.RolloutUnifiedActive)
if err := outputReportPage(rt, "reports", nil, reportPageEvidence{HasMore: true}); err == nil {
t.Fatal("unified page without continuation returned success")
}
}
+18 -5
View File
@@ -40,6 +40,15 @@ var attendanceSemanticCatalogJSON []byte
//go:embed semantic_catalog_mail.json
var mailSemanticCatalogJSON []byte
//go:embed semantic_catalog_oa.json
var oaSemanticCatalogJSON []byte
//go:embed semantic_catalog_ding.json
var dingSemanticCatalogJSON []byte
//go:embed semantic_catalog_report.json
var reportSemanticCatalogJSON []byte
type semanticCatalogFile struct {
Version int `json:"version"`
Service string `json:"service"`
@@ -69,6 +78,9 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
todoSemanticCatalogJSON,
attendanceSemanticCatalogJSON,
mailSemanticCatalogJSON,
oaSemanticCatalogJSON,
dingSemanticCatalogJSON,
reportSemanticCatalogJSON,
)
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
@@ -130,11 +142,12 @@ func loadSemanticCatalog(raw []byte, out map[string]semanticCatalogRecord) {
command, record.Availability))
}
// A command that was already part of the visible CLI contract cannot be
// hidden in the same feature change merely because Agent publication is
// withdrawn. This narrow fact preserves historical discovery/argv while
// the unavailable Interface and public=false keep it out of Agent routes.
if record.CompatibilityVisible && (record.Public || record.Availability != AvailabilityUnavailable) {
panic(fmt.Sprintf("semantic catalog command %q can be compatibility-visible only when non-public and unavailable", command))
// hidden merely because Agent publication is withdrawn. Compatibility
// visibility owns only historical CLI discovery; availability independently
// records whether that compatibility path still executes. public=false keeps
// both available and unavailable compatibility leaves out of Agent routes.
if record.CompatibilityVisible && record.Public {
panic(fmt.Sprintf("semantic catalog command %q cannot be both public and compatibility-visible", command))
}
key := publicCatalogKey(source.Service, command)
if _, exists := out[key]; exists {
@@ -136,21 +136,6 @@ func TestCrossPlatformCoverageSemanticCatalogRejectsInvalidRecords(t *testing.T)
}
}
}`,
"compatibility-visible available": `{
"version": 1,
"service": "chat",
"default_availability": "available",
"shortcuts": {
"+messages": {
"disposition": "semantic_adapter",
"semantic_delta": "reviewed",
"risk": "read",
"public": false,
"compatibility_visible": true,
"reviewed": true
}
}
}`,
}
original := semanticCatalogJSON
t.Cleanup(func() { semanticCatalogJSON = original })
@@ -210,6 +195,28 @@ func TestCrossPlatformCoverageCompatibilityVisibleStaysNonPublic(t *testing.T) {
if InPublicCatalog(item.Service, item.Command) {
t.Fatal("compatibility-visible unavailable shortcut entered the public catalog")
}
available := map[string]semanticCatalogRecord{}
loadSemanticCatalog([]byte(`{
"version":1,
"service":"compatibility-test",
"default_availability":"unavailable",
"shortcuts":{
"+legacy":{
"disposition":"semantic_adapter",
"semantic_delta":"historical CLI path remains executable but is not Agent-public",
"risk":"write",
"availability":"available",
"public":false,
"compatibility_visible":true,
"reviewed":true
}
}
}`), available)
record := available[publicCatalogKey("compatibility-test", "+legacy")]
if record.Public || !record.CompatibilityVisible || record.Availability != AvailabilityAvailable {
t.Fatalf("available compatibility record = %#v", record)
}
}
func TestCrossPlatformCoverageRuntimeReadDataBranches(t *testing.T) {
@@ -0,0 +1,48 @@
{
"version": 1,
"service": "ding",
"default_availability": "unavailable",
"shortcuts": {
"+list": {
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、result.dingMessages、稳定 openDingId 与 hasMore/nextCursor;exact Shortcut 已完成已知非空与保证零项证明,但真实续页返回停滞 cursor,严格实现拒绝虚假分页并保持不可用。",
"risk": "read",
"public": false,
"compatibility_visible": true,
"reviewed": true
},
"+receiver-status": {
"disposition": "semantic_adapter",
"semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;current HEAD exact Shortcut 与 owning atomic/raw 的请求身份、1 项结果和完整接收行集合一致。",
"risk": "read",
"availability": "available",
"public": true,
"reviewed": true
},
"+send-personal": {
"disposition": "semantic_adapter",
"semantic_delta": "历史 CLI 兼容入口在用户确认后保持可执行,并修正 app/sms/call 到 APP/SMS/PHONE 的下游枚举映射;因接收项没有稳定接收人身份且撤回无可查询终态,不进入 Agent 公共发现。",
"risk": "write",
"availability": "available",
"public": false,
"compatibility_visible": true,
"reviewed": true
},
"+send-by-message": {
"disposition": "semantic_adapter",
"semantic_delta": "覆盖基于消息的应用内、短信和电话 DING并映射 lark-cli 三种 urgent 任务;隔离 APP fixture 已证明源消息、稳定 DING 回执和目标 DING 读回,但接收人身份与撤回终态仍不可验证,保持不可用。",
"risk": "write",
"public": false,
"reviewed": true
},
"+recall-personal": {
"disposition": "semantic_adapter",
"semantic_delta": "历史 CLI 兼容入口在用户确认后保持可执行;个人 DING 撤回只返回布尔成功且不回显终态,不能作为 Agent 可验证写能力,因此不进入公共发现。",
"risk": "high-risk-write",
"availability": "available",
"public": false,
"compatibility_visible": true,
"reviewed": true
}
}
}
@@ -0,0 +1,96 @@
{
"version": 1,
"service": "oa",
"default_availability": "unavailable",
"shortcuts": {
"+list-executed": {
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、result.values、稳定 processInstanceId 与 hasMore;known-nonempty exact/raw 的 7 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,收紧后拒绝,因此不进入 Agent 公开发现。",
"risk": "read",
"availability": "available",
"public": false,
"compatibility_visible": true,
"reviewed": true
},
"+list-submitted": {
"disposition": "semantic_adapter",
"semantic_delta": "严格返回已发起审批实例及页完整性;known-nonempty exact/raw 的 14 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,不能把空数组推断为终页,因此不进入 Agent 公开发现。",
"risk": "read",
"availability": "available",
"public": false,
"compatibility_visible": true,
"reviewed": true
},
"+my-initiated": {
"disposition": "alias_internal",
"semantic_delta": "保留历史 initiated 字段且不回退原始响应;known-nonempty exact/raw 的 14 个稳定身份一致,但复用的 guaranteed-zero raw 响应缺少 hasMore,故与主列表一并保持非公开。",
"risk": "read",
"availability": "available",
"primary": "+list-submitted",
"public": false,
"compatibility_visible": true,
"reviewed": true
},
"+search-forms": {
"disposition": "semantic_adapter",
"semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。",
"risk": "read",
"availability": "available",
"public": true,
"reviewed": true
},
"+done-approvals": {
"disposition": "alias_internal",
"semantic_delta": "保留旧的首屏已处理审批摘要;已验证严格非空结果,但不提供页码,新的 Agent 路由统一使用 +list-executed。",
"risk": "read",
"availability": "available",
"primary": "+list-executed",
"public": false,
"reviewed": true
},
"+list-forms": {
"disposition": "semantic_adapter",
"semantic_delta": "真实非空页不返回 continuation,且 cursor 0/1 的集合高度重叠;严格实现拒绝缺失、畸形、停滞或倒退游标,下游修复前不进入 Agent 公开发现。",
"risk": "read",
"availability": "available",
"public": false,
"compatibility_visible": true,
"reviewed": true
},
"+list-pending": {
"disposition": "semantic_adapter",
"semantic_delta": "严格待办投影与分页已实现;exact Shortcut 与同参数 atomic/raw 均隔离到 success=true 但 result.values=null,错型集合被拒绝,且当前没有安全非空待审批 fixture,因此不进入 Agent 公开发现。",
"risk": "read",
"availability": "available",
"public": false,
"compatibility_visible": true,
"reviewed": true
},
"+pending": {
"disposition": "alias_internal",
"semantic_delta": "历史首屏待办入口路由到 +list-pending;同一真实响应的 result.values 为 null 而不是合法空数组,且当前没有已知非空安全 fixture,因此别名与主入口均保持非公开。",
"risk": "read",
"availability": "available",
"primary": "+list-pending",
"public": false,
"reviewed": true
},
"+list-cc": {
"disposition": "semantic_adapter",
"semantic_delta": "严格抄送列表与分页校验已实现;exact Shortcut 和同参数 atomic/raw 隔离到错型业务状态与 null 集合,不能把响应解释为空页,且当前没有安全非空抄送 fixture,因此不进入 Agent 公开发现。",
"risk": "read",
"availability": "available",
"public": false,
"compatibility_visible": true,
"reviewed": true
},
"+approve-by": {
"disposition": "primary_smart",
"semantic_delta": "唯一定位实例和任务、确认后写入、精确任务读回的严格编排已实现;缺少可撤销的待办审批 fixture 和可靠清理路径,因此不进入 Agent 公开发现。",
"risk": "high-risk-write",
"availability": "available",
"public": false,
"reviewed": true
}
}
}
@@ -0,0 +1,39 @@
{
"version": 1,
"service": "report",
"default_availability": "unavailable",
"shortcuts": {
"+inbox-list": {
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;current HEAD exact 与 owning atomic 同场景已知页均为 20 项、稳定身份集合和 next cursor 一致,独立未来范围均为 0 且明确终止。终止页回显 cursor 只作已验证收据且不发布 next_token。",
"risk": "read",
"availability": "available",
"public": true,
"reviewed": true
},
"+outbox-list": {
"disposition": "semantic_adapter",
"semantic_delta": "严格验证发件箱集合、稳定 reportId 和分页终止证据;current HEAD exact 与 owning atomic 同场景已知页均为 1 项且身份一致,独立未来范围均为 0 并明确终止。",
"risk": "read",
"availability": "available",
"public": true,
"reviewed": true
},
"+template-search": {
"disposition": "semantic_adapter",
"semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;current HEAD exact 与 owning atomic 完整集合过滤的已知结果均为 1 且身份一致,随机 UUID 查询均为 0。",
"risk": "read",
"availability": "available",
"public": true,
"reviewed": true
},
"+report-latest": {
"disposition": "primary_smart",
"semantic_delta": "完整验证默认最近 20 天或显式不超过 20 天的发件箱;current HEAD exact 所选稳定 reportId 与 owning atomic 候选和精确详情身份一致,严格详情字段计数双层均为 3。",
"risk": "read",
"availability": "available",
"public": true,
"reviewed": true
}
}
}
-277
View File
@@ -1,277 +0,0 @@
// 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.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package smart
import (
"fmt"
"strconv"
"strings"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
// Approve: agree to ONE of MY pending OA approvals by matching a keyword in its
// title / order number, in a single command (钉钉原生编排能力).
//
// Steps (all tool names + params copied verbatim from helpers/oa.go):
//
// 1. list my pending approvals via list_pending_approvals (starTime / endTime
// as float64 millis over a recent window, plus query=keyword for
// server-side narrowing — mirroring `dws oa approval list-pending`);
//
// 2. scan the returned instances and keep the ones whose title / order number
// contains --keyword: none → "没找到待审批单据", many → list candidates
// (标题 + processInstanceId) so the caller can be more specific, exactly one
// → take its processInstanceId;
//
// 3. resolve the pending taskId for that instance via list_pending_tasks
// (processInstanceId), then agree via approve_processInstance
// (processInstanceId + taskId as float64, optional remark) —
// mirroring `dws oa approval tasks` + `dws oa approval approve`.
//
// dws oa +approve-by --keyword 报销
// dws oa +approve-by --keyword 出差单 --comment "同意"
var Approve = shortcut.Shortcut{
Service: "oa",
Command: "+approve-by",
Product: "oa",
Description: "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)",
Intent: "当你只记得某张待你审批单据的标题或单号关键词、想直接把它审批通过,却不想先翻待办列表、" +
"复制审批实例 ID 再查任务 ID 时使用;内部先拉取你近三个月待处理的审批单,按标题/单号包含关键词匹配:" +
"没匹配到会提示「没找到待审批单据」,匹配到多条会列出候选(标题+实例ID)让你写得更精确," +
"唯一命中时先取出它的待审批任务 ID,再以「同意」提交。这会真实提交审批决策且不可撤回,请确认关键词足够精确。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "keyword", Type: shortcut.FlagString, Desc: "待审批单据的单号或标题关键词", Required: true},
{Name: "comment", Type: shortcut.FlagString, Desc: "审批意见(可选)", Required: false},
},
Tips: []string{
`dws oa +approve-by --keyword 报销`,
`dws oa +approve-by --keyword 出差单 --comment "同意"`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
keyword := strings.TrimSpace(rt.Str("keyword"))
if keyword == "" {
return apperrors.NewValidation("请用 --keyword 提供待审批单据的单号或标题关键词")
}
// Step 1 — list my pending approvals. starTime/endTime are float64
// milliseconds and query narrows server-side, mirroring
// helpers.list-pending (list_pending_approvals). Window: last ~90 days.
now := time.Now()
listArgs := map[string]any{
"starTime": float64(now.AddDate(0, 0, -90).UnixMilli()),
"endTime": float64(now.UnixMilli()),
"query": keyword,
}
data, err := rt.CallMCPData("oa", "list_pending_approvals", listArgs)
if err != nil {
return err
}
// Step 2 — match by title / order number substring.
matches := shortcutApproveMatch(data, keyword)
switch {
case len(matches) == 0:
return apperrors.NewValidation(fmt.Sprintf(
"没找到待审批单据:待我处理的审批里没有标题/单号包含 %q 的单据。", keyword))
case len(matches) > 1:
return apperrors.NewValidation(fmt.Sprintf(
"%q 匹配到 %d 条待审批单据,请用更精确的关键词,或用 `dws oa approval approve --instance-id --task-id` 指定:%s",
keyword, len(matches), strings.Join(shortcutApproveLabels(matches), ";")))
}
instanceID := matches[0].instanceID
// Step 3a — resolve the pending taskId for this instance via
// list_pending_tasks (processInstanceId), mirroring `dws oa approval tasks`.
tasksData, err := rt.CallMCPData("oa", "list_pending_tasks", map[string]any{
"processInstanceId": instanceID,
})
if err != nil {
return err
}
taskID := shortcutApproveTaskID(tasksData)
if taskID == "" {
return apperrors.NewValidation(fmt.Sprintf(
"没能拿到 %q 的待审批任务 ID,请用 `dws oa approval tasks --instance-id %s` 查看后手动 approve。",
matches[0].title, instanceID))
}
// Step 3b — agree. taskId is passed as float64 and remark is optional,
// mirroring helpers.approve (approve_processInstance).
taskIDNum, err := strconv.ParseFloat(taskID, 64)
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("任务 ID %q 不是合法数字,无法审批。", taskID))
}
approveArgs := map[string]any{
"processInstanceId": instanceID,
"taskId": taskIDNum,
}
if c := strings.TrimSpace(rt.Str("comment")); c != "" {
approveArgs["remark"] = c
}
return rt.CallMCP("approve_processInstance", approveArgs)
},
}
// shortcutApproveInstance is the minimal identity we need from a pending-approval
// entry: its processInstanceId (fed to approve/tasks) and a human title/order
// number for matching + disambiguation.
type shortcutApproveInstance struct {
instanceID string
title string
}
// shortcutApproveMatch walks a list_pending_approvals response and returns the
// instances whose title / order number contains keyword (case-insensitive). The
// gateway wraps the list under one of several common container keys, so we probe
// them defensively (mirroring latestMinutesItems / shortcutTodoCards).
func shortcutApproveMatch(data map[string]any, keyword string) []shortcutApproveInstance {
items := shortcutApproveItems(data)
kw := strings.ToLower(keyword)
var out []shortcutApproveInstance
seen := map[string]bool{}
for _, m := range items {
id := shortcutApproveInstanceID(m)
if id == "" || seen[id] {
continue
}
title := shortcutApproveTitle(m)
// Match on either the visible title/order number or the instance id.
if !strings.Contains(strings.ToLower(title), kw) &&
!strings.Contains(strings.ToLower(id), kw) {
continue
}
seen[id] = true
if title == "" {
title = id
}
out = append(out, shortcutApproveInstance{instanceID: id, title: title})
}
return out
}
// shortcutApproveItems pulls the list of pending-approval entries out of the
// response, probing common container shapes before scanning for a bare list.
func shortcutApproveItems(data map[string]any) []map[string]any {
for _, key := range []string{"result", "list", "instances", "items", "data", "records", "processList"} {
if arr, ok := data[key].([]any); ok {
return shortcutApproveToMaps(arr)
}
if inner, ok := data[key].(map[string]any); ok {
for _, k2 := range []string{"list", "instances", "items", "records", "result", "processList"} {
if arr, ok := inner[k2].([]any); ok {
return shortcutApproveToMaps(arr)
}
}
}
}
return nil
}
func shortcutApproveToMaps(arr []any) []map[string]any {
out := make([]map[string]any, 0, len(arr))
for _, it := range arr {
if m, ok := it.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// shortcutApproveInstanceID reads an entry's processInstanceId (the value the
// helper feeds to --instance-id), tolerating a few common field names and both
// string / numeric JSON encodings.
func shortcutApproveInstanceID(m map[string]any) string {
for _, key := range []string{"processInstanceId", "processInstanceID", "instanceId", "businessId"} {
if s := shortcutApproveStr(m[key]); s != "" {
return s
}
}
return ""
}
// shortcutApproveTitle reads a human-facing title / order number for matching
// and disambiguation, probing common field names.
func shortcutApproveTitle(m map[string]any) string {
for _, key := range []string{"title", "processTitle", "name", "subject", "businessId", "processCode"} {
if s := shortcutApproveStr(m[key]); s != "" {
return s
}
}
return ""
}
// shortcutApproveTaskID walks a list_pending_tasks response and returns the
// first pending taskId (the value the helper feeds to --task-id).
func shortcutApproveTaskID(data map[string]any) string {
items := shortcutApproveItems(data)
// list_pending_tasks may also return a bare {"result": [...]}/task list; the
// probe above covers those. Some gateways return a single task object.
if len(items) == 0 {
if id := shortcutApproveTaskIDField(data); id != "" {
return id
}
if r, ok := data["result"].(map[string]any); ok {
if id := shortcutApproveTaskIDField(r); id != "" {
return id
}
}
return ""
}
for _, m := range items {
if id := shortcutApproveTaskIDField(m); id != "" {
return id
}
}
return ""
}
func shortcutApproveTaskIDField(m map[string]any) string {
for _, key := range []string{"taskId", "taskID", "activityId"} {
if s := shortcutApproveStr(m[key]); s != "" {
return s
}
}
return ""
}
// shortcutApproveStr coerces a JSON value into a string, tolerating string and
// numeric encodings (taskId / instance id can arrive as either).
func shortcutApproveStr(v any) string {
switch t := v.(type) {
case string:
return t
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
case int64:
return strconv.FormatInt(t, 10)
}
return ""
}
func shortcutApproveLabels(items []shortcutApproveInstance) []string {
out := make([]string, 0, len(items))
for _, c := range items {
out = append(out, fmt.Sprintf("%s(instanceId=%s)", c.title, c.instanceID))
}
return out
}
func init() {
shortcut.Register(Approve)
}
-109
View File
@@ -1,109 +0,0 @@
// 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.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package smart
import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
// DoneApprovals: READ-ONLY list of the approval tasks I have already processed
// (my approval history), projected to clean fields. Unlike `oa +approve-by`
// (which agrees to an approval) or `oa +pending` (which lists what still waits
// for me), this shortcut only looks back at what I've already handled — it never
// approves / rejects / mutates anything.
//
// Steps (tool name + param keys copied verbatim from helpers/oa.go):
//
// 1. list my done approvals via get_done_tasks with pageNumber / pageSize as
// float64, mirroring `dws oa approval list-executed`; optional --limit maps
// to pageSize (float64). This tool takes NO time window (unlike
// list_pending_approvals) — only pageNumber/pageSize/query.
//
// 2. defensively unwrap the returned instance list (reusing shortcutApproveItems
// from approve.go, multiple candidate container keys + one nested level) and
// project each entry to a readable shape {title, originatorName,
// processInstanceId, createTime}, every field probed across candidate keys.
//
// 3. if nothing has been processed, report "没有已处理的审批记录" instead of an
// empty raw dump.
//
// dws oa +done-approvals
// dws oa +done-approvals --limit 10
var DoneApprovals = shortcut.Shortcut{
Service: "oa",
Command: "+done-approvals",
Product: "oa",
Description: "只读列出我已处理过的审批任务(审批历史)并投影为可读列表",
Intent: "当你只想快速回看「我已经处理过(同意/拒绝)」的审批任务历史——每条的标题、发起人、审批实例 ID 和创建时间——" +
"而不想拿到一大坨原始字段时使用;内部拉取你的已办审批单,再在本地投影出可读字段。" +
"这是纯只读操作,只做列出与本地投影,绝不会同意、拒绝或以任何方式提交/修改任何审批;" +
"若没有任何已处理记录则提示「没有已处理的审批记录」。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)", Required: false},
},
Tips: []string{
`dws oa +done-approvals`,
`dws oa +done-approvals --limit 10`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — list my done approvals. pageNumber/pageSize are float64. Keys
// copied verbatim from helpers.list-executed (get_done_tasks). This tool
// carries no time window; pageNumber defaults to the first page.
// pageSize is required by get_done_tasks — the 1:1 list-executed always
// sends one (a missing/zero pageSize triggers a backend business error),
// so default to 20 and let --limit override.
params := map[string]any{
"pageNumber": float64(1),
"pageSize": float64(20),
}
if rt.Changed("limit") {
if n := rt.Int("limit"); n > 0 {
params["pageSize"] = float64(n)
}
}
data, err := rt.CallMCPData("oa", "get_done_tasks", params)
if err != nil {
return err
}
// Step 2 — project entries. shortcutApproveItems (approve.go) defensively
// unwraps the list container (result/list/instances/items/data/records/
// processList, incl. one nested level).
items := shortcutApproveItems(data)
results := make([]map[string]any, 0, len(items))
for _, m := range items {
results = append(results, map[string]any{
// shortcutApproveTitle probes title/processTitle/name/subject/…
"title": shortcutApproveTitle(m),
// shortcutApproveInstanceID probes processInstanceId/instanceId/…
"processInstanceId": shortcutApproveInstanceID(m),
"originatorName": pendingApprovalsOriginator(m),
"createTime": pendingApprovalsCreateTime(m),
})
}
// Step 3 — empty result guard.
if len(results) == 0 {
return apperrors.NewValidation("没有已处理的审批记录")
}
return rt.Output(map[string]any{"count": len(results), "done": results})
},
}
func init() {
shortcut.Register(DoneApprovals)
}
-247
View File
@@ -1,247 +0,0 @@
// 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.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package smart
import (
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
// MyInitiated: list the OA approval instances *I* have initiated (submitted), in
// one step.
//
// Steps:
//
// 1. call get_submitted_instances on the OA server — the exact tool + parameter
// names (pageNumber / pageSize as float64, optional query) used by
// helpers.approvalSubmittedListCmd ("dws oa approval list-submitted"). page
// and limit default to the same first-page values (page 1, limit 20).
//
// 2. defensively locate the instance list inside the response (probing common
// container keys, incl. a nested result/data object) and project each item
// down to {title, businessId, status, processInstanceId} with multiple
// candidate keys per field. When no recognisable list is found we print the
// raw payload so nothing is silently lost.
//
// 3. print via rt.Output so it honours --format / --jq / --fields.
//
// Read-only: it only lists and reshapes my submitted approvals, it never
// approves, rejects, revokes or mutates anything.
//
// dws oa +my-initiated
// dws oa +my-initiated --query 报销
// dws oa +my-initiated --page 2 --limit 50
var MyInitiated = shortcut.Shortcut{
Service: "oa",
Command: "+my-initiated",
Product: "oa",
Description: "列出我发起(提交)的审批单据",
Intent: "当你想快速看清自己发起(提交)过哪些 OA 审批单据、方便跟进它们的进展时使用;" +
"内部直接拉取当前用户已发起的审批实例列表(等价于 dws oa approval list-submitted)," +
"再在本地把每条单据投影成标题、单号(businessId)、状态和审批实例 ID(processInstanceId) 四个关键字段。" +
"可用 --query 按关键字过滤、--page/--limit 翻页(默认第 1 页、每页 20 条)。" +
"这是纯只读操作,只做列表与本地投影,不会同意、拒绝、撤销或修改任何审批单据;若没有发起过审批则返回空列表。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "shortcut_my_initiated",
CanonicalPath: "oa.shortcut_my_initiated",
CLIPath: "oa +my-initiated",
PrimaryCLIPath: "oa +my-initiated",
},
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{"当你想快速看清自己发起(提交)过哪些 OA 审批单据、方便跟进它们的进展时使用;内部直接拉取当前用户已发起的审批实例列表(等价于 dws oa approval list-submitted),再在本地把每条单据投影成标题、单号(businessId)、状态和审批实例 ID(processInstanceId) 四个关键字段。可用 --query 按关键字过滤、--page/--limit 翻页(默认第 1 页、每页 20 条)。这是纯只读操作,只做列表与本地投影,不会同意、拒绝、撤销或修改任何审批单据;若没有发起过审批则返回空列表。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{
"dws oa +my-initiated",
"dws oa +my-initiated --query 报销",
},
},
},
Flags: []shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索(可选)", Required: false},
{Name: "page", Type: shortcut.FlagInt, Desc: "分页页码(可选,默认 1)", Default: "1", Required: false},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页大小(可选,默认 20)", Default: "20", Required: false},
},
Tips: []string{
`dws oa +my-initiated`,
`dws oa +my-initiated --query 报销`,
`dws oa +my-initiated --page 2 --limit 50`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — list my submitted approval instances. pageNumber/pageSize are
// float64 and query is optional, mirroring helpers.approvalSubmittedListCmd.
page := rt.Int("page")
if page <= 0 {
page = 1
}
limit := rt.Int("limit")
if limit <= 0 {
limit = 20
}
params := map[string]any{
"pageNumber": float64(page),
"pageSize": float64(limit),
}
if q := strings.TrimSpace(rt.Str("query")); q != "" {
params["query"] = q
}
data, err := rt.CallMCPData("oa", "get_submitted_instances", params)
if err != nil {
return err
}
// Step 2 — project the instance list; fall back to the raw payload when we
// cannot locate a recognisable list.
items := myInitiatedItems(data)
if len(items) == 0 {
return rt.Output(data)
}
results := make([]map[string]any, 0, len(items))
for _, m := range items {
results = append(results, map[string]any{
"title": myInitiatedTitle(m),
"businessId": myInitiatedBusinessID(m),
"status": myInitiatedStatus(m),
"processInstanceId": myInitiatedInstanceID(m),
})
}
// Step 3 — print the projected list.
return rt.Output(map[string]any{"initiated": results})
},
}
// myInitiatedItems locates the instance list inside a get_submitted_instances
// response, probing common container keys at the top level and nested under a
// result/data object. Returns nil when no list is found.
func myInitiatedItems(data map[string]any) []map[string]any {
if data == nil {
return nil
}
keys := []string{"list", "instances", "processInstances", "items", "data", "records", "result", "rows"}
for _, key := range keys {
if arr, ok := data[key].([]any); ok {
return myInitiatedToMaps(arr)
}
if inner, ok := data[key].(map[string]any); ok {
for _, k2 := range []string{"list", "instances", "processInstances", "items", "data", "records", "rows"} {
if arr, ok := inner[k2].([]any); ok {
return myInitiatedToMaps(arr)
}
}
}
}
return nil
}
func myInitiatedToMaps(arr []any) []map[string]any {
out := make([]map[string]any, 0, len(arr))
for _, it := range arr {
if m, ok := it.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// myInitiatedTitle reads an instance's human-readable title, tolerating the
// common title keys the gateway may use.
func myInitiatedTitle(m map[string]any) any {
for _, key := range []string{"title", "subject", "name", "processName", "formName", "instanceTitle"} {
if v := myInitiatedString(m[key]); v != "" {
return v
}
}
return nil
}
// myInitiatedBusinessID reads an instance's business / order number, preferring
// the customer-visible business id and falling back to related identifiers.
func myInitiatedBusinessID(m map[string]any) any {
for _, key := range []string{"businessId", "bizId", "orderNo", "orderNumber", "number", "serialNumber"} {
if v := myInitiatedString(m[key]); v != "" {
return v
}
}
return nil
}
// myInitiatedStatus reads an instance's current status/result, tolerating both
// the running status and the final result keys.
func myInitiatedStatus(m map[string]any) any {
for _, key := range []string{"status", "statusText", "processStatus", "instanceStatus", "result", "approvalResult"} {
if v := myInitiatedString(m[key]); v != "" {
return v
}
}
return nil
}
// myInitiatedInstanceID reads an instance's processInstanceId, tolerating the
// common id keys.
func myInitiatedInstanceID(m map[string]any) any {
for _, key := range []string{"processInstanceId", "instanceId", "processInstanceID", "id", "bizId"} {
if v := myInitiatedString(m[key]); v != "" {
return v
}
}
return nil
}
// myInitiatedString coerces a scalar JSON value to a trimmed string, returning
// "" for nil / non-scalar / empty values.
func myInitiatedString(v any) string {
switch typed := v.(type) {
case string:
return strings.TrimSpace(typed)
case float64:
// approval ids/numbers may arrive as JSON numbers; render without a
// trailing decimal point when integral.
return strconv.FormatFloat(typed, 'f', -1, 64)
case int64:
return strconv.FormatInt(typed, 10)
case int:
return strconv.Itoa(typed)
case bool:
if typed {
return "true"
}
return "false"
default:
return ""
}
}
func init() {
shortcut.Register(MyInitiated)
}
@@ -1,128 +0,0 @@
// 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.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package smart
import (
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
// PendingApprovals: READ-ONLY list of the approvals waiting for ME, projected to
// clean fields. Unlike `oa +approve-by` (which actually agrees to an approval),
// this shortcut only looks — it never approves / rejects / mutates anything.
//
// Steps (tool name + param keys copied verbatim from helpers/oa.go):
//
// 1. list my pending approvals via list_pending_approvals with starTime /
// endTime as float64 milliseconds over a recent (~90 day) window, mirroring
// `dws oa approval list-pending`; optional --limit maps to pageSize (float64).
//
// 2. defensively unwrap the returned instance list (multiple candidate
// container keys, one nested level) and project each entry to a readable
// shape {title, originatorName, processInstanceId, createTime} — every field
// probed across several candidate keys.
//
// 3. if nothing is pending, report "当前没有待我审批的任务" instead of an empty
// raw dump.
//
// dws oa +pending
// dws oa +pending --limit 10
var PendingApprovals = shortcut.Shortcut{
Service: "oa",
Command: "+pending",
Product: "oa",
Description: "只读列出待我审批的审批任务并投影为可读列表(只看不批)",
Intent: "当你只想快速看一眼「待我审批」的审批任务清单——每条的标题、发起人、审批实例 ID 和创建时间——" +
"而不想拿到一大坨原始字段时使用;内部拉取你近三个月待处理的审批单,再在本地投影出可读字段。" +
"这是纯只读操作,只做列出与本地投影,绝不会同意、拒绝或以任何方式提交/修改审批(要一键通过请改用 `dws oa +approve-by`);" +
"若当前没有待你审批的任务则提示「当前没有待我审批的任务」。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)", Required: false},
},
Tips: []string{
`dws oa +pending`,
`dws oa +pending --limit 10`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — list my pending approvals. starTime/endTime are float64
// milliseconds; pageSize is optional. Keys copied verbatim from
// helpers.list-pending (list_pending_approvals). Window: last ~90 days.
now := time.Now()
params := map[string]any{
"starTime": float64(now.AddDate(0, 0, -90).UnixMilli()),
"endTime": float64(now.UnixMilli()),
}
if rt.Changed("limit") {
if n := rt.Int("limit"); n > 0 {
params["pageSize"] = float64(n)
}
}
data, err := rt.CallMCPData("oa", "list_pending_approvals", params)
if err != nil {
return err
}
// Step 2 — project entries. shortcutApproveItems (approve.go) defensively
// unwraps the list container (result/list/instances/items/data/records/
// processList, incl. one nested level).
items := shortcutApproveItems(data)
results := make([]map[string]any, 0, len(items))
for _, m := range items {
results = append(results, map[string]any{
// shortcutApproveTitle probes title/processTitle/name/subject/…
"title": shortcutApproveTitle(m),
// shortcutApproveInstanceID probes processInstanceId/instanceId/…
"processInstanceId": shortcutApproveInstanceID(m),
"originatorName": pendingApprovalsOriginator(m),
"createTime": pendingApprovalsCreateTime(m),
})
}
// Step 3 — empty result guard.
if len(results) == 0 {
return apperrors.NewValidation("当前没有待我审批的任务")
}
return rt.Output(map[string]any{"count": len(results), "pending": results})
},
}
// pendingApprovalsOriginator reads the initiator's display name, tolerating the
// common field names the gateway may use.
func pendingApprovalsOriginator(m map[string]any) string {
for _, key := range []string{"originatorName", "originatorUserName", "creatorName", "creator", "applicantName", "userName"} {
if s := shortcutApproveStr(m[key]); s != "" {
return s
}
}
return ""
}
// pendingApprovalsCreateTime reads a pending approval's create time, returning
// the raw value (usually epoch millis) under whichever candidate key is present.
func pendingApprovalsCreateTime(m map[string]any) any {
for _, key := range []string{"createTime", "gmtCreate", "createTimeStr", "startTime", "createdAt"} {
if v, ok := m[key]; ok && v != nil {
return v
}
}
return nil
}
func init() {
shortcut.Register(PendingApprovals)
}
-220
View File
@@ -1,220 +0,0 @@
// 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.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package smart
import (
"strconv"
"strings"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
// ReportLatest: fetch the detail of MY most recently submitted report (日志/汇报)
// in one step (DingTalk-native).
//
// Steps:
//
// 1. list the reports I sent via get_send_report_list. cursor/size/startTime/
// endTime mirror helpers.runReportSent — the server caps a single query span
// at 20 days, so we default to the last-20-days window just like the helper.
// An optional --keyword is passed through as report_template_name (the same
// filter the helper exposes as --template-name).
//
// 2. locate the newest entry: the item with the largest create time
// (createTime/gmtCreate/sendTime), falling back to the first item that
// carries a reportId (lists come back newest-first).
//
// 3. print that report's body via get_report_entry_details (report_id param
// mirrors helpers.runReportDetail). If nothing carries a reportId we fall
// back to printing the picked list row via rt.Output.
//
// If I have not sent any report in the window it reports "暂无日志" instead of
// failing obscurely.
//
// dws report +report-latest
// dws report +report-latest --keyword 日报
var ReportLatest = shortcut.Shortcut{
Service: "report",
Command: "+report-latest",
Product: "report",
Description: "取我最新提交的一篇日志/汇报详情(钉钉原生)",
Intent: "当你只想快速看回自己最近发出的一篇日志/汇报,却不想先翻发件箱列表、复制 reportId 再查详情时使用;" +
"内部先列出你近期发出的日志(默认最近 20 天,服务端单次查询跨度上限 20 天;可用 --keyword 按日志模板名过滤)," +
"自动挑出创建时间最新的一条,再拉取它的正文详情(字段明细、发送人、时间、钉钉跳转链接等)。" +
"这是只读操作,不会创建或修改任何日志;若这段时间内你没有发出过日志则提示「暂无日志」。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "keyword", Type: shortcut.FlagString, Desc: "按日志模板名过滤(可选,对应 report outbox list 的 --template-name)", Required: false},
},
Tips: []string{
`dws report +report-latest`,
`dws report +report-latest --keyword 日报`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — list the reports I sent. Mirror helpers.runReportSent: the
// server caps a single get_send_report_list query at a 20-day span, so
// default startTime/endTime to the last-20-days window. cursor/size are
// floats and report_template_name is the optional keyword filter.
now := time.Now()
startMs := now.AddDate(0, 0, -20).Truncate(24 * time.Hour).UnixMilli()
endMs := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).UnixMilli()
listArgs := map[string]any{
"cursor": float64(0),
"size": float64(20),
"startTime": float64(startMs),
"endTime": float64(endMs),
}
if kw := strings.TrimSpace(rt.Str("keyword")); kw != "" {
listArgs["report_template_name"] = kw
}
data, err := rt.CallMCPData("report", "get_send_report_list", listArgs)
if err != nil {
return err
}
// Step 2 — pick the newest sent report.
row, reportID := shortcutReportLatestPick(data)
if row == nil {
return apperrors.NewValidation("暂无日志")
}
// Step 3 — print its detail (report_id mirrors helpers.runReportDetail).
// If we somehow could not read a reportId, fall back to the list row.
if reportID == "" {
return rt.Output(row)
}
return rt.CallMCP("get_report_entry_details", map[string]any{
"report_id": reportID,
})
},
}
// shortcutReportLatestPick walks a get_send_report_list response, finds the
// report entries, and returns the newest one (largest create time, falling back
// to the first entry that carries a reportId) together with its reportId.
// Returns (nil, "") when there are no entries.
func shortcutReportLatestPick(data map[string]any) (map[string]any, string) {
items := shortcutReportLatestItems(data)
if len(items) == 0 {
return nil, ""
}
var firstItem map[string]any
firstID := ""
var bestItem map[string]any
bestID := ""
var bestTime int64
haveTime := false
for _, m := range items {
id := shortcutReportLatestID(m)
if id == "" {
continue
}
if firstItem == nil {
firstItem = m
firstID = id
}
if t, ok := shortcutReportLatestCreateMillis(m); ok {
if !haveTime || t > bestTime {
haveTime = true
bestTime = t
bestItem = m
bestID = id
}
}
}
if haveTime && bestItem != nil {
return bestItem, bestID
}
if firstItem != nil {
return firstItem, firstID
}
// No entry carried a reportId; still surface the first raw item so the
// caller can print something rather than mis-report "暂无日志".
return items[0], ""
}
// shortcutReportLatestItems pulls the report entries out of a
// get_send_report_list response, probing the same container keys the helper's
// findReportListItems uses (list/items/data/reports/records/report_list/
// reportList nested under the top level or under result).
func shortcutReportLatestItems(data map[string]any) []map[string]any {
keys := []string{"list", "items", "data", "reports", "records", "report_list", "reportList", "result"}
for _, key := range keys {
if arr, ok := data[key].([]any); ok {
return shortcutReportLatestToMaps(arr)
}
if inner, ok := data[key].(map[string]any); ok {
for _, k2 := range []string{"list", "items", "data", "reports", "records", "report_list", "reportList"} {
if arr, ok := inner[k2].([]any); ok {
return shortcutReportLatestToMaps(arr)
}
}
}
}
return nil
}
func shortcutReportLatestToMaps(arr []any) []map[string]any {
out := make([]map[string]any, 0, len(arr))
for _, it := range arr {
if m, ok := it.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// shortcutReportLatestID reads an entry's reportId, mirroring the key aliases in
// helpers.reportIDFromMap.
func shortcutReportLatestID(m map[string]any) string {
for _, key := range []string{"reportId", "reportID", "report_id", "report_Id", "report-id"} {
if id, ok := m[key].(string); ok {
if id = strings.TrimSpace(id); id != "" {
return id
}
}
}
return ""
}
// shortcutReportLatestCreateMillis reads an entry's create time in millis,
// mirroring the create-time keys helpers.reportReadableCreateTimeMillis probes.
func shortcutReportLatestCreateMillis(m map[string]any) (int64, bool) {
for _, key := range []string{"createTime", "gmtCreate", "sendTime", "time", "modifiedTime"} {
switch v := m[key].(type) {
case float64:
if v > 0 {
return int64(v), true
}
case string:
if s := strings.TrimSpace(v); s != "" {
if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 {
return n, true
}
}
}
}
return 0, false
}
func init() {
shortcut.Register(ReportLatest)
}
@@ -0,0 +1,51 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package todo
import (
"context"
"io"
"strings"
"testing"
"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/pkg/edition"
"github.com/spf13/cobra"
)
type todoPageSizeCaller struct {
calls int
}
func (c *todoPageSizeCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
c.calls++
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"result":{"todoCards":[]}}`}}}, nil
}
func (*todoPageSizeCaller) Format() string { return "json" }
func (*todoPageSizeCaller) DryRun() bool { return false }
func (*todoPageSizeCaller) Fields() string { return "" }
func (*todoPageSizeCaller) JQ() string { return "" }
func TestCrossPlatformCoverageGetMyTasksRejectsUnsupportedPageSize(t *testing.T) {
caller := &todoPageSizeCaller{}
helpers.InitDepsForTest(t, caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.AddCommand(shortcut.Commands()...)
root.SetArgs([]string{"todo", "+get-my-tasks", "--size", "21"})
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), "--size") {
t.Fatalf("size 21 error = %v, want local --size validation", err)
}
if caller.calls != 0 {
t.Fatalf("size 21 made %d remote calls, want 0", caller.calls)
}
}
+2 -1
View File
@@ -197,7 +197,8 @@ type Shortcut struct {
Hidden bool
// CompatibilityVisible preserves a historically visible CLI command while
// keeping it out of the Agent/public Shortcut catalog. Such a command is
// shown only by `dws shortcut list --all` and must remain unavailable.
// shown only by `dws shortcut list --all`; Availability independently says
// whether the historical execution path remains callable.
CompatibilityVisible bool
// Disposition is the reviewed semantic relation to the Runtime Schema leaf
// surface. It determines default Agent discovery independently from live
+24
View File
@@ -154,6 +154,30 @@ func projectWikiRows(items []any, aliases map[string][]string) []map[string]any
return rows
}
func projectWikiRowsPreservingSource(items []any, aliases map[string][]string) []map[string]any {
rows := make([]map[string]any, 0, len(items))
for _, item := range items {
source := item.(map[string]any)
row := make(map[string]any, len(source)+len(aliases))
for key, value := range source {
row[key] = value
}
for canonical, candidates := range aliases {
if value, ok := row[canonical]; ok && value != nil {
continue
}
for _, candidate := range candidates {
if value, ok := source[candidate]; ok && value != nil {
row[canonical] = value
break
}
}
}
rows = append(rows, row)
}
return rows
}
func addWikiPagination(out, page map[string]any) {
for _, pair := range [][2]string{{"nextCursor", "nextCursor"}, {"nextToken", "nextCursor"}, {"nextPageToken", "nextCursor"}, {"pageToken", "nextCursor"}, {"hasMore", "hasMore"}, {"truncated", "truncated"}, {"totalCount", "totalCount"}, {"autoPageComplete", "autoPageComplete"}, {"autoPageStopReason", "autoPageStopReason"}, {"pagesFetched", "pagesFetched"}} {
if value, ok := page[pair[0]]; ok && value != nil {
+1 -1
View File
@@ -237,7 +237,7 @@ var FeedList = readShortcut("+feed-list", "严格分页列出知识库动态", "
if err != nil {
return err
}
feeds := projectWikiRows(items, map[string][]string{"id": {"id", "feedId"}, "type": {"type", "feedType", "action"}, "time": {"time", "createTime"}, "name": {"name", "title", "fileName"}, "nodeId": {"nodeId", "fileId"}})
feeds := projectWikiRowsPreservingSource(items, map[string][]string{"id": {"id", "feedId"}, "type": {"type", "feedType", "action"}, "time": {"time", "createTime"}, "name": {"name", "title", "fileName"}, "nodeId": {"nodeId", "fileId"}})
out := map[string]any{"count": len(feeds), "feeds": feeds}
addWikiPagination(out, page)
return rt.Output(out)
@@ -294,6 +294,29 @@ func TestCrossPlatformCoverageWikiReadWorkflows(t *testing.T) {
})
}
func TestCrossPlatformCoverageWikiFeedListPreservesBusinessPayload(t *testing.T) {
caller := &wikiCoverageCaller{responses: map[string][]string{
"wiki/list_workspace_feeds": {`{"success":true,"feeds":[{"type":1,"time":1750067400000,"content":{"doc":{"name":"Important","docKey":"dk1"}},"users":[{"nick":"Alice","userId":"u1"}],"futureField":{"value":"keep"}}],"hasMore":false}`},
}}
out, err := runWikiCoverageCLI(t, caller, "+feed-list", "--workspace", "w")
if err != nil {
t.Fatal(err)
}
feeds := out["feeds"].([]any)
feed := feeds[0].(map[string]any)
content, contentOK := feed["content"].(map[string]any)
users, usersOK := feed["users"].([]any)
if !contentOK || content["doc"].(map[string]any)["name"] != "Important" {
t.Fatalf("content was lost or changed: %#v", feed)
}
if !usersOK || users[0].(map[string]any)["nick"] != "Alice" {
t.Fatalf("users were lost or changed: %#v", feed)
}
if _, ok := feed["futureField"]; !ok {
t.Fatalf("unknown feed business fields must be preserved: %#v", feed)
}
}
func TestCrossPlatformCoverageWikiWriteWorkflows(t *testing.T) {
t.Run("confirmation gates precede every remote call", func(t *testing.T) {
for _, tc := range []struct {
+20 -8
View File
@@ -14,7 +14,7 @@ usage() {
# Single source of truth for the partition set. CI runs one job per partition and
# pins its shard names to this list, so a name that appears here without a
# dispatch entry below fails closed rather than silently skipping tests.
APP_PARTITIONS='schema a-b c d-r s-z-example-fuzz'
APP_PARTITIONS='schema a-b c-a-l c-m-r c-s-z c-other d-r s-z-example-fuzz'
mode="${1:-}"
partition=""
@@ -66,7 +66,10 @@ fi
schema_count=0
ab_count=0
c_count=0
cal_count=0
cmr_count=0
csz_count=0
cother_count=0
dr_count=0
sz_count=0
unmatched_count=0
@@ -75,7 +78,10 @@ while IFS= read -r test_name; do
case "$test_name" in
Test*Schema*) schema_count=$((schema_count + 1)) ;;
Test[A-B]*) ab_count=$((ab_count + 1)) ;;
TestC*) c_count=$((c_count + 1)) ;;
TestCrossPlatformCoverage[A-L]*) cal_count=$((cal_count + 1)) ;;
TestCrossPlatformCoverage[M-R]*) cmr_count=$((cmr_count + 1)) ;;
TestCrossPlatformCoverage[S-Z]*) csz_count=$((csz_count + 1)) ;;
TestC*) cother_count=$((cother_count + 1)) ;;
Test[D-R]*) dr_count=$((dr_count + 1)) ;;
Test[S-Z]*|Example*|Fuzz*) sz_count=$((sz_count + 1)) ;;
*)
@@ -96,7 +102,10 @@ classified=''
for spec in \
"schema:$schema_count" \
"a-b:$ab_count" \
"c:$c_count" \
"c-a-l:$cal_count" \
"c-m-r:$cmr_count" \
"c-s-z:$csz_count" \
"c-other:$cother_count" \
"d-r:$dr_count" \
"s-z-example-fuzz:$sz_count"
do
@@ -133,14 +142,14 @@ for name in $classified; do
done
total_count="$(wc -l < "$tests" | tr -d ' ')"
assigned_count=$((schema_count + ab_count + c_count + dr_count + sz_count))
assigned_count=$((schema_count + ab_count + cal_count + cmr_count + csz_count + cother_count + dr_count + sz_count))
if [ "$assigned_count" -ne "$total_count" ]; then
printf 'app race partitions assigned %s tests, want %s\n' "$assigned_count" "$total_count" >&2
exit 1
fi
printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c=%s d-r=%s s-z-example-fuzz=%s\n' \
"$total_count" "$schema_count" "$ab_count" "$c_count" "$dr_count" "$sz_count"
printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c-a-l=%s c-m-r=%s c-s-z=%s c-other=%s d-r=%s s-z-example-fuzz=%s\n' \
"$total_count" "$schema_count" "$ab_count" "$cal_count" "$cmr_count" "$csz_count" "$cother_count" "$dr_count" "$sz_count"
if [ "$mode" = "verify" ]; then
exit 0
@@ -198,7 +207,10 @@ run_named_partition() {
case "$1" in
schema) run_partition schema no-race "$schema_pattern" ;;
a-b) run_partition a-b race '^Test[A-B]' "$schema_pattern" ;;
c) run_partition c race '^TestC' "$schema_pattern" ;;
c-a-l) run_partition c-a-l race '^TestCrossPlatformCoverage[A-L]' "$schema_pattern" ;;
c-m-r) run_partition c-m-r race '^TestCrossPlatformCoverage[M-R]' "$schema_pattern" ;;
c-s-z) run_partition c-s-z race '^TestCrossPlatformCoverage[S-Z]' "$schema_pattern" ;;
c-other) run_partition c-other race '^TestC' '^Test.*Schema|^TestCrossPlatformCoverage' ;;
d-r) run_partition d-r race '^Test[D-R]' "$schema_pattern" ;;
s-z-example-fuzz)
run_partition s-z-example-fuzz race '^(Test[S-Z]|Example|Fuzz)' "$schema_pattern"
+3
View File
@@ -29,6 +29,9 @@ SEMANTIC_PATHS = [
ROOT / "internal" / "shortcut" / "semantic_catalog_todo.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_attendance.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_mail.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_oa.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_ding.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_report.json",
]
@@ -403,6 +403,11 @@ CANDIDATE_RAW="$TMP_ROOT/candidate-schema.json"
go build -o "$CANDIDATE_BIN" ./cmd
)
CHECKER_SUPPORTS_MIGRATION_BASE_SCHEMA=false
if "$CHECKER" --help 2>&1 | grep -Fq -- 'migration-base-schema'; then
CHECKER_SUPPORTS_MIGRATION_BASE_SCHEMA=true
fi
mkdir -p "$TMP_ROOT/base-home" "$TMP_ROOT/stable-home" "$TMP_ROOT/candidate-home"
HOME="$TMP_ROOT/base-home" DWS_LANG=zh \
"$BASE_BIN" schema --all --format json >"$BASE_RAW"
@@ -468,23 +473,22 @@ check_with_migrations() {
historical_kind="$1"
historical_ref="$2"
historical_baseline="$3"
set -- \
--approved-flag-migrations "$APPROVED_MANIFEST" \
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then
check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" \
--approved-flag-migrations "$APPROVED_MANIFEST" \
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
set -- "$@" \
--approved-command-migrations "$APPROVED_COMMAND_MANIFEST" \
--candidate-command-migrations "$CANDIDATE_COMMAND_MANIFEST" \
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
else
check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" \
--approved-flag-migrations "$APPROVED_MANIFEST" \
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
--candidate-command-migrations "$CANDIDATE_COMMAND_MANIFEST"
fi
if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ] &&
[ "$CHECKER_SUPPORTS_MIGRATION_BASE_SCHEMA" = true ]; then
set -- "$@" --migration-base-schema "$BASELINE"
fi
check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" "$@"
}
check_with_migrations "PR merge-base" "$BASE_REF" "$BASELINE"
+22 -1
View File
@@ -68,7 +68,28 @@ if [ "$archive_changed" = true ]; then
index($0, heading) == 1 { found = 1; next }
found && /^## / { exit }
found { print }
' >"$tmp_root/actual-notes"
' >"$tmp_root/actual-section"
case "$release_version" in
*-beta.*)
cp "$tmp_root/actual-section" "$tmp_root/actual-notes"
;;
*)
stable_beta="$(sed -n 's/^### Changes since `\(v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*\)`$/\1/p' "$tmp_root/actual-section")"
[ "$(printf '%s\n' "$stable_beta" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || {
printf '%s\n' 'error: stable release-seal with archived fragments requires exactly one ### Changes since `vX.Y.Z-beta.N` boundary' >&2
exit 1
}
. "$ROOT/scripts/release/release-lib.sh"
[ "$(release_core_tag "$stable_beta")" = "v$release_version" ] || {
printf 'error: stable post-beta fragment boundary %s does not match release v%s\n' "$stable_beta" "$release_version" >&2
exit 1
}
awk '
found { print }
/^### Changes since `v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*`$/ { found = 1 }
' "$tmp_root/actual-section" >"$tmp_root/actual-notes"
;;
esac
normalize_notes() {
awk '
/^[[:space:]]*$/ && !started { next }

Some files were not shown because too many files have changed in this diff Show More