Compare commits

...
Author SHA1 Message Date
chichuan 27b16b190f Merge branch 'main' into feat/eval-dispatch 2026-08-11 15:47:54 +08:00
github-actions[bot] de1e1aaf6c Merge pull request #913 from DingTalk-Real-AI/codex/fix-im-reliability
fix(chat): harden IM search, card updates, and message workflows
2026-08-11 15:47:27 +08:00
chichuan 20d1f7c614 feat(eval-dispatch): optional sha= for own-PR dispatch; structural cases ref validation
- /eval on one's own PR may omit sha=: the guard auto-pins the
  dispatch-time head (commenter == PR author leaves no third-party
  swap window); dispatching another author's PR still requires the
  explicit reviewed SHA (keeps the P1-2 TOCTOU remedy where the
  threat lives)
- cases= is now validated structurally per git check-ref-format
  semantics (leading/trailing//double slashes, '..', dot-leading
  components, .lock suffixes) and rejects '-'-leading values to
  prevent git fetch option injection (review P2)
2026-08-11 15:46:16 +08:00
chichuan 233e0359e4 chore(eval-dispatch): seed allowlist with 53 internal contributors 2026-08-11 15:45:33 +08:00
chichuan ad6837d694 feat(eval-dispatch): allowlist tier for self-service PR evaluation
Users listed in .github/eval-allowlist.txt (default branch, PR-reviewed)
may dispatch /eval for their own PRs only; write/maintain/admin retain
dispatch for any PR. Fail-closed on permission API 404/network errors.
2026-08-11 15:45:33 +08:00
克谨 7849116a69 Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability 2026-08-11 15:17:59 +08:00
克谨 b082135e6e test(chat): cover scoped search branches 2026-08-11 15:17:48 +08:00
github-actions[bot] 9ae0191270 Merge pull request #932 from abucraft/codex/aitable-workflow-run-history
feat: add aitable workflow run and history commands
2026-08-11 07:08:34 +00:00
chichuan 211a5fa393 Merge branch 'main' into codex/aitable-workflow-run-history 2026-08-11 14:33:14 +08:00
chichuan 8619d90119 Merge remote-tracking branch 'origin/main' into feat/eval-dispatch 2026-08-11 14:16:33 +08:00
克谨 af199e73e2 Merge origin/main into codex/fix-im-reliability 2026-08-11 14:10:05 +08:00
github-actions[bot] fd24619437 Merge pull request #935 from xiaoji121/fix/json-output-doc-export-drive-download
fix: return JSON receipts for exports and downloads
2026-08-11 14:08:11 +08:00
chichuan 42e764a7a8 fix(ci): harden eval dispatch authorization 2026-08-11 13:57:42 +08:00
Dongming Ji 6337058d15 Merge branch 'main' into fix/json-output-doc-export-drive-download 2026-08-11 13:51:46 +08:00
克谨 d38868c8aa Merge origin/main into codex/fix-im-reliability 2026-08-11 13:51:27 +08:00
github-actions[bot] de8040ecc2 Merge pull request #938 from xlb1130/feat/im-page-all-pagination
docs(chat): expose typed message pagination help
2026-08-11 13:36:43 +08:00
Dongming Ji 96f406be6b Merge branch 'main' into fix/json-output-doc-export-drive-download 2026-08-11 13:34:55 +08:00
克谨 0d99d18acc test(chat): align update-card selection copy 2026-08-11 12:44:13 +08:00
xlb1130 9377abc5f6 Merge branch 'main' into feat/im-page-all-pagination 2026-08-11 12:38:16 +08:00
克谨 e8ca78fe49 Merge origin/main into codex/fix-im-reliability 2026-08-11 12:07:27 +08:00
github-actions[bot] b923f522d5 Merge pull request #912 from aqruan/fix/minutes-permission-apply-policy-int
fix(minutes): type permission apply --policy as int
2026-08-11 04:01:32 +00:00
克谨 28bc577e88 Merge origin/main into codex/fix-im-reliability 2026-08-11 11:50:22 +08:00
克谨 82dfee7291 fix(chat): preserve layered IM workflow contracts 2026-08-11 11:48:39 +08:00
Dongming Ji 1d2edbaa9f Merge branch 'main' into fix/json-output-doc-export-drive-download 2026-08-11 11:45:16 +08:00
xlb1130 25a5f5b7d2 Merge branch 'main' into feat/im-page-all-pagination 2026-08-11 11:44:24 +08:00
chichuan 0b43905697 Merge branch 'main' into fix/minutes-permission-apply-policy-int 2026-08-11 11:29:04 +08:00
李晟 28227b19c7 Merge branch 'main' into codex/aitable-workflow-run-history 2026-08-11 11:28:44 +08:00
github-actions[bot] 622632908e Merge pull request #943 from DingTalk-Real-AI/codex/fix-helper-ci-sharding
ci: shard helper changes through full suite
2026-08-11 11:27:09 +08:00
chichuan 69cef74e1d Merge branch 'main' into feat/eval-dispatch 2026-08-11 11:10:05 +08:00
chichuan 2ec25ebb98 Merge branch 'main' into fix/minutes-permission-apply-policy-int 2026-08-11 11:08:54 +08:00
Dongming Ji bccc9eb056 Merge branch 'main' into fix/json-output-doc-export-drive-download 2026-08-11 11:03:13 +08:00
xlb1130 2274fd96f0 Merge branch 'main' into feat/im-page-all-pagination 2026-08-11 10:55:10 +08:00
chichuan 10fe258e4b ci: shard helper changes through full suite 2026-08-11 10:54:42 +08:00
github-actions[bot] 22ab166c9b Merge pull request #905 from wxianfeng/feat/dws-event-oa
feat(event): support personal OA approval events
2026-08-11 02:46:51 +00:00
阮知夏 d3e444cb56 docs(changelog): move the Minutes policy notes into Unreleased
The two Minutes notes (permission apply --policy int typing and the skill
reference updates) landed in the released 1.0.58-beta.2 section after the
branch merged main. That rewrites published release notes and would drop
both notes from the next release generated out of Unreleased. Move them
verbatim into a Changed subsection under Unreleased; the beta.2 section is
byte-identical to main again.
2026-08-11 10:43:51 +08:00
克谨 0148ad1800 test(chat): cover scoped search error fallbacks 2026-08-11 10:29:53 +08:00
炳昱 4a4a1e0407 Merge branch 'main' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli into feat/dws-event-oa 2026-08-11 10:18:55 +08:00
aqruan e38fd9ab93 Merge branch 'main' into fix/minutes-permission-apply-policy-int 2026-08-11 10:09:53 +08:00
长真 fb33a0b9e0 Merge remote-tracking branch 'origin/feat/im-page-all-pagination' into feat/im-page-all-pagination 2026-08-11 09:59:50 +08:00
长真 e94c7063ed fix(helpers): sync paged aggregate cursors 2026-08-11 09:59:10 +08:00
克谨 d6b51a04f4 fix(chat): preserve scoped search preflight errors 2026-08-11 09:55:19 +08:00
xlb1130 cd3a09e153 Merge branch 'main' into feat/im-page-all-pagination 2026-08-11 09:32:01 +08:00
李晟 2f925d29fd Merge branch 'main' into codex/aitable-workflow-run-history 2026-08-11 09:26:07 +08:00
克谨 68483f05b2 Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability 2026-08-11 09:17:02 +08:00
修雨 730d3fa27f Merge pull request #941 from DingTalk-Real-AI/codex/issue-940-stdio-idempotency-race-budget
test(transport): widen stdio idempotency race budget
2026-08-11 09:06:58 +08:00
长真 6eb3efa065 fix(helpers): stop paged commands at max items 2026-08-11 08:41:18 +08:00
chichuan 783e1eeef9 fix(ci): stabilize minutes coverage contracts 2026-08-11 00:13:48 +08:00
xlb1130 9e0a67f728 Merge branch 'main' into feat/im-page-all-pagination 2026-08-11 00:00:33 +08:00
长真 19f9285f8c fix(helpers): propagate paged output errors 2026-08-10 23:50:55 +08:00
修雨 c295027e84 Merge main into test/transport race budget candidate 2026-08-10 23:47:12 +08:00
克谨 3817ac230d Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability 2026-08-10 23:46:54 +08:00
chichuan 75b54a9467 Merge branch 'main' into fix/minutes-permission-apply-policy-int 2026-08-10 23:44:46 +08:00
github-actions[bot] 24437fc1a5 Merge pull request #921 from DingTalk-Real-AI/codex/interface-migration-governance
ci: govern exact CLI flag migrations
2026-08-10 23:43:19 +08:00
Dongming Ji 851d491d2a Merge branch 'main' into fix/json-output-doc-export-drive-download 2026-08-10 23:31:35 +08:00
chichuan b55f243780 ci(test): shard helper changes in full suite 2026-08-10 23:25:52 +08:00
chichuan e9850a2e49 fix(ci): enforce stable Schema compatibility 2026-08-10 23:06:13 +08:00
克谨 257ac94fb1 Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability 2026-08-10 22:31:57 +08:00
xlb1130 9834a84888 Merge branch 'main' into feat/im-page-all-pagination 2026-08-10 22:31:18 +08:00
chichuan 8097943e3a Merge branch 'main' into codex/interface-migration-governance 2026-08-10 22:31:16 +08:00
chichuan a68c06540c Merge remote-tracking branch 'origin/main' into fix-912-conflict
# Conflicts:
#	CHANGELOG.md
2026-08-10 22:26:01 +08:00
长真 44c5ef13b4 test(chat): cover conversation pagination edges 2026-08-10 22:24:27 +08:00
github-actions[bot] d5a9a72fa6 Merge pull request #931 from DingTalk-Real-AI/fix/schema-compat-policy-int
ci(schema): allow reviewed parameter type migrations
2026-08-10 22:22:35 +08:00
炳昱 6f73e5187a Merge official main into feat/dws-event-oa 2026-08-10 22:21:25 +08:00
chichuan b7918be6f3 fix(ci): require stable flag migration reference 2026-08-10 22:16:00 +08:00
Dongming Ji b70e109e89 Merge branch 'main' into fix/json-output-doc-export-drive-download 2026-08-10 22:13:31 +08:00
chichuan b84b56d9f8 Merge origin/main into interface migration governance 2026-08-10 22:03:18 +08:00
chichuan e66cd95c51 Merge remote-tracking branch 'origin/main' into fix/schema-compat-policy-int 2026-08-10 21:59:55 +08:00
chichuan 7e8b216e07 ci(schema): compare the full parameter contract for reviewed type migrations
Auto-CR (P1) correctly flagged that the carve-out's "nothing else changed"
guard was keyed on len(otherFailures) == 0, which only observes changes the
gate already judges incompatible. Several parameter contract changes are
individually compatible and so produce no failure at all: relaxing required or
cli_required, clearing required_when, widening enum, clearing interface_type,
and clearing property through a reviewed mapping exclusion. Any of those could
have ridden along with a reviewed type migration, leaving the exemption wider
than both its documentation and what the entry actually reviewed.

Replace the failure-list heuristic with a real equality check over every
published field except Type. Comparing the struct also means a field added to
parameterSchema later is covered automatically, instead of silently widening
every existing entry. The type check moves back ahead of the field loop because
it no longer needs to observe the other findings.

Add a rejection case for each individually-compatible direction. Each case first
asserts that the drift alone really is compatible, so it keeps exercising the
equality guard instead of quietly duplicating one of the incompatible-bundle
cases.

Verified against the previous implementation: with the old guard all six new
cases fail while the nine incompatible-bundle cases still pass, which is exactly
the gap that was reported.
2026-08-10 21:52:58 +08:00
克谨 3fc144a699 Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability 2026-08-10 21:19:53 +08:00
github-actions[bot] 5501c9f1a5 Merge pull request #933 from pengzhihan47-star/codex/doc-shortcut_and_skill_opt
feat(doc): harden dingtalk-doc shortcuts, contracts, and verification
2026-08-10 21:11:23 +08:00
长真 4d274c9da3 fix(chat): merge conversation message pagination 2026-08-10 20:34:43 +08:00
如椽 7ffb48c9ae test: cover JSON export and download receipts 2026-08-10 19:57:37 +08:00
如椽 a24fd542c0 Merge remote-tracking branch 'upstream/main' into fix/json-output-doc-export-drive-download
# Conflicts:
#	CHANGELOG.md
2026-08-10 19:28:55 +08:00
长真 b8418b6a5f test(chat): cover paged command edge cases 2026-08-10 18:59:24 +08:00
修雨 af71efd253 test(transport): widen stdio idempotency race budget
Refs #940

Authority: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/issues/940

Assignment: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/issues/940#issuecomment-5239203628
2026-08-10 18:58:21 +08:00
xlb1130 8c19b0048b Merge branch 'main' into feat/im-page-all-pagination 2026-08-10 18:24:07 +08:00
长真 a9751fa74d docs(changelog): drop typed pagination entry from branch 2026-08-10 18:23:41 +08:00
镜玄 8a0bd34e13 Merge remote-tracking branch 'upstream/main' into codex/aitable-workflow-run-history
# Conflicts:
#	CHANGELOG.md
2026-08-10 18:17:15 +08:00
长真 5a160cefd8 docs(chat): expose typed message pagination help 2026-08-10 17:59:38 +08:00
炳昱 a9c0e0409c Merge remote-tracking branch 'official/main' into feat/dws-event-oa 2026-08-10 17:58:42 +08:00
炳昱 9616441e54 fix(skill): migrate retired shared skill 2026-08-10 17:58:33 +08:00
柏智 eefe6f05e1 fix(schema): review doc import constraint transition 2026-08-10 17:49:27 +08:00
柏智 6598292b1b fix(doc): allow import to default root 2026-08-10 17:32:23 +08:00
柏智 dea637228d fix(doc): preserve schema compatibility after review 2026-08-10 17:32:23 +08:00
柏智 a09467f1eb fix(doc): address PR 906 review feedback 2026-08-10 17:32:23 +08:00
柏智 34feb348af feat(doc): harden shortcuts and skill routing 2026-08-10 17:32:23 +08:00
如椽 08ee5dc573 fix: emit JSON receipts for exports and downloads 2026-08-10 17:21:54 +08:00
chichuan 6b1a1a6201 Merge branch 'main' into fix/schema-compat-policy-int 2026-08-10 17:19:54 +08:00
镜玄 5c45bd57da test: cover aitable workflow validation branches 2026-08-10 17:13:58 +08:00
克谨 349537e336 Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability 2026-08-10 17:13:12 +08:00
github-actions[bot] 9f60cdeef1 Merge pull request #920 from Anonymity-0/feat/card-reply-mentions
feat(chat): support mentions in streaming card creation
2026-08-10 17:06:24 +08:00
前津 258caa5906 fix(chat): fail safely when card mention tag is missing 2026-08-10 16:39:51 +08:00
前津 a0d59a79aa feat(chat): prepend card mention tag to content 2026-08-10 16:39:51 +08:00
前津 c7148f3ebb docs(chat): clarify streaming card mention content 2026-08-10 16:39:51 +08:00
前津 ef29b48a55 fix(chat): keep skill within context budget 2026-08-10 16:39:51 +08:00
前津 5c33ea526e feat(chat): support mentions in streaming card creation 2026-08-10 16:39:51 +08:00
chichuan 6d3b54b25f Merge branch 'main' into fix/schema-compat-policy-int 2026-08-10 16:37:45 +08:00
克谨 b243b38d65 Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability 2026-08-10 16:32:20 +08:00
github-actions[bot] 867bb44586 chore: update beta formula for v1.0.58-beta.2 [skip ci] 2026-08-10 08:22:55 +00:00
镜玄 819355b31f feat: add aitable workflow run and history commands 2026-08-10 16:20:22 +08:00
克谨 c3d4de52a7 Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability 2026-08-10 16:09:52 +08:00
chichuan fd0c3350f3 ci(schema): allow reviewed parameter type migrations
schema-compatibility is the third check in the same Interface Integrity job,
after the two CLI interface gates. It also rejected every published parameter
type change outright. Because the earlier gates failed first and `set -e`
stopped the step from ever running, this one never surfaced in CI, so the
previous exemption only covered two thirds of the problem.

checkParameterCompatibility now consults a precise allowlist: the tool path,
parameter name and both type values must match exactly, making it
direction-sensitive by construction, and it applies only when nothing else the
gate checks about the parameter moved (default, interface_default, format,
property, interface_type, required, cli_required, required_when, enum). The type
check moved to the end of the function so the carve-out can see those findings;
ordering is unobservable because the result is sorted.

The only entry is "minutes/minutes.apply_minutes_permission" parameter "policy"
migrating from "string" to "integer" (for #912). That type is projected from the
Cobra flag type (provenance cobra_flag_type), so it describes how the CLI accepts
a value. Consumers build a command line from it, and "--policy 4" is the same
argv under either declaration — a quoted "--policy \"4\"" still reaches pflag as
4 — while RunE keeps enforcing the same [2,4] domain. The parameter maps to
property "policyId", which the command has always sent as a number, so "integer"
is closer to the actual request than "string" was.

Table values must be the canonical form schemaType emits: the JSON encoding of
the type keyword, so `"string"` with its quotes rather than a bare string. The
guard test recomputes both through schemaType and checks the decoded name
against the closed JSON Schema type set — reviewedInterfaceRefRedirect was
silently disabled twice by exactly this class of spelling mistake.
2026-08-10 15:57:15 +08:00
炳昱 0e14f69aae Merge commit '6575301a3a7fef264f0550185a0bee13087be729' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli into feat/dws-event-oa 2026-08-10 15:43:37 +08:00
炳昱 9f14035483 test(event): cover subscription and migration failures 2026-08-10 15:42:44 +08:00
chichuan a5672152a7 ci: add /eval comment dispatch workflow for internal MCP evaluation (Aone JSON trigger contract) 2026-08-10 15:34:01 +08:00
chichuan 2d38abe681 feat(ci): PR 评论 /eval 触发内网 MCP 评测的 dispatch workflow
- issue_comment 触发,author_association ∈ OWNER/MEMBER/COLLABORATOR 门控
- 不 checkout、不执行 PR 代码;触发通道与凭证全部经 secrets 注入
- scripts/ci/eval_comment_parse.py 解析 /eval <products> [cases=<ref>](10 个单测)
2026-08-10 15:34:01 +08:00
阮知夏 f478b7d3e1 Merge remote-tracking branch 'origin/main' into fix/minutes-permission-apply-policy-int
# Conflicts:
#	CHANGELOG.md
2026-08-10 15:27:38 +08:00
克谨 d84c73e8b2 Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability 2026-08-10 15:27:09 +08:00
炳昱 8daf5c71cd Merge commit '93a20718372f434f9eda84850df816a7c29c34fc' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli into feat/dws-event-oa 2026-08-10 15:03:39 +08:00
克谨 431f64be85 Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability 2026-08-10 14:13:46 +08:00
克谨 d979d86fa3 Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability
# Conflicts:
#	internal/helpers/chat.go
2026-08-10 12:21:37 +08:00
chichuan 64e1dcc150 test: make temp failure portable on Windows 2026-08-10 11:36:53 +08:00
chichuan f3ecac1ad1 ci: satisfy workflow shell lint 2026-08-10 11:14:23 +08:00
阮知夏 b150911da9 docs(minutes): scope permission member-uids rule and add apply routing 2026-08-10 11:09:08 +08:00
chichuan 46ae1c50fe ci: govern exact CLI flag migrations 2026-08-10 10:46:20 +08:00
阮知夏 37d6a4ea2e Merge remote-tracking branch 'origin/main' into fix/minutes-permission-apply-policy-int
# Conflicts:
#	CHANGELOG.md
2026-08-10 10:32:21 +08:00
克谨 20c8e0dfec Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability
# Conflicts:
#	scripts/policy/schema-compat/main.go
2026-08-10 10:22:06 +08:00
长真 cde050f146 test(chat): cover paged command delay sleep 2026-08-10 10:10:16 +08:00
克谨 e02410dae6 fix(ci): review card confirmation hardening 2026-08-10 01:02:28 +08:00
克谨 74d31566ff fix(chat): align native card update confirmation 2026-08-10 00:42:27 +08:00
克谨 6765a74d83 Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability
# Conflicts:
#	internal/shortcut/smart/compatibility_coverage_test.go
#	internal/shortcut/smart/search_msg.go
#	internal/shortcut/smart/search_msg_execution_test.go
#	skills/multi/dingtalk-chat/references/contracts.md
2026-08-10 00:30:24 +08:00
克谨 13e5914638 test(chat): close changed-code coverage gaps 2026-08-10 00:07:53 +08:00
阮知夏 8fcc6baee0 Merge remote-tracking branch 'origin/main' into fix/minutes-permission-apply-policy-int
# Conflicts:
#	CHANGELOG.md
2026-08-10 00:07:42 +08:00
阮知夏 6774d423b7 docs(minutes): drop hot-word delete references from skill docs 2026-08-09 23:43:51 +08:00
长真 8156528c05 fix(chat): harden IM pagination cursor mapping 2026-08-09 20:20:20 +08:00
长真 e5a60386c6 feat(chat): add typed IM message pagination 2026-08-09 17:18:53 +08:00
wxianfeng 1ee37ec4c2 fix(event): harden subscription reuse and skill migration 2026-08-07 18:46:25 +08:00
阮知夏 06b0a9eef3 docs(minutes): drop hot-word delete intent routing 2026-08-07 17:56:32 +08:00
克谨 83f72377a7 fix(chat): satisfy IM contract and compatibility gates 2026-08-07 17:41:25 +08:00
克谨 50712d3305 Merge remote-tracking branch 'origin/main' into codex/fix-im-search-conversation-scope 2026-08-07 17:02:16 +08:00
wxianfeng 7e27fa384a Merge remote-tracking branch 'upstream/main' into feat/dws-event-oa 2026-08-07 16:47:17 +08:00
wxianfeng 8bf6c15fad feat(event): restore standalone event skill 2026-08-07 16:38:30 +08:00
阮知夏 f79a6fc707 fix(minutes): type permission apply --policy as int 2026-08-07 16:25:38 +08:00
克谨 5a09204bf5 fix(chat): complete resource reference downloads 2026-08-07 16:23:20 +08:00
克谨 0d11b2be45 fix(chat): preserve resource filenames in message refs 2026-08-07 16:07:56 +08:00
克谨 effe7c829e fix(chat): align message workflows and diagnostics 2026-08-07 15:48:29 +08:00
克谨 7c76dfea4b fix(chat): fail closed for scoped search and card updates 2026-08-07 15:30:09 +08:00
炳昱 c803cf7eeb fix(event): validate reused OA subscriptions in dry-run 2026-08-07 15:30:03 +08:00
炳昱 832d3ab886 test(event): cover OA validation branches 2026-08-07 14:58:52 +08:00
wxianfeng 47f303d3fc Merge remote-tracking branch 'origin/feat/dws-event-oa' into feat/dws-event-oa 2026-08-07 14:56:28 +08:00
wxianfeng 1199240a36 Merge remote-tracking branch 'upstream/main' into feat/dws-event-oa
# Conflicts:
#	skills/mono/references/products/event.md
#	skills/multi/dingtalk-misc/references/event-oa.md
#	skills/multi/dingtalk-misc/references/event.md
2026-08-07 14:48:37 +08:00
炳昱 354d39a6f1 Merge branch 'main' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli into feat/dws-event-oa
# Conflicts:
#	skills/mono/references/products/event.md
#	skills/multi/dingtalk-misc/references/event-oa.md
#	skills/multi/dingtalk-misc/references/event.md
2026-08-07 14:23:12 +08:00
炳昱 6704eda83a fix(event): switch personal event defaults to production 2026-08-07 10:22:32 +08:00
wxianfeng b581426488 Merge remote-tracking branch 'upstream/main' into feat/dws-event-oa
# Conflicts:
#	internal/app/event_personal_command.go
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog.json
#	internal/cli/schema_hints/index.json
#	internal/cli/schema_hints/reference-review.json
#	skills/mono/SKILL.md
#	skills/mono/references/products/event.md
#	skills/multi/dingtalk-event/SKILL.md
2026-08-05 22:43:36 +08:00
炳昱 703406df13 feat(event): publish typed OA approval schemas 2026-07-29 22:23:13 +08:00
炳昱 753d538140 feat(event): complete personal OA approval events 2026-07-28 21:38:17 +08:00
wxianfeng f890dda7e7 feat(event): add personal OA approval events
Use the Event-specific pre-release control and stream ticket endpoints by default.
2026-07-28 16:15:18 +08:00
wxianfeng c870d2ebdc Merge remote-tracking branch 'upstream/main' 2026-07-28 10:52:41 +08:00
192 changed files with 25699 additions and 3963 deletions
+61
View File
@@ -0,0 +1,61 @@
# /eval 自助触发允许名单
#
# 名单内的 GitHub 登录名可对【自己创建的 PR】触发 /eval 评测;
# 对任意 PR 触发仍需仓库 write/maintain/admin 权限(维护者背书)。
# 授权读取的始终是默认分支上的本文件,PR 无法修改自身授权。
#
# 变更本文件必须走 PR 评审。每行一个 GitHub login,# 开头为注释。
aftersss
notable-open
EdgarWang0925
ayunya
yutongshe
qingyang1014
caiTriumph
xlb1130
Anonymity-0
FuShu-Yang
guimingyue
AlwaysLee
TaoJikun
zengyoulingzyl-stack
liyuan333
huangyoo
lifeihong
nitonitori
cywan1998
gangwn
junlonghuo2
aqruan
Freda0909
ShawnWhite777
PeterGuy326
abucraft
pengzhihan47-star
rainyak8
gongrongyun
huangyuanzhuo-coder
ybcstudy
bigqy
liwang-ai
meng93
wxianfeng
Patrick-Star-CN
rossluo28-hz
dxy704330469
gtezg30062
Neige-Premaire
zhuoyu20
avicii-chen
typefield
Haofeng0705
Huwenjiao
liuzeyang
maoqxxmm
FloralTide
lingyun9833
dxb121
C0922
xiaoji121
H3java
+29 -17
View File
@@ -148,6 +148,7 @@ jobs:
filename === '.github/actionlint.yaml' ||
filename.startsWith('scripts/') ||
filename.startsWith('verify/') ||
filename.startsWith('internal/helpers/') ||
filename.startsWith('internal/generator/') ||
filename.startsWith('internal/cli/schema') ||
filename.startsWith('internal/interfacesnapshot/') ||
@@ -1370,36 +1371,47 @@ jobs:
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
base_ref="$(git rev-parse HEAD^)"
fi
candidate_ref="$(git rev-parse 'HEAD^{commit}')"
if [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$candidate_ref" != "$PR_HEAD_SHA" ]; then
echo "Compatibility checkout $candidate_ref does not match PR head $PR_HEAD_SHA" >&2
exit 1
fi
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
stable_ref="$(git tag --merged "$base_ref" --list 'v[0-9]*' --sort=-version:refname | awk 'index($0, "-") == 0 { print; exit }')"
. ./scripts/release/release-lib.sh
stable_ref=""
for tag in $(git tag --merged "$base_ref" --list 'v*' --sort=-version:refname); do
release_is_stable_version "$tag" || continue
if git rev-parse --verify --quiet "refs/tags/withdrawn/$tag" >/dev/null; then
continue
fi
stable_ref="$tag"
break
done
if [ -z "$stable_ref" ]; then
echo "No stable release tag is reachable from compatibility base $base_ref" >&2
exit 1
fi
git rev-parse --verify "${stable_ref}^{commit}" >/dev/null
echo "COMPATIBILITY_BASE_REF=$base_ref" >> "$GITHUB_ENV"
echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV"
printf '%s\n' \
"COMPATIBILITY_BASE_REF=$base_ref" \
"COMPATIBILITY_STABLE_REF=$stable_ref" \
"COMPATIBILITY_CANDIDATE_REF=$candidate_ref" >> "$GITHUB_ENV"
- name: Check historical commands and help compatibility
- name: Check historical commands, help, and complete CLI compatibility
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
run: |
make authoritative-interface-integrity \
BASE_REF="$COMPATIBILITY_BASE_REF"
if [ "$(git rev-parse "${COMPATIBILITY_BASE_REF}^{commit}")" != "$(git rev-parse "${COMPATIBILITY_STABLE_REF}^{commit}")" ]; then
make authoritative-interface-integrity \
BASE_REF="$COMPATIBILITY_STABLE_REF"
fi
- name: Check complete CLI command compatibility
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
run: |
./scripts/policy/check-command-compatibility.sh \
--base-ref "$COMPATIBILITY_BASE_REF" \
--stable-ref "$COMPATIBILITY_STABLE_REF"
BASE_REF="$COMPATIBILITY_BASE_REF" \
STABLE_REF="$COMPATIBILITY_STABLE_REF" \
CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"
- name: Check complete Schema compatibility
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
run: |
make schema-compatibility \
BASE_REF="$COMPATIBILITY_BASE_REF" \
STABLE_REF="$COMPATIBILITY_STABLE_REF" \
CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"
- name: Check skill command references
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
+139
View File
@@ -0,0 +1,139 @@
name: PR Eval Dispatch
# `/eval <products> [sha=<full-head-sha>] [cases=<ref>]` PR 评论 → 触发内网评测流水线,报告由内网 bot 回贴。
# 本 workflow 只在默认分支上下文运行,不 checkout、不执行 PR 代码。
# 审核 SHA 规则:评测他人 PR 必须显式携带 sha=(审阅背书凭据,验证
# 其恰为当前 open head);评测自己创建的 PR 可省略,自动钉住派发时刻
# 的当前 head(作者自背书,无第三方偷换窗口);内网 CI 另以
# FETCH_HEAD 校验兜底派发后的变更。
# 授权两级:仓库 write/maintain/admin 可派发任意 PR;默认分支
# .github/eval-allowlist.txt 名单内的用户仅可派发自己创建的 PR。
# 触发通道与凭证全部经 secrets 注入,文件内不出现任何内网信息。
on:
issue_comment:
types:
- created
permissions: {}
concurrency:
group: eval-dispatch-${{ github.event.issue.number }}
cancel-in-progress: false
jobs:
dispatch:
name: Dispatch internal evaluation
if: >-
github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/eval')
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
issues: write
pull-requests: read
steps:
- name: Check out default branch tooling
uses: actions/checkout@v4
- name: Verify commenter dispatch authorization
env:
GH_TOKEN: ${{ github.token }}
COMMENTER: ${{ github.event.comment.user.login }}
PR_AUTHOR: ${{ github.event.issue.user.login }}
EVAL_ALLOWLIST_PATH: .github/eval-allowlist.txt
run: |
# 不用 --fail:非协作者查权限返回 404 错误体,交由 guard 走名单分支;硬网络错误降级为空对象同样 fail-closed
permission_json="$(curl --silent --show-error \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/collaborators/${COMMENTER}/permission")" || permission_json='{}'
printf '%s' "$permission_json" | python3 scripts/ci/eval_dispatch_guard.py permission
- name: Parse /eval command
id: parse
continue-on-error: true
env:
COMMENT_BODY: ${{ github.event.comment.body }}
run: python3 scripts/ci/eval_comment_parse.py
- name: Reply usage on parse failure
if: steps.parse.outcome == 'failure'
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.issue.number }}
PARSE_ERROR: ${{ steps.parse.outputs.error }}
run: |
body="❌ /eval 命令解析失败:${PARSE_ERROR}"
jq -n --arg body "$body" '{body: $body}' | curl --fail --silent --show-error \
-X POST \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
--data @- \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null
exit 1
- name: Verify reviewed PR head
id: pr
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.issue.number }}
EXPECTED_PR_NUMBER: ${{ github.event.issue.number }}
REVIEWED_SHA: ${{ steps.parse.outputs.reviewed_sha }}
COMMENTER: ${{ github.event.comment.user.login }}
run: |
pr_json="$(curl --fail --silent --show-error \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}")"
printf '%s' "$pr_json" \
| python3 scripts/ci/eval_dispatch_guard.py head \
>> "$GITHUB_OUTPUT"
- name: Trigger internal evaluation pipeline
env:
EVAL_TRIGGER_TOKEN: ${{ secrets.EVAL_TRIGGER_TOKEN }}
EVAL_TRIGGER_URL: ${{ secrets.EVAL_TRIGGER_URL }}
PR_NUMBER: ${{ github.event.issue.number }}
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
PRODUCTS: ${{ steps.parse.outputs.products }}
CASES_REF: ${{ steps.parse.outputs.cases_ref }}
run: |
if [ -z "$EVAL_TRIGGER_TOKEN" ] || [ -z "$EVAL_TRIGGER_URL" ]; then
echo "EVAL_TRIGGER_URL / EVAL_TRIGGER_TOKEN not configured; cannot dispatch." >&2
exit 1
fi
jq -n \
--arg pr "$PR_NUMBER" \
--arg sha "$PR_HEAD_SHA" \
--arg products "$PRODUCTS" \
--arg cases "$CASES_REF" \
'{branch: "main", params: {pr_number: $pr, pr_head_sha: $sha, products: $products, cases_ref: $cases}}' \
| curl --fail --silent --show-error \
-X POST \
-H "private-token: ${EVAL_TRIGGER_TOKEN}" \
-H "Content-Type: application/json" \
--data @- \
"$EVAL_TRIGGER_URL"
echo "Internal evaluation dispatched."
- name: Acknowledge on PR
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.issue.number }}
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
PRODUCTS: ${{ steps.parse.outputs.products }}
CASES_REF: ${{ steps.parse.outputs.cases_ref }}
run: |
cases_note=""
if [ -n "$CASES_REF" ]; then
cases_note=",用例版本 \`${CASES_REF}\`"
fi
body="🛰️ /eval 已受理:产品集 \`${PRODUCTS}\`${cases_note},评测对象 \`${PR_HEAD_SHA}\`。内网评测流水线运行结束后将由 bot 回贴报告(首行为基线对比头条)。"
jq -n --arg body "$body" '{body: $body}' | curl --fail --silent --show-error \
-X POST \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
--data @- \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null
+27
View File
@@ -6,6 +6,32 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
### Added
- **Aitable workflow execution and history** — adds `dws aitable workflow run` for confirmed asynchronous execution of scheduled or record-triggered workflows, plus `dws aitable workflow history` for status-, time-, and page-filtered execution records. The commands map directly to `aitable/run_workflow` and `aitable/get_flow_record_list`, validate trigger-specific arguments locally, and document the `executionId` / `instanceId` correlation.
- **Streaming-card mentions** — `chat +messages-send-card` now accepts
`--at-open-dingtalk-ids` and `--at-all` for group cards, passing mention
targets to the initial card-creation request and prepending its returned
`atTag` to the automatic streaming update.
### Fixed
- **Machine-readable export and download receipts** — `dws doc export`,
`dws drive download`, and `dws drive download --version` now keep progress
logs on stderr under `--format json` and emit one JSON result on stdout after
a successful local write. The result includes the saved path and byte size;
document exports additionally report the node, requested format, job/task
ID, and final status.
### Changed
- **Minutes `permission apply --policy` type** — `--policy` is now declared as
an `int` flag and its required check uses `Flags().Changed`, matching the
numeric-parameter convention. `--help` reports `int` instead of `string`;
accepted values (2/3/4) and gateway behavior are unchanged.
- **Minutes skill references** — document `permission apply` in both Minutes
skill references: list it in the command trees, describe its policy values and
how it differs from `permission add`, and add its intent routing.
## [1.0.58-beta.2] - 2026-08-10
### Added
@@ -32,6 +58,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
- **`sheet range batch-clear` / `batch-set-style` 的 `--ranges` 拒绝空白工作表前缀**(用户可见行为变更)— 此前只按原始串里 `!` 的位置判断,`" !A1:B2"` 修剪后工作表名成了空串,操作却照样带着 `sheetId: ""` 提交:服务端要么让整批 `batch_update` 失败,要么更糟——落到默认工作表而不是用户指定的那张表,且命令报成功。现在工作表名与范围都必须在修剪之后仍非空,否则在发起任何请求之前报错。`batch-set-style --batch` 的纯空白 `sheetId` / `range` 同样拒绝(此前只挡空字符串);`--batch` 下发仍用原值不替用户修剪,因为 `sheetId` 可以是允许带首尾空格的工作表**名**。两条 `--ranges` 路径现在共用同一个拆分器。
- **`sheet insert-dimension` / `delete-dimension` / `update-dimension` 的 `--length` 严格校验**(用户可见行为变更)— 解析由 `fmt.Sscanf("%d")` 改为 `strconv.Atoi`。此前只消费前缀数字,`--length 2x` / `3foo` 会被静默当成 `2` / `3` 并对错误的行列数执行操作(删除方向不可回滚);现在整个值必须是合法正整数,否则报错「`--length` 必须为正整数(>= 1)」且不发起任何请求。**升级影响**:原先依赖这种宽松解析、在传畸形 `--length` 的脚本会开始报错,请把参数修正为纯数字。合法数字值行为不变,上限仍为 5000。`add-dimension` 的 `--length` 是 `Int` 类型 flag,一直由 cobra 严格校验,不受影响。
- **CLI 接口兼容门禁支持 reviewed flag 类型豁免**(无用户可见变更)— `authoritative-interface-integrity` 与 `check-command-compatibility.sh` 此前一律拒绝历史命令的 flag 类型变更,即使新类型只是把同一套校验从 RunE 前移到解析期,也没有任何评审通道。现在两道门禁各带一张精确豁免表:命令路径 + flag 名 + 旧类型 → 新类型四元组全等才命中、方向敏感(`string`→`int` 与 `int`→`string` 是两个不同的键,只有被评审的方向可用),且仅当该 flag 的其他契约(shorthand / required / hidden / no-opt / scope)纹丝不动时才放行,因此豁免夹带不了别的破坏。首条也是目前唯一一条登记的是 `dws minutes permission apply --policy` 的 `string` → `int`(配合 #912):旧实现在 RunE 里做 `strconv.ParseInt(v, 10, 64)` 再校验 `[2,4]`,新实现由 pflag 以 `strconv.ParseInt(s, 0, 64)` 解析后仍校验 `[2,4]`,**历史上能成功的调用集是新调用集的子集**(base 0 额外接受 `0x3` 这类写法,只放宽不收紧),非法值依然失败、只是报错文案与时机前移;flag 默认值由 `""` 变 `"0"` 是类型的必然结果,两道门禁都不比较默认值,且该 flag 必须显式给出、默认值不可达。两张表必须逐字一致并有守卫测试锚定漂移——重复是被迫的而非选择:`check-authoritative-interface-baselines.sh` 会把整个 `scripts/policy/interface-baseline` 目录复制进检出历史版本的 worktree 再编译,那份拷贝不能 import 本分支新增的包。
- **Schema 兼容门禁支持 reviewed 参数类型豁免**(无用户可见变更)— 接上一条。`schema-compatibility` 是同一个 `Interface Integrity` job 里排在两道 CLI 接口门禁之后的第三道检查,此前也一律拒绝已发布参数的 `type` 变更。由于前两道先失败、`set -e` 让它从未在 CI 上暴露,上一条豁免只解决了三分之二。现在 `checkParameterCompatibility` 也带一张精确豁免表:`<product>/<tool id>` + 参数名 + 旧类型 + 新类型四元组全等才命中、方向敏感,且仅当该参数**除 `type` 外的全部已发布字段逐字段相等**时才放行。这里刻意用相等性比较而非「没有产生其他兼容性错误」:放宽 `required` / `cli_required`、清空 `required_when`、扩宽 `enum`、清空 `interface_type`、经 reviewed mapping exclusion 清空 `property`——这些变化单独看都是兼容的、根本不产生错误,若以错误列表代替相等性检查,它们就能搭着一次已评审的类型迁移一起蒙混过关。结构体整体比较还意味着将来给 `parameterSchema` 新增字段时会自动纳入守卫,而不是悄悄放宽每一条既有条目。唯一条目是 `minutes/minutes.apply_minutes_permission` 的 `policy` 由 `"string"` 迁移到 `"integer"`(配合 #912):该 `type` 由 Cobra flag 类型投影而来(provenance `cobra_flag_type`),描述的是 CLI 如何接受取值;消费方据此拼装的是命令行,而 `--policy 4` 在两种声明下是同一个 argv,加引号的 `--policy "4"` 到 pflag 仍是 4,RunE 也仍校验 `[2,4]`——而且该参数映射的 property `policyId` 一直以数字上报,新声明比旧声明更贴近真实请求。表里的类型值必须是 `schemaType` 实际产出的带引号形态(`"string"` 而非裸 `string`),守卫测试用 `schemaType` 复算并校验类型名属于 JSON Schema 的封闭取值集合——`reviewedInterfaceRefRedirect` 曾因键的书写形态错误两次静默失效,这里不重犯。
## [1.0.58-beta.1] - 2026-08-07
+11 -3
View File
@@ -68,9 +68,17 @@ coverage is additionally selected for platform-sensitive code.
3. Include both the commands/results and user-visible or contract-level
behavior evidence in the PR description.
4. Run `./scripts/policy/check-command-surface.sh --strict` when command
paths/flags change. CI also runs
`./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>`
against both the target branch and latest stable release.
paths/flags change. CI resolves the exact merge-base, latest reachable
non-withdrawn stable GA tag, and committed candidate SHA, then enters the single compatibility
decision seam through
`make authoritative-interface-integrity BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`.
The Make target delegates to the authoritative wrapper; CI does not invoke a
second comparator or the legacy fixture checker. See
[CLI flag compatibility migration governance](docs/cli-interface-flag-migrations.md)
for the reviewed two-stage `pending` → `consumed` lifecycle.
Agent-visible flag migrations must also run
`make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`;
it consumes the same base-owned ledger rather than a second exception list.
5. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may
change.
6. Run `./scripts/release/verify-package-managers.sh` when packaging or
+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.58-beta.1"
version "1.0.58-beta.2"
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.58-beta.1/dws-darwin-arm64.tar.gz"
sha256 "34f7dabf1a97ed950c1cd331e5fb873a62c87632db249ca1b42bd2da925927f0"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-darwin-arm64.tar.gz"
sha256 "1b2b6953f7f1ae1ca6ecb0702424ac0e1a976a6a5ff91e8ffc3b5ae495d98c7c"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.1/dws-darwin-amd64.tar.gz"
sha256 "c812ad070ac76f8ab5aefe2ae1b9cdf9217389b383296acb36070fc2f40b9890"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-darwin-amd64.tar.gz"
sha256 "a1c1b3c58b48e04c0ae520062f9d6ab0dc961eddb635497bdb9b4345316e45f6"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.1/dws-linux-arm64.tar.gz"
sha256 "7dd5e38647c88e4b7936e0f41315cd612128faeb7960f50b28dbbd1fa9683d68"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-linux-arm64.tar.gz"
sha256 "7f35e3c4734f17b125a8c32f3c95e05d1410f683cf6956be857ee9349f8e4d36"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.1/dws-linux-amd64.tar.gz"
sha256 "b384671440a62f1e5dca5dcdb49e51ef1aad816b601021ba363c8c69986ebd2f"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-linux-amd64.tar.gz"
sha256 "37beb9e39790563cf0584ac23376f713bf2eb2c50cff4222965e831ac9adbb0e"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.1/dws-skills.zip"
sha256 "2cf96aa2d35a01a4b92db2bd2d1760476d62d7528de5ec50a997c9a7713c3513"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-skills.zip"
sha256 "7e10fead4192059c98d596c5b1886f77fd550526de5cd18c425cdad6fd64cd3a"
end
def install
+25 -8
View File
@@ -24,13 +24,13 @@ help:
@printf " make format-check - Check all repository Go source files with gofmt\n"
@printf " make fmt - Format all repository Go source files\n"
@printf " make policy - Check the built dws plus open-source and Schema policies\n"
@printf " make interface-integrity - Check historical commands and help contracts still work\n"
@printf " make authoritative-interface-integrity BASE_REF=<ref> - Check the Git-owned PR merge-base\n"
@printf " make interface-integrity [BASE_REF=<ref>] [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check authoritative CLI history\n"
@printf " make authoritative-interface-integrity BASE_REF=<ref> [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check Git-owned CLI history\n"
@printf " make coverage-gate BASE_REF=<ref> - Enforce overall non-regression and 100%% changed-code coverage\n"
@printf " make coverage-gate-platform BASE_REF=<ref> PROFILE=<file> - Enforce 100%% native changed-code coverage\n"
@printf " make update-interface-baseline - Add new CLI contracts without removing history\n"
@printf " make reset-interface-baseline - DANGEROUS: replace all CLI compatibility history\n"
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
@printf " make update-interface-baseline - Update the non-authoritative CLI smoke fixture\n"
@printf " make reset-interface-baseline - DANGEROUS: replace the non-authoritative CLI smoke fixture\n"
@printf " make schema-compatibility BASE_REF=<ref> [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check the authoritative Schema history\n"
@printf " make skill-command-integrity - Check dws commands referenced by skills exist\n"
@printf " make skill-context-budget - Check generated Skill drift and common-path context budgets\n"
@printf " make multi-im-skill-chain-integrity - Check reviewed IM intents keep one default Skill route\n"
@@ -103,10 +103,22 @@ edition-test:
$(GO) test -v -count=1 ./pkg/editiontest/...
interface-integrity:
@./scripts/policy/check-interface-baseline.sh
@base_ref="$(BASE_REF)"; \
candidate_ref="$(CANDIDATE_REF)"; \
if [ -z "$$base_ref" ]; then base_ref="origin/main"; fi; \
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
./scripts/policy/check-authoritative-interface-baselines.sh \
--base-ref "$$base_ref" \
--stable-ref "$(STABLE_REF)" \
--candidate-ref "$$candidate_ref"
authoritative-interface-integrity:
@./scripts/policy/check-authoritative-interface-baselines.sh --base-ref "$(BASE_REF)"
@candidate_ref="$(CANDIDATE_REF)"; \
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
./scripts/policy/check-authoritative-interface-baselines.sh \
--base-ref "$(BASE_REF)" \
--stable-ref "$(STABLE_REF)" \
--candidate-ref "$$candidate_ref"
coverage-gate:
@./scripts/policy/check-coverage-gate.sh --base-ref "$(BASE_REF)" --scope-buildable
@@ -121,7 +133,12 @@ reset-interface-baseline:
@./scripts/policy/check-interface-baseline.sh --reset
schema-compatibility:
@./scripts/policy/check-authoritative-schema-compatibility.sh --base-ref "$(BASE_REF)"
@candidate_ref="$(CANDIDATE_REF)"; \
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
./scripts/policy/check-authoritative-schema-compatibility.sh \
--base-ref "$(BASE_REF)" \
--stable-ref "$(STABLE_REF)" \
--candidate-ref "$$candidate_ref"
skill-command-integrity:
@./scripts/policy/check-skill-commands.sh
+26 -16
View File
@@ -471,7 +471,7 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
<details>
<summary><strong>Personal Event Subscription</strong> — real-time DingTalk messages for event-driven agents</summary>
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, and group title/disband lifecycle events.
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and six OA approval task/instance events.
The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`.
@@ -481,28 +481,33 @@ For an event-focused installation, use the official convenience installer:
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
# Or install the standalone multi skill from an existing dws installation
dws skill setup --mode multi -s event
```
```bash
# Inspect the public personal event catalog and schema
dws event list
dws event schema user_im_message_receive_o2o --flatten
dws event list --category oa
dws event schema user_oa_approval_task_created --flatten
# Listen for messages that mention the current user
dws event consume user_im_message_receive_at --flatten -f ndjson
dws event +listen-im --kind at-me -f ndjson
# Listen for one-to-one messages with a specified user
dws event consume user_im_message_receive_o2o --user <userId> --flatten -f ndjson
# Listen for messages from a specified sender
dws event +listen-im --kind sender --user <userId> -f ndjson
# Listen by openDingtalkId (external contact, bot, or cross-organization identity)
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> --flatten -f ndjson
dws event +listen-im --kind sender --open-dingtalk-id <openDingtalkId> -f ndjson
# Listen for messages in a specified group
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
dws event +listen-im --kind group --chat-id <openConversationId> -f ndjson
# Listen for all one-to-one or all group messages
dws event consume user_im_message_receive_o2o_all --flatten -f ndjson
dws event consume user_im_message_receive_group_all --flatten -f ndjson
dws event +listen-im --kind all-direct -f ndjson
dws event +listen-im --kind all-group -f ndjson
# Listen for a specified group's title changes, member changes, or disband event
dws event consume user_im_group_updated --group <openConversationId> --flatten -f ndjson
@@ -510,14 +515,19 @@ dws event consume user_im_group_member_added --group <openConversationId> --flat
dws event consume user_im_group_member_exited --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_disbanded --group <openConversationId> --flatten -f ndjson
# Listen for multiple events for the same user in one process
# Listen for messages, reads, and recalls from the same sender in one process
dws event +listen-im --kind sender --user <userId> \
--events message,read,recall -f ndjson
# Listen for all six public OA approval events in one process
dws event consume \
user_im_message_receive_o2o \
user_im_message_read_o2o \
user_im_message_recall_o2o \
--user <userId> \
--flatten \
-f ndjson
user_oa_approval_task_created \
user_oa_approval_task_finished \
user_oa_approval_task_redirected \
user_oa_approval_instance_started \
user_oa_approval_instance_terminated \
user_oa_approval_instance_finished \
--flatten -f ndjson
# Inspect local consumers and cancel a subscription
dws event status
@@ -536,7 +546,7 @@ For one-to-one and specified-sender events, use exactly one target identity: `--
| Observability | `status` shows remote subscriptions, the personal bus, and local consumers |
| Cross-platform | Unix Socket on macOS/Linux, Windows Named Pipe on Windows |
See `skills/multi/dingtalk-misc/references/event.md` for the Agent workflow and supported event parameters.
See `skills/multi/dingtalk-event/SKILL.md` for the Agent workflow and supported event parameters.
</details>
+26 -16
View File
@@ -465,7 +465,7 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
<details>
<summary><strong>个人事件订阅</strong> — 实时接收钉钉消息,驱动事件触发的 Agent</summary>
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应,以及群标题变更和群解散事件。
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及六个 OA 审批任务/实例事件。
默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。
@@ -475,28 +475,33 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
# 或在已有 dws 环境中安装独立的 multi skill
dws skill setup --mode multi -s event
```
```bash
# 查看公开个人事件目录和 schema
dws event list
dws event schema user_im_message_receive_o2o --flatten
dws event list --category oa
dws event schema user_oa_approval_task_created --flatten
# 监听当前用户被 @ 的消息
dws event consume user_im_message_receive_at --flatten -f ndjson
dws event +listen-im --kind at-me -f ndjson
# 监听与指定用户的单聊消息
dws event consume user_im_message_receive_o2o --user <userId> --flatten -f ndjson
# 监听指定发送人的消息
dws event +listen-im --kind sender --user <userId> -f ndjson
# 使用 openDingtalkId 监听外部联系人、机器人或跨组织身份
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> --flatten -f ndjson
dws event +listen-im --kind sender --open-dingtalk-id <openDingtalkId> -f ndjson
# 监听指定群的消息
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
dws event +listen-im --kind group --chat-id <openConversationId> -f ndjson
# 监听所有单聊或所有群消息
dws event consume user_im_message_receive_o2o_all --flatten -f ndjson
dws event consume user_im_message_receive_group_all --flatten -f ndjson
dws event +listen-im --kind all-direct -f ndjson
dws event +listen-im --kind all-group -f ndjson
# 监听指定群标题变更、成员进退群或群解散
dws event consume user_im_group_updated --group <openConversationId> --flatten -f ndjson
@@ -504,14 +509,19 @@ dws event consume user_im_group_member_added --group <openConversationId> --flat
dws event consume user_im_group_member_exited --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_disbanded --group <openConversationId> --flatten -f ndjson
# 一个进程监听同一用户的多个事件
# 一个进程监听同一发送人的消息、已读和撤回
dws event +listen-im --kind sender --user <userId> \
--events message,read,recall -f ndjson
# 一个进程监听全部六个公开 OA 审批事件
dws event consume \
user_im_message_receive_o2o \
user_im_message_read_o2o \
user_im_message_recall_o2o \
--user <userId> \
--flatten \
-f ndjson
user_oa_approval_task_created \
user_oa_approval_task_finished \
user_oa_approval_task_redirected \
user_oa_approval_instance_started \
user_oa_approval_instance_terminated \
user_oa_approval_instance_finished \
--flatten -f ndjson
# 查看本地 consume,并取消指定订阅
dws event status
@@ -530,7 +540,7 @@ dws event stop <subscribe_id>
| 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers |
| 跨平台 | macOS/Linux 使用 Unix Socket,Windows 使用 Named Pipe |
Agent 工作流和事件参数详见 `skills/multi/dingtalk-misc/references/event.md`。
Agent 工作流和事件参数详见 `skills/multi/dingtalk-event/SKILL.md`。
</details>
+91 -2
View File
@@ -17,18 +17,23 @@
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
"github.com/spf13/cobra"
)
var newRootCommand = func() *cobra.Command { return app.NewRootCommand() }
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
@@ -112,7 +117,11 @@ func runGenerate(args []string, stdout, stderr io.Writer) error {
defer i18n.SetLang(previousLang)
i18n.SetLang("en")
snapshot := interfacesnapshot.Capture(app.NewRootCommand())
root := newRootCommand()
snapshot := interfacesnapshot.Capture(root)
if err := validateHelpRendering(root, snapshot); err != nil {
return err
}
if *output == "-" {
return interfacesnapshot.Write(stdout, snapshot)
}
@@ -138,6 +147,16 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
currentPath := flags.String("current", "", "candidate snapshot path")
basePath := flags.String("base", "", "target main/development baseline snapshot path")
stablePath := flags.String("stable", "", "latest stable GA snapshot path")
approvedMigrationsPath := flags.String(
"approved-flag-migrations",
"",
"merge-base-owned approved flag migration manifest",
)
candidateMigrationsPath := flags.String(
"candidate-flag-migrations",
"",
"candidate flag migration manifest",
)
if err := flags.Parse(args); err != nil {
return false, err
}
@@ -150,6 +169,14 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
if *basePath == "" && *stablePath == "" {
return false, fmt.Errorf("compare requires --base, --stable, or both")
}
if (*approvedMigrationsPath == "") != (*candidateMigrationsPath == "") {
return false, fmt.Errorf(
"--approved-flag-migrations and --candidate-flag-migrations must be provided together",
)
}
if *approvedMigrationsPath != "" && (*basePath == "" || *stablePath == "") {
return false, fmt.Errorf("flag migration compare requires both --base and --stable")
}
current, err := readSnapshot(*currentPath)
if err != nil {
@@ -170,6 +197,25 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
}
report := interfacesnapshot.CompareAll(current, references)
if *approvedMigrationsPath != "" {
approved, readErr := readFlagMigrationManifest(*approvedMigrationsPath)
if readErr != nil {
return false, fmt.Errorf("read approved flag migrations: %w", readErr)
}
candidate, readErr := readFlagMigrationManifest(*candidateMigrationsPath)
if readErr != nil {
return false, fmt.Errorf("read candidate flag migrations: %w", readErr)
}
report, err = interfacesnapshot.CompareAllWithFlagMigrations(
current,
references,
approved,
candidate,
)
if err != nil {
return false, fmt.Errorf("validate flag migration lifecycle: %w", err)
}
}
encoder := json.NewEncoder(stdout)
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
@@ -179,6 +225,49 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
return report.Compatible, nil
}
func readFlagMigrationManifest(path string) (interfacesnapshot.FlagMigrationManifest, error) {
file, err := os.Open(filepath.Clean(path))
if err != nil {
return interfacesnapshot.FlagMigrationManifest{}, err
}
defer file.Close()
return interfacesnapshot.ReadFlagMigrationManifest(file)
}
func validateHelpRendering(root *cobra.Command, snapshot interfacesnapshot.Snapshot) error {
for _, command := range snapshot.Commands {
path := strings.TrimPrefix(command.Path, "dws")
resolved, remaining, err := root.Find(strings.Fields(path))
if err != nil || len(remaining) != 0 || resolved == nil {
return fmt.Errorf("resolve %q before help rendering: remaining=%v error=%v", command.Path, remaining, err)
}
if err := renderCommandHelp(resolved); err != nil {
return fmt.Errorf("render %q help: %w", command.Path, err)
}
}
return nil
}
func renderCommandHelp(command *cobra.Command) (err error) {
var stdout, stderr bytes.Buffer
command.InitDefaultHelpFlag()
command.SetOut(&stdout)
command.SetErr(&stderr)
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("help renderer panicked: %v", recovered)
}
}()
command.HelpFunc()(command, []string{})
if stderr.Len() > 0 {
return fmt.Errorf("help renderer wrote an error: %s", strings.TrimSpace(stderr.String()))
}
if stdout.Len() == 0 {
return fmt.Errorf("help renderer produced empty output")
}
return nil
}
func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
file, err := os.Open(filepath.Clean(path))
if err != nil {
@@ -191,5 +280,5 @@ func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
func printUsage(w io.Writer) {
fmt.Fprintln(w, "usage:")
fmt.Fprintln(w, " interface-snapshot generate [--output FILE]")
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE]")
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE]")
}
+447
View File
@@ -15,11 +15,16 @@ package main
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageRunGenerateCapturesActualRootOffline(t *testing.T) {
@@ -56,6 +61,24 @@ func TestCrossPlatformCoverageRunGenerateCapturesActualRootOffline(t *testing.T)
}
}
func TestCrossPlatformCoverageRunGenerateRejectsHelpRenderingFailure(t *testing.T) {
testseam.Swap(t, &newRootCommand, func() *cobra.Command {
root := &cobra.Command{Use: "dws"}
root.SetHelpFunc(func(command *cobra.Command, _ []string) {
_, _ = io.WriteString(command.ErrOrStderr(), "injected help failure")
})
return root
})
var stdout, stderr bytes.Buffer
if exitCode := run([]string{"generate"}, &stdout, &stderr); exitCode != 2 {
t.Fatalf("run(generate) exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stderr.String(), "injected help failure") {
t.Fatalf("run(generate) stderr=%q", stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareUsesBothSnapshotInputsAndExitCode(t *testing.T) {
current := commandSnapshot("dws")
mergeBase := commandSnapshot("dws")
@@ -83,6 +106,368 @@ func TestCrossPlatformCoverageRunCompareUsesBothSnapshotInputsAndExitCode(t *tes
}
}
func TestCrossPlatformCoverageRunCompareEnforcesBaseOwnedFlagMigrationLifecycle(t *testing.T) {
dir := t.TempDir()
before := flagMigrationSnapshot(false)
after := flagMigrationSnapshot(true)
currentPath := writeSnapshot(t, dir, "current.json", after)
basePath := writeSnapshot(t, dir, "base.json", before)
stablePath := writeSnapshot(t, dir, "stable.json", before)
approvedPath := writeManifest(t, dir, "approved.json", flagMigrationManifestJSON("pending"))
candidatePath := writeManifest(t, dir, "candidate.json", flagMigrationManifestJSON("consumed"))
var stdout, stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", currentPath,
"--base", basePath,
"--stable", stablePath,
"--approved-flag-migrations", approvedPath,
"--candidate-flag-migrations", candidatePath,
}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("exact base-owned migration exit=%d stderr=%s", exitCode, stderr.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"compatible": true`)) {
t.Fatalf("exact migration report is not compatible:\n%s", stdout.String())
}
stdout.Reset()
stderr.Reset()
emptyApproved := writeManifest(t, dir, "empty-approved.json", `{"version":1,"migrations":[]}`)
exitCode = run([]string{
"compare",
"--current", currentPath,
"--base", basePath,
"--stable", stablePath,
"--approved-flag-migrations", emptyApproved,
"--candidate-flag-migrations", candidatePath,
}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "must start pending") {
t.Fatalf("candidate self-approval exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareRequiresBothFlagMigrationInputs(t *testing.T) {
dir := t.TempDir()
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
basePath := writeSnapshot(t, dir, "base.json", commandSnapshot("dws"))
approvedPath := writeManifest(t, dir, "approved.json", `{"version":1,"migrations":[]}`)
var stdout, stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", currentPath,
"--base", basePath,
"--approved-flag-migrations", approvedPath,
}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "must be provided together") {
t.Fatalf("one-sided migration input exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareRequiresBothReferencesForFlagMigrations(t *testing.T) {
dir := t.TempDir()
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
basePath := writeSnapshot(t, dir, "base.json", commandSnapshot("dws"))
stablePath := writeSnapshot(t, dir, "stable.json", commandSnapshot("dws"))
approvedPath := writeManifest(t, dir, "approved.json", `{"version":1,"migrations":[]}`)
candidatePath := writeManifest(t, dir, "candidate.json", `{"version":1,"migrations":[]}`)
tests := []struct {
name string
args []string
}{
{
name: "missing stable",
args: []string{"--base", basePath},
},
{
name: "missing base",
args: []string{"--stable", stablePath},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
args := []string{"compare", "--current", currentPath}
args = append(args, test.args...)
args = append(args,
"--approved-flag-migrations", approvedPath,
"--candidate-flag-migrations", candidatePath,
)
var stdout, stderr bytes.Buffer
exitCode := run(args, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "requires both --base and --stable") {
t.Fatalf("one-reference migration compare exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
})
}
}
func TestCrossPlatformCoverageRunPrintsUsageForMissingAndUnknownCommands(t *testing.T) {
tests := []struct {
name string
args []string
wantStderr []string
}{
{
name: "missing command",
args: nil,
wantStderr: []string{"usage:", "interface-snapshot generate", "--approved-flag-migrations"},
},
{
name: "unknown command",
args: []string{"unknown"},
wantStderr: []string{`unknown command "unknown"`, "usage:", "interface-snapshot compare"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
t.Fatalf("run(%v) exit=%d, want 2", test.args, exitCode)
}
if stdout.Len() != 0 {
t.Fatalf("run(%v) unexpectedly wrote stdout: %s", test.args, stdout.String())
}
for _, want := range test.wantStderr {
if !strings.Contains(stderr.String(), want) {
t.Errorf("run(%v) stderr missing %q:\n%s", test.args, want, stderr.String())
}
}
})
}
}
func TestCrossPlatformCoverageRunRejectsInvalidSubcommandArguments(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "generate unknown flag", args: []string{"generate", "--unknown"}, want: "flag provided but not defined"},
{name: "generate positional", args: []string{"generate", "unexpected"}, want: "generate accepts no positional arguments"},
{name: "compare unknown flag", args: []string{"compare", "--unknown"}, want: "flag provided but not defined"},
{name: "compare positional", args: []string{"compare", "unexpected"}, want: "compare accepts no positional arguments"},
{name: "compare missing current", args: []string{"compare", "--base", "base.json"}, want: "compare requires --current"},
{name: "compare missing reference", args: []string{"compare", "--current", "current.json"}, want: "compare requires --base, --stable, or both"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
t.Fatalf("run(%v) exit=%d, want 2", test.args, exitCode)
}
if !strings.Contains(stderr.String(), test.want) {
t.Fatalf("run(%v) stderr missing %q:\n%s", test.args, test.want, stderr.String())
}
})
}
}
func TestCrossPlatformCoverageRunGenerateRejectsUnsafeOutputPath(t *testing.T) {
outputDirectory := t.TempDir()
var stdout, stderr bytes.Buffer
exitCode := run([]string{"generate", "--output", outputDirectory}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "create snapshot") {
t.Fatalf("directory output exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
}
func TestCrossPlatformCoverageRunGenerateReportsTemporaryDirectoryFailure(t *testing.T) {
missingTempRoot := filepath.Join(t.TempDir(), "missing")
for _, name := range []string{"TMPDIR", "TMP", "TEMP"} {
t.Setenv(name, missingTempRoot)
}
var stdout, stderr bytes.Buffer
exitCode := run([]string{"generate"}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "create isolated home") {
t.Fatalf("invalid temporary root exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareReportsSnapshotReadFailures(t *testing.T) {
dir := t.TempDir()
validPath := writeSnapshot(t, dir, "valid.json", commandSnapshot("dws"))
missingPath := filepath.Join(dir, "missing.json")
tests := []struct {
name string
args []string
want string
}{
{
name: "current",
args: []string{"compare", "--current", missingPath, "--base", validPath},
want: "read current snapshot",
},
{
name: "main",
args: []string{"compare", "--current", validPath, "--base", missingPath},
want: "read main/development baseline snapshot",
},
{
name: "stable",
args: []string{"compare", "--current", validPath, "--stable", missingPath},
want: "read stable snapshot",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
t.Fatalf("run(compare) exit=%d, want 2", exitCode)
}
if !strings.Contains(stderr.String(), test.want) {
t.Fatalf("stderr missing %q:\n%s", test.want, stderr.String())
}
})
}
}
func TestCrossPlatformCoverageRunCompareReportsEachManifestReadFailure(t *testing.T) {
dir := t.TempDir()
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
validManifest := writeManifest(t, dir, "valid-manifest.json", `{"version":1,"migrations":[]}`)
invalidManifest := writeManifest(t, dir, "invalid-manifest.json", `{`)
tests := []struct {
name string
approved string
candidate string
want string
}{
{
name: "approved manifest",
approved: invalidManifest,
candidate: validManifest,
want: "read approved flag migrations",
},
{
name: "candidate manifest",
approved: validManifest,
candidate: invalidManifest,
want: "read candidate flag migrations",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", snapshotPath,
"--base", snapshotPath,
"--stable", snapshotPath,
"--approved-flag-migrations", test.approved,
"--candidate-flag-migrations", test.candidate,
}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), test.want) {
t.Fatalf("%s exit=%d stdout=%s stderr=%s", test.name, exitCode, stdout.String(), stderr.String())
}
})
}
}
func TestCrossPlatformCoverageRunCompareReportsOutputFailure(t *testing.T) {
dir := t.TempDir()
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
var stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", snapshotPath,
"--base", snapshotPath,
}, failingWriter{}, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "write comparison report") {
t.Fatalf("comparison output failure exit=%d stderr=%s", exitCode, stderr.String())
}
}
func TestCrossPlatformCoverageReadHelpersRejectMissingAndInvalidInputs(t *testing.T) {
dir := t.TempDir()
missingPath := filepath.Join(dir, "missing.json")
invalidPath := filepath.Join(dir, "invalid.json")
if err := os.WriteFile(invalidPath, []byte(`{`), 0o600); err != nil {
t.Fatalf("write invalid fixture: %v", err)
}
if _, err := readSnapshot(missingPath); err == nil {
t.Fatal("readSnapshot(missing) unexpectedly succeeded")
}
if _, err := readSnapshot(invalidPath); err == nil {
t.Fatal("readSnapshot(invalid) unexpectedly succeeded")
}
if _, err := readFlagMigrationManifest(missingPath); err == nil {
t.Fatal("readFlagMigrationManifest(missing) unexpectedly succeeded")
}
if _, err := readFlagMigrationManifest(invalidPath); err == nil {
t.Fatal("readFlagMigrationManifest(invalid) unexpectedly succeeded")
}
}
func TestCrossPlatformCoverageValidateHelpRenderingReportsResolveError(t *testing.T) {
root := &cobra.Command{Use: "dws"}
err := validateHelpRendering(root, commandSnapshot("dws missing"))
if err == nil || !strings.Contains(err.Error(), `resolve "dws missing" before help rendering`) {
t.Fatalf("validateHelpRendering resolve error = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingReportsTemplateError(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.SetHelpTemplate(`{{index .Commands 99}}`)
err := validateHelpRendering(root, commandSnapshot("dws"))
if err == nil || !strings.Contains(err.Error(), `render "dws" help`) {
t.Fatalf("validateHelpRendering template error = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingRecoversTemplatePanic(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.SetHelpTemplate("{{")
err := validateHelpRendering(root, commandSnapshot("dws"))
if err == nil || !strings.Contains(err.Error(), `render "dws" help`) {
t.Fatalf("validateHelpRendering template panic = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingRejectsCustomHelpStderr(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.SetHelpFunc(func(command *cobra.Command, _ []string) {
_, _ = io.WriteString(command.ErrOrStderr(), "injected help failure")
})
err := validateHelpRendering(root, commandSnapshot("dws"))
if err == nil || !strings.Contains(err.Error(), "injected help failure") {
t.Fatalf("validateHelpRendering custom stderr = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingRejectsEmptyOutput(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.SetHelpFunc(func(*cobra.Command, []string) {})
err := validateHelpRendering(root, commandSnapshot("dws"))
if err == nil || !strings.Contains(err.Error(), "empty") {
t.Fatalf("validateHelpRendering empty output = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingAcceptsNormalOutput(t *testing.T) {
root := &cobra.Command{Use: "dws", Short: "root command"}
if err := validateHelpRendering(root, commandSnapshot("dws")); err != nil {
t.Fatalf("validateHelpRendering normal output: %v", err)
}
}
type failingWriter struct{}
func (failingWriter) Write([]byte) (int, error) {
return 0, errors.New("injected write failure")
}
var _ io.Writer = failingWriter{}
func commandSnapshot(paths ...string) interfacesnapshot.Snapshot {
commands := make([]interfacesnapshot.Command, 0, len(paths))
for _, path := range paths {
@@ -120,6 +505,68 @@ func writeSnapshot(t *testing.T, dir, name string, snapshot interfacesnapshot.Sn
return path
}
func writeManifest(t *testing.T, dir, name, contents string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
return path
}
func flagMigrationSnapshot(after bool) interfacesnapshot.Snapshot {
legacy := interfacesnapshot.Flag{
Name: "legacy-id",
Shorthand: "l",
Type: "string",
Default: "",
NoOpt: "auto",
Required: true,
}
flags := []interfacesnapshot.Flag{legacy}
if after {
legacy.Required = false
legacy.Hidden = true
legacy.AliasOf = "message-id"
flags = []interfacesnapshot.Flag{
legacy,
{Name: "message-id", Type: "string", Default: "", Required: true},
}
}
return interfacesnapshot.Snapshot{
SchemaVersion: interfacesnapshot.SchemaVersion,
Rules: interfacesnapshot.Rules{
ExcludedCommandSubtrees: []string{},
ExcludedFlags: []string{},
},
Commands: []interfacesnapshot.Command{
{Path: "dws", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
{Path: "dws chat send", Runnable: true, Aliases: []string{}, LocalFlags: flags, InheritedFlags: []interfacesnapshot.Flag{}},
},
}
}
func flagMigrationManifestJSON(state string) string {
return strings.Replace(`{
"version": 1,
"migrations": [{
"command": "dws chat send",
"legacy": {
"name": "legacy-id",
"before": {"present": true, "type": "string", "required": true, "shorthand": "l", "no_opt": "auto", "scope": "local"},
"after": {"present": true, "type": "string", "hidden": true, "shorthand": "l", "no_opt": "auto", "scope": "local", "alias_of": "message-id"}
},
"canonical": {
"name": "message-id",
"before": {"present": false},
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
},
"state": "STATE",
"reason": "reviewed exact migration"
}]
}`, "STATE", state, 1)
}
func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool {
for _, flag := range flags {
if flag.Name == name && flag.Type == flagType {
+39 -8
View File
@@ -157,15 +157,33 @@ expected to repeat every CI job locally:
```sh
make build
make policy
make interface-integrity
make authoritative-interface-integrity BASE_REF=<merge-base>
make schema-compatibility BASE_REF=<merge-base>
make interface-integrity BASE_REF=<merge-base> STABLE_REF=<stable-tag> CANDIDATE_REF=<candidate-sha>
make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<stable-tag> CANDIDATE_REF=<candidate-sha>
make skill-command-integrity
make cli-smoke
make mock-mcp-smoke
go test -v -count=1 ./pkg/editiontest/...
```
CI 先解析并核对精确的 merge-base、最近可达且未撤回的 stable GA tag 和已提交的 candidate
SHA,再调用 `make authoritative-interface-integrity`。本地 `make interface-integrity`
与该 CI target 都只委托给同一个 modern authoritative wrapper,不存在第二个比较
入口。省略 `BASE_REF` 时本地 target 默认比较 `origin/main`,省略 `STABLE_REF` 时自动
选择该 base 可达且未撤回的最近 stable GA tag,省略 `CANDIDATE_REF` 时比较已提交的 `HEAD`。
需要逐字复现某次 CI 时,应显式传入该次运行记录的 merge-base、stable tag 和
candidate SHA。
`make update-interface-baseline` / `make reset-interface-baseline` 只维护
`test/fixtures/cli-interface-baseline.txt` 这一份非权威 CLI Smoke fixture。底层旧
`check-interface-baseline.sh` 不再作为本地或 CI 的兼容性审批入口,也不能用于批准
flag 迁移。
Schema compatibility 使用同一组 base、stable、candidate refs 和同一份 base-owned flag
migration ledger。merge-base-owned checker 分别规范化 merge-base 与 stable 的完整
Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过 Interface
lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会
放宽其他 Schema 历史字段。
For an exact CHANGELOG-only branch:
```sh
@@ -186,11 +204,24 @@ are evaluated by the same block-deduplicating checker; supporting policy and
shortcut profiles contribute to changed-code coverage only. The checked-in
badge is presentation only and is never read as a gate input.
Compatibility checks derive authoritative Interface snapshots from the PR
merge-base and the latest reachable stable release. The candidate cannot bless
a breaking change by editing a fixture. Schema additions are allowed;
historical products, tools, parameters, mappings, positional execution fields,
constraints, and safety semantics remain protected.
CLI 兼容检查只使用 modern Interface Snapshot 这一处权威比较 seam,并从 PR
merge-base 和最近的可达 stable release 生成权威快照。本治理机制合入后,
merge-base 拥有生成器、比较器和已审批迁移清单,因此 candidate 不能通过修改
helper、fixture 或在同一 PR 新增 self-approval 记录来放行 breaking change。首次
bootstrap 仍由 merge-base 已有的 modern helper 做无豁免比较,并只接受 candidate
提交中的规范空清单;完整边界见下方治理文档。
精确的两阶段 flag 迁移生命周期见
[CLI flag 兼容迁移治理](cli-interface-flag-migrations.md)。治理 PR 只能在
surface 未变化时新增 `pending`;后续产品 PR 达到审批的精确 surface 后,才能
消费 base-owned 记录并改为 `consumed`。在 main 与 stable 都达到 after 状态前
必须保留该回执,之后再由单独 PR 清理。机制只放行记录中的 legacy
visible-to-hidden,以及 canonical required 新增或提升;删除、type、scope、
shorthand、no-opt 和任何无关漂移仍然阻塞。Schema 可以新增;历史 product、
tool、parameter、mapping、positional execution、constraint 与 safety 语义继续
受保护。`alias_of` 只是一项由 `FlagSpec.Aliases` 产生的框架关系证据,不是 payload
等价证明;产品 PR 仍须证明 canonical 与 legacy 的最终运行 payload 等价并在 transport
前拒绝冲突输入。当前迁移清单为空,不授权 PR #904。
## Required GitHub repository settings
+176
View File
@@ -0,0 +1,176 @@
# CLI flag 兼容迁移治理
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 提升为必填。它只解决这一种精确变更,不是通用 breaking-change 豁免。
同名 flag 的精确类型迁移属于另一类评审机制,只能进入
`internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename
只能进入本文的 JSON lifecycle ledger。一项迁移不得跨两种机制组合授权。
## 唯一比较入口与信任边界
PR 与本地兼容性审批的唯一权威比较入口是 modern Interface Snapshot:
- `cmd/interface-snapshot` 生成和比较快照;
- `internal/interfacesnapshot` 实现兼容规则和迁移生命周期;
- `scripts/policy/check-command-compatibility.sh` 组装 candidate、PR merge-base 和最近可达且未撤回的 stable GA 三份快照;
- `scripts/policy/check-authoritative-interface-baselines.sh` 只保留为 Makefile 的兼容包装,不再维护第二套判断逻辑。
紧随其后的 Schema compatibility 不是第二份审批清单。它从同一 merge-base-owned
ledger 和同一组三方 Interface Snapshot 取得已经完成 lifecycle 校验的
authorization。merge-base-owned checker 会分别规范化 merge-base 与 stable 的完整
Schema,并让 candidate 对两份历史 contract 独立执行检查;授权的 flag rename 只会
精确投影到当前被检查的历史副本。candidate 不能为 CLI 与 Schema 分别提供两套例外。
`make interface-integrity` 也调用上述 authoritative wrapper;默认 base 为
`origin/main`,stable 可由包装脚本自动解析,candidate 默认为已提交的 `HEAD`。旧
`scripts/policy/check-interface-baseline.sh` 只供
`make update-interface-baseline` / `make reset-interface-baseline` 维护非权威 CLI
Smoke fixture,不参与迁移审批。
直接调用 `interface-snapshot compare` 时,只要提供 migration manifest 参数,就必须
同时提供 `--base` 与 `--stable`;核心 lifecycle 也拒绝缺失 stable 的非空清单,避免
调用方因漏传历史参考而提前清理 consumed receipt。
PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入本机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空清单,不会让 candidate 新增的 comparator 决定本 PR 是否兼容。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此本治理 PR 的新 parser、lifecycle、launcher 与 hostile tests 仍是必须由真人评审的受保护策略变更;它们合入后才成为后续 PR 的 base-owned authority。
这条边界保护比较规则和审批数据,不是任意代码沙箱。GitHub workflow / launcher 的变更仍由仓库保护规则和真人评审负责;candidate Cobra 构建也会执行 candidate 代码,因此对同一 runner 上的主动恶意代码,需要独立进程或文件系统隔离,不能把本门禁描述成已经解决。
已审批清单固定为:
```text
scripts/policy/interface-migrations/approved-flag-migrations-v1.json
```
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。当前清单为空,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。
## 两阶段迁移与回执清理
每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期:
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|---|---|---|
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 达到记录的必填状态 |
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。
下面只是清单结构示例,不代表已审批命令;实际字段必须从 Interface Snapshot 核对:
```json
{
"version": 1,
"migrations": [
{
"command": "dws chat message recall",
"legacy": {
"name": "msg-id",
"before": {
"present": true,
"type": "string",
"required": true,
"scope": "local"
},
"after": {
"present": true,
"type": "string",
"hidden": true,
"scope": "local",
"alias_of": "message-id"
}
},
"canonical": {
"name": "message-id",
"before": { "present": false },
"after": {
"present": true,
"type": "string",
"required": true,
"scope": "local"
}
},
"state": "pending",
"reason": "保留旧 argv 兼容性,并将规范 flag 设为唯一可见入口"
}
]
}
```
产品迁移 PR 必须保持同一条记录的命令、flag、before/after 和 reason 不变,只把 `pending` 改成 `consumed`。
## `alias_of` 是框架来源的受评审关系证据
`alias_of` 不是 Schema 同义词、参数概念词典或任意文字声明。它只能由 `FlagSpec.Aliases` 写入,并与内部 origin `corecmd.flag_spec_aliases.v1` 成对出现;每次 Interface Integrity 都会在已提交的 detached candidate 上执行源码门禁,禁止其他生产文件写入或复刻这些 evidence token。Interface Snapshot 会验证:
- legacy 与 canonical 位于同一个可执行命令;
- canonical flag 确实存在;
- legacy 与 canonical 类型一致;
- legacy 不是指向自身,也不存在 alias chain;
- legacy 的 after 状态精确指向该记录中的 canonical flag。
通过命令框架声明 `FlagSpec.Aliases` 时,框架会自动注册隐藏的兼容 flag,并写入两项 relation annotation;仅手写 `alias_of`、伪造 origin、重复值或不精确值都会让快照生成失败。不要用 Schema overlay、迁移清单或手写 Cobra annotation 伪造关系。
这项关系证据只证明 legacy/canonical 经过受控框架路径建立关系,不证明最终 transport payload 等价,也不会替产品代码实现命令特有的值同步。当前框架还禁止把
`MarkRequired` 与 `FlagSpec.Aliases` 直接组合,因为 Cobra 的 hard-required
校验只识别 canonical spelling。若产品迁移同时需要 canonical 的 Cobra required
标记和 legacy spelling,产品 PR 必须提供明确的运行时方案,并通过 canonical / legacy
最终 payload 等价、同值输入一致、冲突输入在 transport 前失败、legacy 仍可调用但 Help 隐藏等测试;迁移清单和 relation evidence 都不能替代这些证明。
## 豁免边界
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
2. canonical flag 的 `required_flag_added`(新增时即必填)或 `flag_became_required`(已有 flag 从可选变必填)。
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
- 删除 legacy、canonical、命令或其他 flag;
- flag 类型或迁移记录中的 scope、shorthand、`no_opt` 漂移;
- `alias_of` 缺失、指向变化或 alias chain;
- 命令路径及任何无关的阻塞性接口变化;
- 不精确、部分完成、超出记录范围的 surface 变化。
## Schema 投影边界
Agent-visible command 会把 visible Cobra flag 投影为 Schema parameter,因此合法的
legacy hidden 迁移会同时表现为历史 parameter 消失,constraint member 也可能从
legacy 名改为 canonical 名。Schema adapter 只接受已经由三方 Interface Snapshot
判定为 authorized 的迁移,并按 tool 的精确 `primary_cli_path` 绑定:
- reference 仍处于 `before` 且该 flag 有 Schema surface 时,baseline legacy parameter
必须存在,candidate legacy parameter 必须消失,candidate canonical parameter 必须存在;
如果 baseline 只有 canonical、没有 legacy,则 adapter 不得借 CLI ledger 提升
`required` / `cli_required` 或重写 constraint;
- rename 前后的 `type`、`property`、`interface_type`、default、format、enum 与
`required_when` 必须完全一致;
- `required` / `cli_required` 只能保持不变或按审批从 `false` 提升为 `true`,禁止降低;
- constraint 只允许在同一 tool 内按已枚举的 legacy → canonical map 做 member 替换、
排序与去重;group kind、非迁移 member 或 group 增删仍然阻塞;
- 多个 legacy 指向同一 canonical 时,所有历史 parameter signature 必须一致,否则
fail closed。
adapter 先构造经过上述验证的历史 contract 副本,再调用原 Schema checker;它不会按
错误字符串删除 finding。这样既能处理纯 rename,也能阻止“旧 required 参数改名后意外
变为 optional”或 property 漂移等伪兼容。`consumed` 回执在 merge-base Schema 已经处于
canonical-only `after` 状态时不需要再次投影;adapter 保持 baseline 不变,由原 checker
验证 candidate 是否仍与该 canonical contract 兼容。
## 本地验证
先确保 merge-base 和 stable tag 已在本地,然后运行与 CI 相同的权威门禁:
```sh
make interface-integrity \
BASE_REF=<merge-base> \
STABLE_REF=<stable-tag> \
CANDIDATE_REF=<candidate-sha>
make schema-compatibility \
BASE_REF=<merge-base> \
STABLE_REF=<stable-tag> \
CANDIDATE_REF=<candidate-sha>
```
`STABLE_REF` 必须解析到从该 merge-base 可达的最高未撤回 stable GA tag;primary checker 会按 release contract 独立核对,不能用任意 after commit 或已撤回版本提前清理回执。包装脚本可以在省略时自动解析。`CANDIDATE_REF` 省略时固定为命令启动时的已提交 `HEAD`;评审和复现 CI 时应显式传入 candidate SHA,避免 surface 与清单来自不同 revision。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,84 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package app
import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
func TestCrossPlatformCoverageChatMessageReceiptActionsBindToRunnableCommands(t *testing.T) {
testCases := []struct {
name string
payload map[string]any
}{
{
name: "send receipt awaiting status",
payload: chatmsg.ProjectMessageSendReceipt(map[string]any{
"openTaskId": "task-pending",
}),
},
{
name: "send receipt ready for message actions",
payload: chatmsg.ProjectMessageSendReceipt(map[string]any{
"openTaskId": "task-ready",
"openMessageId": "message-ready",
"openConversationId": "conversation-ready",
}),
},
{
name: "send status awaiting message reference",
payload: chatmsg.ProjectMessageSendStatus(map[string]any{
"status": "PENDING",
}, "task-pending"),
},
{
name: "send status ready for message actions",
payload: chatmsg.ProjectMessageSendStatus(map[string]any{
"openTaskId": "task-ready",
"openMessageId": "message-ready",
"openConversationId": "conversation-ready",
"status": "SUCCESS",
}, "task-ready"),
},
}
root := NewRootCommand()
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
actions, ok := testCase.payload["nextActions"].([]map[string]any)
if !ok || len(actions) == 0 {
t.Fatalf("nextActions = %#v, want non-empty []map[string]any", testCase.payload["nextActions"])
}
for index, action := range actions {
cliPath, ok := action["cliPath"].(string)
if !ok || strings.TrimSpace(cliPath) == "" {
t.Fatalf("nextActions[%d].cliPath = %#v, want non-empty string", index, action["cliPath"])
}
command, remaining, err := root.Find(strings.Fields(cliPath))
if err != nil {
t.Fatalf("nextActions[%d].cliPath %q does not bind: %v", index, cliPath, err)
}
if command == nil || len(remaining) != 0 || !command.Runnable() {
t.Fatalf("nextActions[%d].cliPath %q resolved to command=%v remaining=%v runnable=%v", index, cliPath, command, remaining, command != nil && command.Runnable())
}
arguments, ok := action["arguments"].(map[string]any)
if !ok {
t.Fatalf("nextActions[%d].arguments = %#v, want map[string]any", index, action["arguments"])
}
for name := range arguments {
if command.Flags().Lookup(name) == nil && command.InheritedFlags().Lookup(name) == nil {
t.Errorf("nextActions[%d] argument %q is not a flag of runnable command %q", index, name, cliPath)
}
}
}
})
}
}
+11 -12
View File
@@ -80,12 +80,12 @@ func newEventCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "event",
Selection: contract.ProductSelectionDecl{
AgentSummary: "订阅/消费个人消息、动作与群生命周期事件,并管理订阅生命周期",
AgentSummary: "实时监听当前用户相关的个人 IM 与 OA 审批事件,并管理订阅生命周期",
UseWhen: []string{
"需要实时监听个人消息接收、全量消息、已读、撤回、表情回应或群生命周期事件,或管理个人事件订阅生命周期",
"需要实时监听未来发生的个人消息、消息动作、群生命周期或 OA 审批任务/实例事件,或管理个人事件订阅生命周期",
},
AvoidWhen: []string{
"查历史聊天或主动发消息分别用 chat 查询/发送命令",
"查历史聊天或主动发消息用 chat;查询或处理审批实例/任务用 oa;配置开放平台应用事件回调用 dev app event",
},
},
})
@@ -333,7 +333,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
f.BoolVar(&force, "force", false,
"仅 --foreground 模式生效:跳过单实例锁 (慎用:会让云事件被随机切分)")
f.BoolVar(&dryRun, "dry-run", false,
"仅打印解析后的配置,不连接 bus / 云端")
"仅打印解析后的配置;不创建订阅、不连接 bus;复用 --subscribe-id 时会只读查询控制面")
f.BoolVar(&foreground, "foreground", false,
"当前进程直接跑 bus 服务、不 fork、不打印事件(给 systemd/k8s 托管用);读事件不要用它")
f.StringVar(&personalOpts.SubscribeID, "subscribe-id", "",
@@ -398,22 +398,21 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
Reason: "Reviewed composite workflow: the command creates or reuses a remote personal-event subscription and coordinates the local event bus and Stream consumer; no single pinned RPC represents the workflow.",
},
Selection: contract.SelectionSpec{
AgentSummary: "订阅并持续消费一个或多个兼容的个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
AgentSummary: "消费 OA、群生命周期或需要底层控制的个人事件流;Agent 通常使用 --flatten 输出 NDJSON",
UseWhen: []string{
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"用户明确要求监听当前身份的所有单聊或所有群消息",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"需要监听六个公开 OA 审批任务/实例 EventKey 中的一个或多个事件",
"需要监听指定群的标题变更、成员进退群或群解散事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标",
"同一目标、同一过滤条件需要同时监听多个兼容事件",
"用户显式给出原始 EventKey、Filter DSL、subscribe_id,要求原始 transport envelope,或需要普通 IM facade 不提供的高级多事件控制",
},
AvoidWhen: []string{
"普通 @我、指定发送人/群、全部单聊/群聊及 message/reaction/read/recall 监听优先使用 event +listen-im",
"只查历史聊天记录时用 chat 查询命令",
"查询、同意、拒绝、转交、撤销或发起审批时用 oa;配置应用事件回调时用 dev app event",
"只看事件目录/字段时用 event list / event schema",
},
Examples: []string{
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
"dws event consume user_im_message_receive_o2o user_im_message_read_o2o --user test-user-001 --flatten --max-events 2 --format ndjson",
"dws event consume user_oa_approval_task_created user_oa_approval_instance_finished --flatten --duration 10m --format ndjson",
"dws event consume user_im_group_member_added --group cid-example --flatten --max-events 1 --format ndjson",
},
},
},
+3 -3
View File
@@ -71,7 +71,7 @@ func newEventListenIMCommand() *cobra.Command {
Use: "+listen-im",
Short: "按 IM 意图解析目标并监听一个或多个个人消息事件",
Long: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey," +
"自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期。",
"自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期;本命令只处理 IM,不接收 OA 审批事件。",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, _ []string) error {
@@ -143,12 +143,12 @@ func newEventListenIMCommand() *cobra.Command {
Reason: "Reviewed IM event facade: it deterministically maps kind/events to public personal EventKeys, resolves one natural user/chat target with the shared typed resolver, then delegates one single- or multi-event invocation to the existing subscription, bus, ready-marker, NDJSON, rollback, cancellation, and cleanup lifecycle.",
},
Selection: contract.SelectionSpec{
AgentSummary: "按 @我、姓名、群名或全量范围监听一个或多个 IM 消息事件",
AgentSummary: "按 @我、发送人、群或全量范围监听普通 IM message/reaction/read/recall 事件",
UseWhen: []string{
"已知要监听 @我、指定发送人、指定群、全部单聊或全部群聊的 message/reaction/read/recall 事件时使用;姓名用 --user-query、群名用 --chat-query,CLI 会唯一解析目标并把多个兼容事件合并到一个消费生命周期。",
},
AvoidWhen: []string{
"需要群标题/成员/解散等生命周期事件、显式 EventKey、复用 subscribe_id、Filter DSL、原始 transport envelope 或其它底层 consume 控制时使用 event consume;只查历史消息时使用 chat 查询入口",
"OA 审批事件、群标题/成员/解散等生命周期事件、显式 EventKey、复用 subscribe_id、Filter DSL、原始 transport envelope 或其它底层控制使用 event consume;只查历史消息使用 chat 查询入口",
},
Examples: []string{
"dws event +listen-im --kind at-me --max-events 1",
+2 -2
View File
@@ -152,8 +152,8 @@ func TestCrossPlatformCoveragePersonalSubscriptionProtectionCoversAllPublicEvent
}
}
if publicCount != 16 {
t.Fatalf("public personal events = %d, want 16", publicCount)
if publicCount != 22 {
t.Fatalf("public personal events = %d, want 22 (16 IM + 6 OA)", publicCount)
}
for _, ruleType := range []string{"at", "all", "singleChat", "sender", "group"} {
if !ruleTypes[ruleType] {
+97 -26
View File
@@ -26,6 +26,7 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"text/tabwriter"
"time"
@@ -201,12 +202,15 @@ func newEventSchemaCommand() *cobra.Command {
},
Selection: contract.SelectionSpec{
AgentSummary: "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
UseWhen: []string{"已知任一公开个人 IM event_key,消费前需要理解输出字段或保守 payload 契约"},
UseWhen: []string{"已知任一公开个人 IM 或 OA event_key,消费前需要理解 --flatten 输出字段或 payload 契约"},
AvoidWhen: []string{
"查询 CLI 命令参数契约时用顶层 dws schema",
"要实际收事件时用 event consume",
},
Examples: []string{"dws event schema user_im_message_receive_at --flatten --format json"},
Examples: []string{
"dws event schema user_im_message_receive_at --flatten --format json",
"dws event schema user_oa_approval_task_created --flatten --format json",
},
},
},
})
@@ -262,6 +266,9 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return personalSubscriptionValidationError(err)
}
if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil {
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
rawFormat := ""
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
rawFormat = opts.Common.FormatRaw
@@ -290,11 +297,18 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
if err != nil {
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
if opts.Common.DryRun {
if strings.TrimSpace(opts.SubscribeID) == "" {
if err := validatePersonalSubscriptionOptions(opts); err != nil {
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
} else {
_, eventKey, _, err := personalEnsureSubscription(ctx, client, identity, opts)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
opts.EventKey = eventKey
}
cfg := consume.Config{
WorkDir: workDir,
@@ -369,7 +383,6 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
}
}
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
var attempt *personalSubscriptionAttemptReservation
if strings.TrimSpace(opts.SubscribeID) == "" {
attempt, err = reservePersonalSubscriptionAttempts(
@@ -408,9 +421,17 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
)
return fmt.Errorf("event consume --as user: %w", err)
}
cleanup := func(cleanupCtx context.Context) {
_ = personalDeleteSubscription(client, cleanupCtx, sub.SubscribeID)
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
ownsSubscription := selfCreated || opts.Ephemeral
var cleanupOnce sync.Once
cleanupOwnedSubscription := func(cleanupCtx context.Context) {
if !ownsSubscription {
return
}
cleanupOnce.Do(func() {
_ = personalDeleteSubscription(client, cleanupCtx, sub.SubscribeID)
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
})
}
if err := personalUpsertRunState(workDir, personal.RunState{
SubscribeID: sub.SubscribeID,
@@ -421,19 +442,19 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
IdentityHash: identityHash,
}); err != nil {
wrapped := fmt.Errorf("save run state: %w", err)
cleanupCtx := context.Background()
if personalSubscriptionCanceled(ctx, wrapped) {
cleanupCtx = ctx
}
if attempt != nil {
cleanupCtx := context.Background()
if personalSubscriptionCanceled(ctx, wrapped) {
cleanupCtx = ctx
}
classification := personalSubscriptionLocalFailure()
wrapped = attempt.completeFailure(ctx, 0, 0, wrapped, &classification)
cleanup(cleanupCtx)
}
cleanupOwnedSubscription(cleanupCtx)
return fmt.Errorf("event consume --as user: %w", wrapped)
}
if err := attempt.completeSuccess(); err != nil {
cleanup(context.Background())
cleanupOwnedSubscription(context.Background())
return fmt.Errorf("event consume --as user: %w", err)
}
// Ownership-based cleanup: a subscription this run CREATED is
@@ -442,9 +463,8 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
// leaks server-side. A subscription REUSED via --subscribe-id is left
// intact — the caller owns its lifecycle. --ephemeral forces cleanup
// either way.
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
if opts.Ephemeral || selfCreated {
defer cleanup(context.Background())
if ownsSubscription {
defer cleanupOwnedSubscription(context.Background())
}
cfg.EventKey = eventKey
@@ -466,17 +486,9 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
Source: foregroundSource,
}
bus.ApplyEnvTuning(&busCfg)
err = personalBusRun(ctx, busCfg)
if err != nil && !opts.Ephemeral {
cleanup(context.Background())
}
return err
return personalBusRun(ctx, busCfg)
}
err = personalConsumeRun(ctx, cfg)
if err != nil && !opts.Ephemeral {
cleanup(context.Background())
}
return err
return personalConsumeRun(ctx, cfg)
}
type personalMultiSubscription struct {
@@ -688,6 +700,9 @@ func preparePersonalMultiOptions(opts personalConsumeOptions) ([]personalConsume
if !def.Public {
return nil, personal.PublicAvailabilityError(eventKey)
}
if err := validatePersonalOAOptions(eventKey, opts); err != nil {
return nil, err
}
switch def.RuleType {
case "singleChat", "sender":
hasUserScope = true
@@ -814,6 +829,9 @@ func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOption
}
func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error {
if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil {
return err
}
if _, _, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
RuleType: opts.Rule,
UserID: opts.UserID,
@@ -826,6 +844,37 @@ func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error {
return err
}
func validatePersonalOAOptions(eventKey string, opts personalConsumeOptions) error {
changed := personalOAOptionNames(opts)
if len(changed) == 0 {
return nil
}
def, ok := personalLookupDefinition(strings.TrimSpace(eventKey))
if !ok || def.Category != "oa" {
return nil
}
return fmt.Errorf("%s not supported for OA event %s", strings.Join(changed, ", "), eventKey)
}
func personalOAOptionNames(opts personalConsumeOptions) []string {
var changed []string
for _, item := range []struct {
name string
value string
}{
{name: "--user", value: opts.UserID},
{name: "--open-dingtalk-id", value: opts.OpenDingTalkID},
{name: "--group", value: opts.GroupID},
{name: "--query", value: opts.QueryCSV},
{name: "--filter-json", value: opts.FilterJSON},
} {
if strings.TrimSpace(item.value) != "" {
changed = append(changed, item.name)
}
}
return changed
}
type personalPreparedSubscription struct {
EventKey string
RuleType string
@@ -839,6 +888,9 @@ func preparePersonalSubscription(identity personal.Identity, opts personalConsum
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return personalPreparedSubscription{}, err
}
if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil {
return personalPreparedSubscription{}, err
}
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
RuleType: opts.Rule,
UserID: opts.UserID,
@@ -885,13 +937,32 @@ func ensurePersonalSubscription(ctx context.Context, client *personal.Client, id
if err != nil {
return nil, "", "", err
}
eventKey := firstNonEmptyPersonalString(opts.EventKey, sub.EventKey)
if sub == nil {
return nil, "", "", errors.New("personal event: server returned an empty subscription")
}
requestedEventKey := strings.TrimSpace(opts.EventKey)
actualEventKey := strings.TrimSpace(sub.EventKey)
if requestedEventKey != "" && actualEventKey != "" && requestedEventKey != actualEventKey {
return nil, "", "", fmt.Errorf(
"event_key %q does not match reused subscription %q event_key %q",
requestedEventKey,
strings.TrimSpace(opts.SubscribeID),
actualEventKey,
)
}
eventKey := actualEventKey
if eventKey == "" {
eventKey = requestedEventKey
}
if eventKey == "" {
return nil, "", "", fmt.Errorf("event_key is required when --subscribe-id lookup returns no event_key")
}
if err := ensurePublicPersonalEvent(eventKey); err != nil {
return nil, "", "", err
}
if err := validatePersonalOAOptions(eventKey, opts); err != nil {
return nil, "", "", err
}
ruleType := firstNonEmptyPersonalString(sub.RuleType, opts.Rule)
if ruleType == "" {
if def, ok := personal.Lookup(eventKey); ok {
@@ -18,6 +18,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
eventtransport "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -36,18 +37,20 @@ func TestCrossPlatformCoveragePersonalEventRemainingSchemaAndSubscriptionCoverag
}
}
oldGet := personalGetSubscription
oldCreate := personalCreateSubscription
t.Cleanup(func() {
personalGetSubscription = oldGet
personalCreateSubscription = oldCreate
})
testseam.Protect(t, &personalGetSubscription)
testseam.Protect(t, &personalCreateSubscription)
client := personal.NewClient("https://example.test", personal.Identity{})
wantErr := errors.New("subscription")
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) { return nil, wantErr }
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
t.Fatalf("get subscription error = %v", err)
}
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return nil, nil
}
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); err == nil || !strings.Contains(err.Error(), "empty subscription") {
t.Fatalf("nil subscription = %v", err)
}
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{}, nil
}
+627
View File
@@ -0,0 +1,627 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"bytes"
"context"
"encoding/json"
"io"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/spf13/cobra"
)
func TestPersonalOAEventListAndSchemaCommands(t *testing.T) {
list := newEventListCommand()
list.SilenceUsage = true
list.SilenceErrors = true
var listOut bytes.Buffer
list.SetOut(&listOut)
list.SetArgs([]string{"--category", "oa"})
if err := list.Execute(); err != nil {
t.Fatalf("event list --category oa error = %v", err)
}
tests := []struct {
eventKey string
properties []string
}{
{
eventKey: personal.EventOAApprovalTaskCreated,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "task_id", "title", "status", "create_time", "event_time",
},
},
{
eventKey: personal.EventOAApprovalTaskFinished,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "task_id", "title", "status", "result", "create_time",
"finish_time", "event_time",
},
},
{
eventKey: personal.EventOAApprovalTaskRedirected,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "task_id", "title", "status", "result", "create_time",
"finish_time", "event_time",
},
},
{
eventKey: personal.EventOAApprovalInstanceStarted,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "title", "status", "create_time", "event_time",
},
},
{
eventKey: personal.EventOAApprovalInstanceTerminated,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "title", "status", "create_time", "finish_time", "event_time",
},
},
{
eventKey: personal.EventOAApprovalInstanceFinished,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "title", "status", "result", "create_time", "finish_time",
"event_time",
},
},
}
for _, tt := range tests {
eventKey := tt.eventKey
if !strings.Contains(listOut.String(), eventKey) {
t.Fatalf("OA event list missing %s:\n%s", eventKey, listOut.String())
}
schema := newEventSchemaCommand()
schema.SilenceUsage = true
schema.SilenceErrors = true
var schemaOut bytes.Buffer
schema.SetOut(&schemaOut)
schema.SetArgs([]string{eventKey, "--flatten"})
if err := schema.Execute(); err != nil {
t.Fatalf("event schema %s --flatten error = %v", eventKey, err)
}
var doc map[string]any
if err := json.Unmarshal(schemaOut.Bytes(), &doc); err != nil {
t.Fatalf("decode schema for %s: %v\n%s", eventKey, err, schemaOut.String())
}
if doc["event_key"] != eventKey || doc["rule_type"] != "all" || doc["jq_root_path"] != "." {
t.Fatalf("schema document for %s = %#v", eventKey, doc)
}
schemaBody, ok := doc["schema"].(map[string]any)
if !ok {
t.Fatalf("schema body for %s = %#v", eventKey, doc["schema"])
}
properties, ok := schemaBody["properties"].(map[string]any)
if !ok || len(properties) != len(tt.properties) {
t.Fatalf("schema properties for %s = %#v, want %d fields", eventKey, schemaBody["properties"], len(tt.properties))
}
for _, name := range tt.properties {
if _, ok := properties[name].(map[string]any); !ok {
t.Fatalf("schema property %s for %s = %#v", name, eventKey, properties[name])
}
}
if _, ok := properties["payload"]; ok {
t.Fatalf("schema for %s exposed generic payload: %#v", eventKey, properties)
}
}
if strings.Contains(listOut.String(), personal.EventMention) {
t.Fatalf("OA category list leaked IM event:\n%s", listOut.String())
}
}
func TestPersonalOAEventConsumeDryRunAndValidation(t *testing.T) {
oldIdentity := personalResolveEventIdentity
oldGet := personalGetSubscription
t.Cleanup(func() {
personalResolveEventIdentity = oldIdentity
personalGetSubscription = oldGet
})
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{
AccessToken: "token",
LocalSubject: "subject",
ClientID: "client",
SourceID: "open",
}, nil
}
personalGetSubscription = func(_ *personal.Client, _ context.Context, subscribeID string) (*personal.Subscription, error) {
switch subscribeID {
case "oa-sub-task":
return &personal.Subscription{
SubscribeID: subscribeID,
EventKey: personal.EventOAApprovalTaskCreated,
RuleType: "all",
}, nil
case "im-sub-at":
return &personal.Subscription{
SubscribeID: subscribeID,
EventKey: personal.EventMention,
RuleType: "at",
}, nil
default:
t.Fatalf("unexpected subscription lookup %q", subscribeID)
return nil, nil
}
}
oaEvents := []string{
personal.EventOAApprovalTaskCreated,
personal.EventOAApprovalTaskFinished,
personal.EventOAApprovalTaskRedirected,
personal.EventOAApprovalInstanceStarted,
personal.EventOAApprovalInstanceTerminated,
personal.EventOAApprovalInstanceFinished,
}
for _, eventKey := range oaEvents {
t.Run(eventKey+"/dry-run", func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var stderr bytes.Buffer
cmd.SetOut(io.Discard)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{eventKey, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("OA dry-run error = %v", err)
}
if !strings.Contains(stderr.String(), "event_types : "+eventKey) {
t.Fatalf("OA dry-run does not select %s:\n%s", eventKey, stderr.String())
}
})
for _, args := range [][]string{
{"--user", "user-1"},
{"--open-dingtalk-id", "open-user-1"},
{"--group", "cid-1"},
{"--query", "urgent"},
{"--filter-json", `{"field":"content","op":"eq","value":"urgent"}`},
} {
name := strings.TrimPrefix(args[0], "--")
t.Run(eventKey+"/reject-"+name, func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(append([]string{eventKey}, append(args, "--dry-run")...))
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "not supported") {
t.Fatalf("OA consume %s error = %v, want unsupported option", args[0], err)
}
})
}
}
t.Run("multi-dry-run", func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var stderr bytes.Buffer
cmd.SetOut(io.Discard)
cmd.SetErr(&stderr)
cmd.SetArgs(append(append([]string(nil), oaEvents...), "--dry-run"))
if err := cmd.Execute(); err != nil {
t.Fatalf("multi OA dry-run error = %v", err)
}
for _, eventKey := range oaEvents {
want := "event_key=" + eventKey + " rule_type=all rule_param={}"
if !strings.Contains(stderr.String(), want) {
t.Fatalf("multi OA dry-run missing %q:\n%s", want, stderr.String())
}
}
})
reuseOverrides := [][]string{
{"--user", "user-1"},
{"--open-dingtalk-id", "open-user-1"},
{"--group", "cid-1"},
{"--query", "urgent"},
{"--filter-json", `{"field":"content","op":"eq","value":"urgent"}`},
}
t.Run("reuse-dry-run/implicit-event-key/resolves-oa-event", func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var stderr bytes.Buffer
cmd.SetOut(io.Discard)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"--subscribe-id", "oa-sub-task", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("implicit reused OA dry-run error = %v", err)
}
if !strings.Contains(stderr.String(), "event_types : "+personal.EventOAApprovalTaskCreated) {
t.Fatalf("implicit reused OA dry-run did not resolve event key:\n%s", stderr.String())
}
})
for _, explicitEventKey := range []bool{true, false} {
mode := "implicit-event-key"
if explicitEventKey {
mode = "explicit-event-key"
}
for _, override := range reuseOverrides {
flag := override[0]
t.Run("reuse-dry-run/"+mode+"/"+strings.TrimPrefix(flag, "--"), func(t *testing.T) {
args := make([]string, 0, 6)
if explicitEventKey {
args = append(args, personal.EventOAApprovalTaskCreated)
}
args = append(args, "--subscribe-id", "oa-sub-task", flag, override[1], "--dry-run")
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), flag+" not supported for OA event") {
t.Fatalf("%s reused OA dry-run %s error = %v", mode, flag, err)
}
})
}
}
t.Run("reuse-dry-run/implicit-im-remains-supported", func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"--subscribe-id", "im-sub-at", "--query", "urgent", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("implicit reused IM dry-run error = %v", err)
}
})
for _, override := range reuseOverrides {
flag, value := override[0], override[1]
t.Run("multi-reject-"+strings.TrimPrefix(flag, "--"), func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
args := append([]string(nil), oaEvents...)
args = append(args, flag, value, "--dry-run")
cmd.SetArgs(args)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "not supported for OA event") {
t.Fatalf("multi OA consume %s error = %v", flag, err)
}
})
}
for _, test := range []struct {
name string
args []string
}{
{
name: "message query remains supported",
args: []string{personal.EventMention, "--query", "urgent", "--dry-run"},
},
{
name: "single group lifecycle filter remains supported",
args: []string{
personal.EventGroupUpdated,
"--group", "cid-1",
"--filter-json", `{"field":"future","op":"eq","value":"value"}`,
"--dry-run",
},
},
} {
t.Run(test.name, func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(test.args)
if err := cmd.Execute(); err != nil {
t.Fatalf("existing IM consume behavior changed: %v", err)
}
})
}
}
func TestCrossPlatformCoveragePersonalOAValidationBranches(t *testing.T) {
invalid := personalConsumeOptions{
EventKey: personal.EventOAApprovalTaskCreated,
UserID: "user-1",
}
if err := validatePersonalSubscriptionOptions(invalid); err == nil ||
!strings.Contains(err.Error(), "--user not supported for OA event") {
t.Fatalf("validatePersonalSubscriptionOptions() error = %v", err)
}
if _, err := preparePersonalSubscription(personal.Identity{}, invalid); err == nil ||
!strings.Contains(err.Error(), "--user not supported for OA event") {
t.Fatalf("preparePersonalSubscription() error = %v", err)
}
oldGet := personalGetSubscription
t.Cleanup(func() { personalGetSubscription = oldGet })
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{
SubscribeID: "oa-sub-without-event-key",
RuleType: "all",
}, nil
}
_, _, _, err := ensurePersonalSubscription(
context.Background(),
nil,
personal.Identity{},
personalConsumeOptions{
SubscribeID: "oa-sub-without-event-key",
EventKey: personal.EventOAApprovalTaskCreated,
UserID: "user-1",
},
)
if err == nil || !strings.Contains(err.Error(), "--user not supported for OA event") {
t.Fatalf("ensurePersonalSubscription() error = %v", err)
}
}
func TestPersonalOAMultiConsumeCreatesIndependentAllSubscriptionsOnSharedBus(t *testing.T) {
restoreMany := installPersonalManySeams(t)
defer restoreMany()
oldCreate := personalCreateSubscription
defer func() { personalCreateSubscription = oldCreate }()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
identity := personal.Identity{
AccessToken: "token",
LocalSubject: "subject",
ClientID: "client",
SourceID: "open",
}
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return identity, nil
}
var requests []personal.CreateSubscriptionRequest
personalCreateSubscription = func(_ *personal.Client, _ context.Context, req personal.CreateSubscriptionRequest) (*personal.Subscription, error) {
requests = append(requests, req)
return &personal.Subscription{SubscribeID: "sub-" + req.EventKey}, nil
}
personalEnsureSubscription = ensurePersonalSubscription
var states []personal.RunState
personalUpsertRunState = func(_ string, state personal.RunState) error {
states = append(states, state)
return nil
}
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return nil }
personalRemoveRunStates = func(string, []string) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
runManyCalls := 0
var gotSpecs []consume.ConsumerSpec
personalConsumeRunMany = func(_ context.Context, _ consume.Config, specs []consume.ConsumerSpec) error {
runManyCalls++
gotSpecs = append([]consume.ConsumerSpec(nil), specs...)
return nil
}
eventKeys := []string{
personal.EventOAApprovalTaskCreated,
personal.EventOAApprovalTaskFinished,
personal.EventOAApprovalTaskRedirected,
personal.EventOAApprovalInstanceStarted,
personal.EventOAApprovalInstanceTerminated,
personal.EventOAApprovalInstanceFinished,
}
if err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
EventKeys: eventKeys,
Flatten: true,
}); err != nil {
t.Fatalf("multi OA consume error = %v", err)
}
if runManyCalls != 1 {
t.Fatalf("RunMany calls = %d, want one shared-bus consume call", runManyCalls)
}
if len(requests) != len(eventKeys) || len(states) != len(eventKeys) || len(gotSpecs) != len(eventKeys) {
t.Fatalf("requests=%d states=%d specs=%d, want %d each", len(requests), len(states), len(gotSpecs), len(eventKeys))
}
for i, eventKey := range eventKeys {
req := requests[i]
if req.EventKey != eventKey || req.RuleType != "all" || req.RuleParam == nil || len(req.RuleParam) != 0 || req.Filter != nil {
t.Fatalf("subscription request[%d] = %#v, want %s all/{}", i, req, eventKey)
}
if states[i].EventKey != eventKey || states[i].RuleType != "all" {
t.Fatalf("run state[%d] = %#v", i, states[i])
}
wantSpec := consume.ConsumerSpec{
EventKey: eventKey,
EventTypes: []string{eventKey},
SubscribeID: "sub-" + eventKey,
ReadySubscribeID: "sub-" + eventKey,
}
if !reflect.DeepEqual(gotSpecs[i], wantSpec) {
t.Fatalf("consumer spec[%d] = %#v, want %#v", i, gotSpecs[i], wantSpec)
}
}
}
func TestPersonalOAReusedSubscriptionRejectsDefinitionOverridesAtRuntime(t *testing.T) {
oldGet := personalGetSubscription
t.Cleanup(func() { personalGetSubscription = oldGet })
getCalls := 0
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
getCalls++
return &personal.Subscription{
SubscribeID: "oa-sub-task",
EventKey: personal.EventOAApprovalTaskCreated,
RuleType: "all",
}, nil
}
tests := []struct {
name string
set func(*personalConsumeOptions)
}{
{name: "user", set: func(opts *personalConsumeOptions) { opts.UserID = "user-1" }},
{name: "open-dingtalk-id", set: func(opts *personalConsumeOptions) { opts.OpenDingTalkID = "open-user-1" }},
{name: "group", set: func(opts *personalConsumeOptions) { opts.GroupID = "cid-1" }},
{name: "query", set: func(opts *personalConsumeOptions) { opts.QueryCSV = "urgent" }},
{name: "filter-json", set: func(opts *personalConsumeOptions) { opts.FilterJSON = `{"field":"content","op":"eq","value":"urgent"}` }},
}
for _, explicitEventKey := range []bool{true, false} {
mode := "implicit-event-key"
if explicitEventKey {
mode = "explicit-event-key"
}
for _, test := range tests {
t.Run(mode+"/"+test.name, func(t *testing.T) {
opts := personalConsumeOptions{SubscribeID: "oa-sub-task"}
if explicitEventKey {
opts.EventKey = personal.EventOAApprovalTaskCreated
}
test.set(&opts)
before := getCalls
_, _, _, err := ensurePersonalSubscription(
context.Background(),
nil,
personal.Identity{},
opts,
)
if err == nil || !strings.Contains(err.Error(), "--"+test.name+" not supported for OA event") {
t.Fatalf("reused OA subscription %s error = %v", test.name, err)
}
if getCalls != before+1 {
t.Fatalf("subscription lookup calls = %d, want %d", getCalls, before+1)
}
})
}
}
}
func TestPersonalOAImplicitReuseRuntimeLooksUpEventBeforeValidation(t *testing.T) {
oldIdentity := personalResolveEventIdentity
oldGet := personalGetSubscription
t.Cleanup(func() {
personalResolveEventIdentity = oldIdentity
personalGetSubscription = oldGet
})
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{
AccessToken: "token",
LocalSubject: "subject",
ClientID: "client",
SourceID: "open",
}, nil
}
getCalls := 0
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
getCalls++
return &personal.Subscription{
SubscribeID: "oa-sub-task",
EventKey: personal.EventOAApprovalTaskCreated,
RuleType: "all",
}, nil
}
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"--subscribe-id", "oa-sub-task", "--group", "cid-1"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "--group not supported for OA event "+personal.EventOAApprovalTaskCreated) {
t.Fatalf("implicit reused OA runtime error = %v", err)
}
if getCalls != 1 {
t.Fatalf("subscription lookup calls = %d, want 1", getCalls)
}
}
func TestPersonalIMReusedSubscriptionWithExistingOverridesRemainsSupported(t *testing.T) {
oldGet := personalGetSubscription
t.Cleanup(func() { personalGetSubscription = oldGet })
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{
SubscribeID: "im-sub",
EventKey: personal.EventSingleChat,
RuleType: "singleChat",
}, nil
}
sub, eventKey, ruleType, err := ensurePersonalSubscription(
context.Background(),
nil,
personal.Identity{},
personalConsumeOptions{
SubscribeID: "im-sub",
EventKey: personal.EventSingleChat,
UserID: "user-1",
QueryCSV: "urgent",
FilterJSON: `{"field":"content","op":"eq","value":"urgent"}`,
},
)
if err != nil {
t.Fatalf("reused IM subscription error = %v", err)
}
if sub.SubscribeID != "im-sub" || eventKey != personal.EventSingleChat || ruleType != "singleChat" {
t.Fatalf("reused IM subscription = %#v, event=%q rule=%q", sub, eventKey, ruleType)
}
}
func TestPersonalOAStatusAndStopCommandWiring(t *testing.T) {
oldStatus := eventRunPersonalStatus
oldStop := eventRunPersonalStop
t.Cleanup(func() {
eventRunPersonalStatus = oldStatus
eventRunPersonalStop = oldStop
})
var statusOpts personalStatusOptions
eventRunPersonalStatus = func(_ *cobra.Command, opts personalStatusOptions) error {
statusOpts = opts
return nil
}
status := newEventStatusCommand()
status.SilenceUsage = true
status.SilenceErrors = true
status.SetOut(io.Discard)
status.SetErr(io.Discard)
status.SetArgs([]string{
"--event", personal.EventOAApprovalTaskCreated,
"--subscribe-id", "oa-sub-task",
"--status", "all",
})
if err := status.Execute(); err != nil {
t.Fatalf("OA event status error = %v", err)
}
if statusOpts.EventKey != personal.EventOAApprovalTaskCreated ||
statusOpts.SubscribeID != "oa-sub-task" ||
statusOpts.Status != "all" {
t.Fatalf("OA status options = %#v", statusOpts)
}
var stopOpts personalStopOptions
eventRunPersonalStop = func(_ *cobra.Command, opts personalStopOptions) error {
stopOpts = opts
return nil
}
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.PersistentFlags().Bool("yes", false, "")
event := &cobra.Command{Use: "event"}
event.AddCommand(newEventStopCommand())
root.AddCommand(event)
root.SetArgs([]string{"event", "stop", "oa-sub-task", "--yes"})
if err := root.Execute(); err != nil {
t.Fatalf("OA event stop error = %v", err)
}
if stopOpts.SubscribeID != "oa-sub-task" || stopOpts.All {
t.Fatalf("OA stop options = %#v", stopOpts)
}
}
@@ -0,0 +1,427 @@
// 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 app
import (
"context"
"errors"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
)
func TestPersonalConsumeCleanupOwnershipRuntimeMatrix(t *testing.T) {
runErr := errors.New("runtime failed")
for _, foreground := range []bool{false, true} {
for _, selfCreated := range []bool{false, true} {
for _, ephemeral := range []bool{false, true} {
for _, failRuntime := range []bool{false, true} {
name := strings.Join([]string{
map[bool]string{false: "background", true: "foreground"}[foreground],
map[bool]string{false: "reused", true: "self-created"}[selfCreated],
map[bool]string{false: "persistent", true: "ephemeral"}[ephemeral],
map[bool]string{false: "success", true: "error"}[failRuntime],
}, "/")
t.Run(name, func(t *testing.T) {
restore := installPersonalManySeams(t)
t.Cleanup(restore)
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldNewSource := personalNewStreamSource
oldBusRun := personalBusRun
oldConsumeRun := personalConsumeRun
t.Cleanup(func() {
personalNewStreamSource = oldNewSource
personalBusRun = oldBusRun
personalConsumeRun = oldConsumeRun
})
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{
AccessToken: "token",
ClientID: "client",
SourceID: "open",
LocalSubject: "subject",
}, nil
}
personalEnsureSubscription = func(
context.Context,
*personal.Client,
personal.Identity,
personalConsumeOptions,
) (*personal.Subscription, string, string, error) {
return &personal.Subscription{SubscribeID: "sub-one"}, personal.EventMention, "at", nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
personalUpsertRunState = func(string, personal.RunState) error { return nil }
deleteCalls := 0
removeCalls := 0
personalDeleteSubscription = func(*personal.Client, context.Context, string) error {
deleteCalls++
return nil
}
personalRemoveRunStates = func(string, []string) error {
removeCalls++
return nil
}
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
return nil, nil
}
personalBusRun = func(context.Context, bus.Config) error {
if failRuntime {
return runErr
}
return nil
}
personalConsumeRun = func(context.Context, consume.Config) error {
if failRuntime {
return runErr
}
return nil
}
opts := personalConsumeOptions{
EventKey: personal.EventMention,
Ephemeral: ephemeral,
ControlBaseURL: "https://mcp.example.test/dws",
Common: commonConsumeOptions{
Foreground: foreground,
},
}
if !selfCreated {
opts.SubscribeID = "sub-one"
}
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), opts)
if failRuntime {
if !errors.Is(err, runErr) {
t.Fatalf("runtime error = %v, want %v", err, runErr)
}
} else if err != nil {
t.Fatalf("consume error = %v", err)
}
wantCleanup := 0
if selfCreated || ephemeral {
wantCleanup = 1
}
if deleteCalls != wantCleanup || removeCalls != wantCleanup {
t.Fatalf(
"cleanup delete/remove = %d/%d, want %d/%d",
deleteCalls,
removeCalls,
wantCleanup,
wantCleanup,
)
}
})
}
}
}
}
}
func TestPersonalConsumeCleanupOwnershipOnRunStateFailure(t *testing.T) {
stateErr := errors.New("save state failed")
for _, test := range []struct {
name string
selfCreated bool
ephemeral bool
wantCleanup int
}{
{name: "self-created", selfCreated: true, wantCleanup: 1},
{name: "reused persistent", wantCleanup: 0},
{name: "reused ephemeral", ephemeral: true, wantCleanup: 1},
} {
t.Run(test.name, func(t *testing.T) {
restore := installPersonalManySeams(t)
t.Cleanup(restore)
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}, nil
}
personalEnsureSubscription = func(
context.Context,
*personal.Client,
personal.Identity,
personalConsumeOptions,
) (*personal.Subscription, string, string, error) {
return &personal.Subscription{SubscribeID: "sub-one"}, personal.EventMention, "at", nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalUpsertRunState = func(string, personal.RunState) error { return stateErr }
deleteCalls := 0
removeCalls := 0
personalDeleteSubscription = func(*personal.Client, context.Context, string) error {
deleteCalls++
return nil
}
personalRemoveRunStates = func(string, []string) error {
removeCalls++
return nil
}
opts := personalConsumeOptions{
EventKey: personal.EventMention,
Ephemeral: test.ephemeral,
ControlBaseURL: "https://mcp.example.test/dws",
}
if !test.selfCreated {
opts.SubscribeID = "sub-one"
}
if err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), opts); !errors.Is(err, stateErr) {
t.Fatalf("state error = %v, want %v", err, stateErr)
}
if deleteCalls != test.wantCleanup || removeCalls != test.wantCleanup {
t.Fatalf(
"cleanup delete/remove = %d/%d, want %d/%d",
deleteCalls,
removeCalls,
test.wantCleanup,
test.wantCleanup,
)
}
})
}
}
func TestPersonalReusedSubscriptionEventKeyResolution(t *testing.T) {
oldGet := personalGetSubscription
t.Cleanup(func() { personalGetSubscription = oldGet })
for _, test := range []struct {
name string
requested string
actual string
wantKey string
wantErr bool
}{
{
name: "matching key uses actual",
requested: personal.EventMention,
actual: personal.EventMention,
wantKey: personal.EventMention,
},
{
name: "implicit key uses actual",
actual: personal.EventOAApprovalTaskCreated,
wantKey: personal.EventOAApprovalTaskCreated,
},
{
name: "missing actual falls back to requested",
requested: personal.EventOAApprovalTaskCreated,
wantKey: personal.EventOAApprovalTaskCreated,
},
{
name: "requested IM mismatches actual OA",
requested: personal.EventMention,
actual: personal.EventOAApprovalTaskCreated,
wantErr: true,
},
{
name: "requested OA mismatches actual IM",
requested: personal.EventOAApprovalTaskCreated,
actual: personal.EventMention,
wantErr: true,
},
} {
t.Run(test.name, func(t *testing.T) {
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{
SubscribeID: "sub-one",
EventKey: test.actual,
}, nil
}
_, eventKey, _, err := ensurePersonalSubscription(
context.Background(),
nil,
personal.Identity{},
personalConsumeOptions{SubscribeID: "sub-one", EventKey: test.requested},
)
if test.wantErr {
if err == nil || !strings.Contains(err.Error(), "does not match reused subscription") {
t.Fatalf("mismatch error = %v", err)
}
if !strings.Contains(err.Error(), test.requested) || !strings.Contains(err.Error(), test.actual) {
t.Fatalf("mismatch error does not identify both keys: %v", err)
}
return
}
if err != nil {
t.Fatalf("resolve reused subscription: %v", err)
}
if eventKey != test.wantKey {
t.Fatalf("resolved event key = %q, want %q", eventKey, test.wantKey)
}
})
}
}
func TestPersonalReusedSubscriptionMismatchStopsDryRunAndRuntime(t *testing.T) {
for _, mode := range []struct {
name string
dryRun bool
foreground bool
}{
{name: "dry-run", dryRun: true},
{name: "background"},
{name: "foreground", foreground: true},
} {
t.Run(mode.name, func(t *testing.T) {
restore := installPersonalManySeams(t)
t.Cleanup(restore)
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldGet := personalGetSubscription
oldNewSource := personalNewStreamSource
oldBusRun := personalBusRun
oldConsumeRun := personalConsumeRun
t.Cleanup(func() {
personalGetSubscription = oldGet
personalNewStreamSource = oldNewSource
personalBusRun = oldBusRun
personalConsumeRun = oldConsumeRun
})
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}, nil
}
personalEnsureSubscription = ensurePersonalSubscription
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{
SubscribeID: "sub-one",
EventKey: personal.EventOAApprovalTaskCreated,
RuleType: "all",
}, nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
upsertCalls := 0
consumeCalls := 0
busCalls := 0
personalUpsertRunState = func(string, personal.RunState) error {
upsertCalls++
return nil
}
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
return nil, nil
}
personalBusRun = func(context.Context, bus.Config) error {
busCalls++
return nil
}
personalConsumeRun = func(context.Context, consume.Config) error {
consumeCalls++
return nil
}
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
EventKey: personal.EventMention,
SubscribeID: "sub-one",
ControlBaseURL: "https://mcp.example.test/dws",
Common: commonConsumeOptions{
DryRun: mode.dryRun,
Foreground: mode.foreground,
},
})
if err == nil || !strings.Contains(err.Error(), "does not match reused subscription") {
t.Fatalf("mismatch error = %v", err)
}
if upsertCalls != 0 || consumeCalls != 0 || busCalls != 0 {
t.Fatalf(
"mismatch reached upsert/consumer/bus = %d/%d/%d",
upsertCalls,
consumeCalls,
busCalls,
)
}
})
}
}
func TestPersonalReusedSubscriptionUsesActualKeyInDryRunAndRuntime(t *testing.T) {
for _, dryRun := range []bool{true, false} {
name := map[bool]string{false: "runtime", true: "dry-run"}[dryRun]
t.Run(name, func(t *testing.T) {
restore := installPersonalManySeams(t)
t.Cleanup(restore)
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldGet := personalGetSubscription
oldConsumeRun := personalConsumeRun
t.Cleanup(func() {
personalGetSubscription = oldGet
personalConsumeRun = oldConsumeRun
})
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}, nil
}
personalEnsureSubscription = ensurePersonalSubscription
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{
SubscribeID: "sub-oa",
EventKey: personal.EventOAApprovalInstanceFinished,
RuleType: "all",
}, nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
personalUpsertRunState = func(_ string, state personal.RunState) error {
if state.EventKey != personal.EventOAApprovalInstanceFinished {
t.Fatalf("run state event key = %q", state.EventKey)
}
return nil
}
var got consume.Config
personalConsumeRun = func(_ context.Context, cfg consume.Config) error {
got = cfg
return nil
}
if err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
SubscribeID: "sub-oa",
ControlBaseURL: "https://mcp.example.test/dws",
Common: commonConsumeOptions{
DryRun: dryRun,
},
}); err != nil {
t.Fatalf("reuse subscription: %v", err)
}
if got.EventKey != personal.EventOAApprovalInstanceFinished ||
len(got.EventTypes) != 1 || got.EventTypes[0] != personal.EventOAApprovalInstanceFinished ||
got.SubscribeID != "sub-oa" {
t.Fatalf("consume config = %#v", got)
}
})
}
}
func TestEventConsumeDryRunHelpDescribesReuseLookup(t *testing.T) {
usage := newEventConsumeCommand().Flags().Lookup("dry-run").Usage
for _, want := range []string{"不创建订阅", "不连接 bus", "复用 --subscribe-id", "只读查询控制面"} {
if !strings.Contains(usage, want) {
t.Fatalf("dry-run help %q missing %q", usage, want)
}
}
}
@@ -0,0 +1,74 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func TestCrossPlatformCoverageEventAgentSelectionBoundaries(t *testing.T) {
_ = NewRootCommand()
eventProduct, ok := contract.LookupProductDecl("event")
if !ok {
t.Fatal("event ProductDecl is not registered")
}
assertSelectionContains(t, "event product", eventProduct.Selection.AgentSummary,
[]string{"IM", "OA"})
assertSelectionContains(t, "event product use_when", strings.Join(eventProduct.Selection.UseWhen, "\n"),
[]string{"消息", "群生命周期", "OA"})
assertSelectionContains(t, "event product avoid_when", strings.Join(eventProduct.Selection.AvoidWhen, "\n"),
[]string{"chat", "oa", "dev app event"})
listenMeta, ok := cli.ResolveMeta("event +listen-im")
if !ok {
t.Fatal("event +listen-im metadata is not registered")
}
assertSelectionContains(t, "event.listen_im use_when", strings.Join(listenMeta.Selection.UseWhen, "\n"),
[]string{"@我", "message/reaction/read/recall"})
assertSelectionContains(t, "event.listen_im avoid_when", strings.Join(listenMeta.Selection.AvoidWhen, "\n"),
[]string{"OA 审批事件", "群标题", "Filter DSL", "event consume", "历史消息"})
consumeMeta, ok := cli.ResolveMeta("event consume")
if !ok {
t.Fatal("event consume metadata is not registered")
}
consumeUse := strings.Join(consumeMeta.Selection.UseWhen, "\n")
assertSelectionContains(t, "event.consume use_when", consumeUse,
[]string{"OA", "群", "EventKey", "Filter DSL", "subscribe_id", "transport envelope", "高级多事件"})
consumeAvoid := strings.Join(consumeMeta.Selection.AvoidWhen, "\n")
assertSelectionContains(t, "event.consume avoid_when", consumeAvoid,
[]string{"event +listen-im", "历史聊天", "oa", "dev app event"})
schemaMeta, ok := cli.ResolveMeta("event schema")
if !ok {
t.Fatal("event schema metadata is not registered")
}
assertSelectionContains(t, "event.schema use_when", strings.Join(schemaMeta.Selection.UseWhen, "\n"),
[]string{"IM", "OA", "--flatten"})
for productID, want := range map[string]string{
"chat": "event +listen-im",
"oa": "event consume",
} {
decl, found := contract.LookupProductDecl(productID)
if !found {
t.Fatalf("%s ProductDecl is not registered", productID)
}
assertSelectionContains(t, productID+" avoid_when", strings.Join(decl.Selection.AvoidWhen, "\n"), []string{want})
}
}
func assertSelectionContains(t *testing.T, label, text string, fragments []string) {
t.Helper()
for _, fragment := range fragments {
if !strings.Contains(text, fragment) {
t.Errorf("%s = %q, want fragment %q", label, text, fragment)
}
}
}
+15 -2
View File
@@ -39,7 +39,7 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
copyArgs[key] = value
}
c.calls = append(c.calls, paramAliasToolCall{server: server, tool: tool, args: copyArgs})
text := paramAliasResponseForTool(tool)
text := c.paramAliasResponseForTool(tool)
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
@@ -48,7 +48,7 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
// print the transport result and need an empty object; smart shortcuts that
// inspect a read response receive the smallest shape that lets their full RunE
// complete without falling back to a validation error.
func paramAliasResponseForTool(tool string) string {
func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string {
switch tool {
case "list_calendar_events":
return `{"result":{"events":[]}}`
@@ -62,9 +62,22 @@ func paramAliasResponseForTool(tool string) string {
return `{"result":[{"name":"Fixture User","userId":"fixture-user","openDingTalkId":"D-fixture-user"}]}`
case "list_doc_versions":
return `{"result":{"items":[{"version":3}]}}`
case "revert_doc_version":
return `{"version":3}`
case "search_doc_templates":
return `{"result":[{"templateId":"fixture-template-id"}]}`
case "create_document":
return `{"nodeId":"fixture-node"}`
case "get_document_content":
for index := len(c.calls) - 2; index >= 0; index-- {
call := c.calls[index]
for _, key := range []string{"jsonml", "markdown"} {
if content, ok := call.args[key].(string); ok {
encoded, _ := json.Marshal(map[string]any{"revision": 1, key: content})
return string(encoded)
}
}
}
return `{"revision":1}`
default:
return `{}`
@@ -123,7 +123,7 @@ var paramAliasCompleteCommands = map[string][]string{
"doc +version-list": {"doc", "+version-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
"doc +version-revert": {"doc", "+version-revert", "--node", "node-1", "--version", "3", "--yes"},
"doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"},
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "append", "--content", "fixture body", "--expected-revision", "1", "--yes"},
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"},
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
"doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"},
@@ -296,7 +296,14 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles {
t.Fatalf("write dry-run fixture %s: %v", path, err)
}
}
return agentExampleFiles{root: root, markdown: markdown, json: jsonFile, batch: batch, binary: binary, image: image}
return agentExampleFiles{
root: root,
markdown: "./" + filepath.Base(markdown),
json: "./" + filepath.Base(jsonFile),
batch: "./" + filepath.Base(batch),
binary: "./" + filepath.Base(binary),
image: "./" + filepath.Base(image),
}
}
func materializeAgentExampleArgv(argv []string, files agentExampleFiles) []string {
@@ -26,6 +26,14 @@ func TestReviewedMutationSafetyReachesFinalSchema(t *testing.T) {
wants := []finalSchemaSafetyWant{
{canonical: "aitable.form_field_hide", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "idempotent", provenance: declared},
{canonical: "chat.dismiss_group", effect: "destructive", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
// Card update intentionally layers confirmation: the atomic typed command
// preserves its original contract, while the Agent-facing shortcut owns
// the outer confirmation boundary.
{canonical: "chat.update_streaming_card", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
{canonical: "chat.shortcut_messages_send", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "chat.shortcut_messages_send_by_webhook", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "chat.shortcut_messages_send_card", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "chat.shortcut_messages_update_card", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "drive.recycle_restore", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
{canonical: "minutes.create_speaker_summary", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
{canonical: "sheet.clear_range", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
@@ -129,6 +129,75 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
}
}
func TestDeliveryDocUpdateShortcutPublishesCompleteConditionalContract(t *testing.T) {
leaf := executeShortcutSchemaQuery(t, "--cli-path", "doc +update")
if got, want := schemaContractString(leaf["confirmation"]), "user_required"; got != want {
t.Fatalf("confirmation = %q, want %q", got, want)
}
parameters := schemaContractMap(leaf["parameters"])
if got, want := len(parameters), 11; got != want {
t.Fatalf("parameter count = %d, want %d: %#v", got, want, parameters)
}
if required, _ := parameters["node"]["required"].(bool); !required {
t.Errorf("--node required = %#v, want true", parameters["node"]["required"])
}
if required, _ := parameters["command"]["required"].(bool); required {
t.Errorf("--command required = true, want runtime custom validation")
}
wantProperties := map[string]string{
"node": "node", "doc": "node", "command": "command", "content": "content", "text": "content", "doc-format": "docFormat",
"block-id": "blockId", "after-block-id": "afterBlockId", "old": "old", "new": "new",
"expected-revision": "expectedRevision",
}
for name, want := range wantProperties {
if got := schemaContractString(parameters[name]["property"]); got != want {
t.Errorf("--%s property = %q, want %q", name, got, want)
}
}
for _, name := range []string{"content", "block-id", "after-block-id", "old", "new"} {
parameter := parameters[name]
if required, _ := parameter["required"].(bool); required {
t.Errorf("--%s required = true, want runtime custom validation", name)
}
if got := schemaContractString(parameter["required_when"]); got != "" {
t.Errorf("--%s required_when = %q, want compatibility-safe custom validation", name, got)
}
}
if constraints, exists := leaf["constraints"]; exists && constraints != nil {
t.Fatalf("enum-discriminated requirements must not be mispublished as relationship constraints: %#v", constraints)
}
}
func TestDeliveryDocCommentExportImportContractsAreCanonical(t *testing.T) {
comment := executeShortcutSchemaQuery(t, "--cli-path", "doc +comment-create")
commentParameters := schemaContractMap(comment["parameters"])
for _, name := range []string{"node", "content", "selection", "block-id", "start", "end", "selected-text", "mention"} {
if _, ok := commentParameters[name]; !ok {
t.Errorf("comment-create missing --%s: %#v", name, commentParameters)
}
}
for name, want := range map[string]string{"node": "node", "mention": "mention"} {
if got := schemaContractString(commentParameters[name]["property"]); got != want {
t.Errorf("comment-create --%s property = %q, want %q", name, got, want)
}
}
export := executeShortcutSchemaQuery(t, "--cli-path", "doc +export")
exportFormat := schemaContractMap(export["parameters"])["export-format"]
if required, _ := exportFormat["required"].(bool); required {
t.Fatalf("export --export-format required = true, want compatibility default")
}
if defaultValue := schemaContractString(exportFormat["default"]); defaultValue != "docx" {
t.Fatalf("export --export-format default = %q, want docx", defaultValue)
}
importLeaf := executeShortcutSchemaQuery(t, "--cli-path", "doc +import")
constraints, _ := importLeaf["constraints"].(map[string]any)
if requireOneOf, ok := constraints["require_one_of"]; ok && !schemaContractJSONEqual(requireOneOf, [][]string{}) {
t.Fatalf("import unexpectedly requires a target: %#v", constraints)
}
}
func executeShortcutSchemaQuery(t testing.TB, args ...string) map[string]any {
t.Helper()
root := NewRootCommand()
@@ -81,6 +81,7 @@ func newServerFailureAPIError(
apperrors.WithReason(fallbackReason),
apperrors.WithServerKey(serverKey),
apperrors.WithHint(fallbackHint),
apperrors.WithActions("运行 dws doctor 检查登录态、网络和本地环境;持续失败时保留 Trace ID 和 Server Code"),
apperrors.WithServerDiag(diag),
}
if classified, ok := classifyServerFailure(message, diag); ok {
@@ -95,6 +95,9 @@ func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing
if typed.Reason != "business_error" || typed.Origin != "" || typed.FailureStage != "" || typed.ExecutionStarted != nil {
t.Fatalf("unexpected fallback classification: %#v", typed)
}
if len(typed.Actions) == 0 || !strings.Contains(typed.Actions[0], "dws doctor") {
t.Fatalf("fallback error has no stable troubleshooting entry: %#v", typed.Actions)
}
}
func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *testing.T) {
+577 -1
View File
@@ -51,6 +51,8 @@ var (
skillSetupInstallMono = installSkillToHomes
skillSetupInstallMulti = installMultiSkillToHomes
skillSetupCopyDir = copyDir
skillSetupMkdirTemp = os.MkdirTemp
skillSetupRename = os.Rename
skillSetupRunForm = (*huh.Form).Run
skillSetupInteractive = isInteractiveTerminal
skillSetupReadDir = os.ReadDir
@@ -136,6 +138,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
// multi 模式枚举 src 下的子 skill 名,供确认信息与安装步骤共用
var multiSkillNames []string
var foldedEventMiscTargets []string
var migrateEventMiscTargets []string
var installsEventMiscCompanion bool
if mode == skillSetupModeMulti {
allMultiSkillNames, listErr := skillSetupListMulti(skillSrc)
if listErr != nil {
@@ -151,6 +156,30 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
// dingtalk-shared carries the global rules every product skill declares as a
// PREREQUISITE; it must ship even when --skill / --exclude narrows the set.
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
foldedEventMiscTargets = findFoldedEventMiscTargets(dests)
if len(foldedEventMiscTargets) > 0 {
hasEvent := containsSkillName(multiSkillNames, multiEventSkill)
hasMisc := containsSkillName(multiSkillNames, multiMiscSkill)
switch {
case normalizedSkillListContains(excludeRaw, multiEventSkill):
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;不能显式 --exclude event,请先完成 dingtalk-event 迁移")
case hasMisc && !hasEvent:
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;不能只覆盖 dingtalk-misc,必须同时迁移 dingtalk-event")
case hasEvent:
if normalizedSkillListContains(excludeRaw, multiMiscSkill) {
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;本次安装 dingtalk-event 必须同时迁移 dingtalk-misc,不能显式 --exclude misc")
}
if !containsSkillName(allMultiSkillNames, multiMiscSkill) {
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由,但当前 multi 源缺少迁移所需的 %s", multiMiscSkill)
}
if err := validateEventMiscMigrationSource(skillSrc); err != nil {
return err
}
migrateEventMiscTargets = append(migrateEventMiscTargets, foldedEventMiscTargets...)
installsEventMiscCompanion = !hasMisc
}
}
}
// --dry-run:仅预览将安装的内容与目标目录,不写入任何文件、不弹确认。
@@ -162,11 +191,15 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
}
if mode == skillSetupModeMulti && len(multiSkillNames) > 0 {
fmt.Fprintf(out, "子 skill:%s\n", strings.Join(multiSkillNames, ", "))
printEventMiscMigrationPreview(out, migrateEventMiscTargets, installsEventMiscCompanion)
}
return nil
}
if !autoYes {
if mode == skillSetupModeMulti {
printEventMiscMigrationPreview(out, migrateEventMiscTargets, installsEventMiscCompanion)
}
ok, err := skillSetupConfirm(out, mode, skillSrc, dests, multiSkillNames)
if err != nil {
return err
@@ -182,7 +215,14 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
case skillSetupModeMono:
installed, skipped, err = skillSetupInstallMono(skillSrc, dests, out, errOut)
case skillSetupModeMulti:
installed, skipped, err = skillSetupInstallMulti(skillSrc, multiSkillNames, dests, out, errOut)
installed, skipped, err = installMultiSkillsWithEventMigration(
skillSrc,
multiSkillNames,
dests,
migrateEventMiscTargets,
out,
errOut,
)
default:
return fmt.Errorf("内部错误:未知 mode %q", mode)
}
@@ -191,6 +231,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
}
fmt.Fprintf(out, "\n✅ Skill 安装完成(mode=%s, installed=%d, skipped=%d)\n", mode, installed, skipped)
fmt.Fprintln(out, "ℹ️ 若 Agent 会话已打开,请重启 Agent 或重新加载 Skills 后再验证路由。")
return nil
}
@@ -204,6 +245,226 @@ const multiSkillPrefix = "dingtalk-"
// dingtalk-shared that was never installed.
const multiSharedSkill = "dingtalk-shared"
// legacyMultiSharedSkill is the retired name shipped by older multi-skill
// bundles. Once the replacement has been installed successfully, remove this
// exact directory so Agent discovery cannot load both routing contracts.
const legacyMultiSharedSkill = "dws-shared"
const (
multiEventSkill = "dingtalk-event"
multiMiscSkill = "dingtalk-misc"
)
var eventMigrationRequiredReferences = []string{
"event-im.md",
"event-im-keys.md",
"event-im-lifecycle.md",
"event-im-operations.md",
"event-im-output.md",
"event-oa.md",
}
func containsSkillName(names []string, want string) bool {
for _, name := range names {
if name == want {
return true
}
}
return false
}
func normalizedSkillListContains(raw []string, want string) bool {
for _, name := range raw {
if normalizeMultiSkillName(name) == want {
return true
}
}
return false
}
// findFoldedEventMiscTargets identifies the short-lived multi-skill layout in
// which personal Event routing lived inside dingtalk-misc. Both markers are
// required so an unrelated misc install is never treated as a migration target.
func findFoldedEventMiscTargets(dests []string) []string {
var targets []string
for _, dest := range dests {
miscRoot := filepath.Join(dest, multiMiscSkill)
skillBody, err := os.ReadFile(filepath.Join(miscRoot, "SKILL.md"))
if err != nil || !containsPersonalEventRoute(skillBody) {
continue
}
eventRef, err := skillSetupStat(filepath.Join(miscRoot, "references", "event.md"))
if err != nil || eventRef.IsDir() {
continue
}
targets = append(targets, dest)
}
sort.Strings(targets)
return targets
}
func containsPersonalEventRoute(skillBody []byte) bool {
body := strings.ToLower(string(skillBody))
for _, marker := range []string{
"dws event",
"个人 event",
"个人 im 事件",
"个人 im/oa",
"personal event",
} {
if strings.Contains(body, marker) {
return true
}
}
return false
}
func printEventMiscMigrationPreview(out io.Writer, targets []string, installsCompanion bool) {
if len(targets) == 0 {
return
}
action := "将原子切换 dingtalk-event 与本次已选择的干净 dingtalk-misc"
if installsCompanion {
action = "将原子切换 dingtalk-event,并额外安装干净的 dingtalk-misc 作为迁移伴侣(仅限以下目标)"
}
fmt.Fprintf(out, "Event Skill 迁移:%s:\n", action)
for _, target := range targets {
fmt.Fprintf(out, " - %s\n", target)
}
}
func validateEventMiscMigrationSource(src string) error {
if err := validateEventMigrationSkillRoot(filepath.Join(src, multiEventSkill)); err != nil {
return fmt.Errorf("event Skill 迁移源无效: %w", err)
}
if err := validateMigrationSkillRoot(filepath.Join(src, multiMiscSkill), multiMiscSkill, nil); err != nil {
return fmt.Errorf("event Skill 迁移源无效: %w", err)
}
if err := validateCleanEventMiscRoot(filepath.Join(src, multiMiscSkill)); err != nil {
return fmt.Errorf("event Skill 迁移源无效: %w", err)
}
return nil
}
func validateEventMigrationSkillRoot(root string) error {
required := make([]string, 0, len(eventMigrationRequiredReferences))
for _, name := range eventMigrationRequiredReferences {
required = append(required, filepath.Join("references", name))
}
return validateMigrationSkillRoot(root, multiEventSkill, required)
}
func validateMigrationSkillRoot(root, expectedName string, requiredFiles []string) error {
skillPath := filepath.Join(root, "SKILL.md")
skillBody, err := os.ReadFile(skillPath)
if err != nil {
return fmt.Errorf("无法读取 %s: %w", skillPath, err)
}
name, err := parseMigrationSkillFrontmatter(skillBody)
if err != nil {
return fmt.Errorf("%s 无效: %w", skillPath, err)
}
if name != expectedName {
return fmt.Errorf("%s 的 name=%q,期望 %q", skillPath, name, expectedName)
}
for _, rel := range requiredFiles {
path := filepath.Join(root, rel)
info, statErr := skillSetupStat(path)
if statErr != nil || info.IsDir() {
if statErr == nil {
statErr = errors.New("is a directory")
}
return fmt.Errorf("缺少有效文件 %s: %w", path, statErr)
}
body, readErr := os.ReadFile(path)
if readErr != nil {
return fmt.Errorf("无法读取 %s: %w", path, readErr)
}
if strings.TrimSpace(string(body)) == "" {
return fmt.Errorf("文件为空 %s", path)
}
}
return nil
}
func parseMigrationSkillFrontmatter(body []byte) (string, error) {
normalized := strings.ReplaceAll(string(body), "\r\n", "\n")
lines := strings.Split(normalized, "\n")
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
return "", errors.New("缺少 YAML frontmatter")
}
name := ""
description := ""
closingLine := -1
for i := 1; i < len(lines); i++ {
rawLine := lines[i]
line := strings.TrimSpace(rawLine)
if line == "---" {
closingLine = i
break
}
// Only inspect top-level frontmatter keys. Nested metadata may legally
// contain its own `name` without changing the Skill identity.
if strings.TrimLeft(rawLine, " \t") != rawLine {
continue
}
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
value = strings.Trim(strings.TrimSpace(value), "\"'")
switch strings.TrimSpace(key) {
case "name":
if name != "" {
return "", errors.New("frontmatter 含重复 name")
}
name = value
case "description":
description = value
}
}
if closingLine < 0 {
return "", errors.New("YAML frontmatter 未闭合")
}
if name == "" {
return "", errors.New("frontmatter 缺少 name")
}
if description == "" {
return "", errors.New("frontmatter 缺少 description")
}
if strings.TrimSpace(strings.Join(lines[closingLine+1:], "\n")) == "" {
return "", errors.New("SKILL.md 正文为空")
}
return name, nil
}
func validateCleanEventMiscRoot(miscRoot string) error {
miscSkillPath := filepath.Join(miscRoot, "SKILL.md")
miscBody, err := os.ReadFile(miscSkillPath)
if err != nil {
return fmt.Errorf("无法读取 %s: %w", miscSkillPath, err)
}
if containsPersonalEventRoute(miscBody) {
return fmt.Errorf("%s 仍包含个人 Event 路由", miscSkillPath)
}
refsRoot := filepath.Join(miscRoot, "references")
entries, err := skillSetupReadDir(refsRoot)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("无法检查 %s: %w", refsRoot, err)
}
for _, entry := range entries {
name := strings.ToLower(entry.Name())
if !entry.IsDir() && strings.HasPrefix(name, "event") && strings.HasSuffix(name, ".md") {
return fmt.Errorf("%s 仍存在折叠 Event 参考页", filepath.Join(refsRoot, entry.Name()))
}
}
return nil
}
// ensureMandatorySharedSkill guarantees the shared dependency skill is included
// whenever it exists in the source, even if --skill / --exclude narrowed it out.
func ensureMandatorySharedSkill(selected, all []string) []string {
@@ -607,6 +868,21 @@ func cleanupMutualExclusion(dest, mode string, out, errOut io.Writer) {
}
}
func cleanupLegacyMultiSharedSkill(dest string, out, errOut io.Writer) {
legacyPath := filepath.Join(dest, legacyMultiSharedSkill)
if _, err := skillSetupStat(legacyPath); err != nil {
if !errors.Is(err, os.ErrNotExist) {
fmt.Fprintf(errOut, " ⚠️ 无法检查已退役 Skill 残留 %s: %v\n", legacyPath, err)
}
return
}
if err := skillSetupRemoveAll(legacyPath); err != nil {
fmt.Fprintf(errOut, " ⚠️ 已退役 Skill 清理失败(已安装 %s) %s: %v\n", multiSharedSkill, legacyPath, err)
return
}
fmt.Fprintf(out, " × 已清理已退役 Skill 残留 %s\n", legacyPath)
}
func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (installed, skipped int, err error) {
sort.Strings(dests)
for _, dest := range dests {
@@ -634,6 +910,299 @@ func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (ins
return installed, skipped, nil
}
func installMultiSkillsWithEventMigration(
src string,
skillNames []string,
dests []string,
migrationTargets []string,
out, errOut io.Writer,
) (installed, skipped int, err error) {
if len(migrationTargets) == 0 {
return skillSetupInstallMulti(src, skillNames, dests, out, errOut)
}
migrationSet := make(map[string]struct{}, len(migrationTargets))
for _, dest := range migrationTargets {
migrationSet[dest] = struct{}{}
}
var ordinaryTargets []string
for _, dest := range dests {
if _, migrates := migrationSet[dest]; !migrates {
ordinaryTargets = append(ordinaryTargets, dest)
}
}
if len(ordinaryTargets) > 0 {
var n, nSkipped int
n, nSkipped, err = skillSetupInstallMulti(src, skillNames, ordinaryTargets, out, errOut)
installed += n
skipped += nSkipped
if err != nil {
return installed, skipped, err
}
if nSkipped > 0 {
return installed, skipped, fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped)
}
}
// The folded pair is excluded from the ordinary best-effort installer. All
// other selected skills (especially dingtalk-shared) must succeed before the
// old Event route is touched.
for _, dest := range migrationTargets {
cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut)
}
var prerequisiteNames []string
for _, name := range skillNames {
if name != multiEventSkill && name != multiMiscSkill {
prerequisiteNames = append(prerequisiteNames, name)
}
}
if len(prerequisiteNames) > 0 {
var n, nSkipped int
n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, migrationTargets, out, errOut)
installed += n
skipped += nSkipped
if err != nil {
return installed, skipped, err
}
if nSkipped > 0 {
return installed, skipped, fmt.Errorf("event Skill 迁移前置安装不完整(skipped=%d);已保留折叠版 Event/misc", nSkipped)
}
}
migrated, migrationErr := migrateEventMiscAtomically(src, migrationTargets, out, errOut)
installed += migrated
if migrationErr != nil {
return installed, skipped, migrationErr
}
return installed, skipped, nil
}
type eventMiscMigration struct {
dest string
stageRoot string
stagedEvent string
stagedMisc string
backupEvent string
backupMisc string
eventPath string
miscPath string
eventBackedUp bool
miscBackedUp bool
newEventEnabled bool
newMiscEnabled bool
}
func prepareEventMiscMigration(src, dest string) (*eventMiscMigration, error) {
stageRoot, err := skillSetupMkdirTemp(dest, ".dws-event-migration-")
if err != nil {
return nil, fmt.Errorf("无法在目标文件系统创建 Event Skill 迁移 staging %s: %w", dest, err)
}
migration := &eventMiscMigration{
dest: dest,
stageRoot: stageRoot,
stagedEvent: filepath.Join(stageRoot, "new-event"),
stagedMisc: filepath.Join(stageRoot, "new-misc"),
backupEvent: filepath.Join(stageRoot, "old-event"),
backupMisc: filepath.Join(stageRoot, "old-misc"),
eventPath: filepath.Join(dest, multiEventSkill),
miscPath: filepath.Join(dest, multiMiscSkill),
}
cleanupOnError := func(cause error) (*eventMiscMigration, error) {
if cleanupErr := skillSetupRemoveAll(stageRoot); cleanupErr != nil {
cause = errors.Join(cause, fmt.Errorf("清理 staging %s 失败: %w", stageRoot, cleanupErr))
}
return nil, cause
}
if err := skillSetupCopyDir(filepath.Join(src, multiEventSkill), migration.stagedEvent); err != nil {
return cleanupOnError(fmt.Errorf("预备 dingtalk-event 失败 %s: %w", dest, err))
}
if err := skillSetupCopyDir(filepath.Join(src, multiMiscSkill), migration.stagedMisc); err != nil {
return cleanupOnError(fmt.Errorf("预备 dingtalk-misc 失败 %s: %w", dest, err))
}
if err := validateEventMigrationSkillRoot(migration.stagedEvent); err != nil {
return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedEvent, err))
}
if err := validateMigrationSkillRoot(migration.stagedMisc, multiMiscSkill, nil); err != nil {
return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedMisc, err))
}
if err := validateCleanEventMiscRoot(migration.stagedMisc); err != nil {
return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedMisc, err))
}
return migration, nil
}
func migrateEventMiscAtomically(src string, dests []string, out, errOut io.Writer) (int, error) {
sortedDests := append([]string(nil), dests...)
sort.Strings(sortedDests)
migrations := make([]*eventMiscMigration, 0, len(sortedDests))
// Stage every target before switching any target. This prevents a source or
// copy failure on a later Agent home from leaving earlier homes upgraded.
for _, dest := range sortedDests {
migration, err := prepareEventMiscMigration(src, dest)
if err != nil {
if cleanupErr := cleanupEventMiscStages(migrations, false, errOut); cleanupErr != nil {
err = errors.Join(err, cleanupErr)
}
return 0, err
}
migrations = append(migrations, migration)
}
committed := make([]*eventMiscMigration, 0, len(migrations))
for _, migration := range migrations {
if err := commitEventMiscMigration(migration); err != nil {
rollbackErr := rollbackEventMiscMigrations(committed)
if rollbackErr != nil {
err = errors.Join(err, fmt.Errorf("已切换目标回滚失败: %w", rollbackErr))
}
var recoveryRoots []string
for _, candidate := range migrations {
if eventMiscMigrationNeedsRecovery(candidate) {
recoveryRoots = append(recoveryRoots, candidate.stageRoot)
}
}
if len(recoveryRoots) > 0 {
err = errors.Join(err, fmt.Errorf("回滚不完整,已保留恢复目录(请勿删除): %s", strings.Join(recoveryRoots, ", ")))
}
if cleanupErr := cleanupEventMiscStages(migrations, true, errOut); cleanupErr != nil {
err = errors.Join(err, cleanupErr)
}
return 0, err
}
committed = append(committed, migration)
}
for _, migration := range migrations {
fmt.Fprintf(out, " ✓ %s\n", migration.eventPath)
fmt.Fprintf(out, " ✓ %s(Event 原子迁移)\n", migration.miscPath)
}
if cleanupErr := cleanupEventMiscStages(migrations, false, errOut); cleanupErr != nil {
fmt.Fprintf(errOut, " ⚠️ Event Skill 迁移已完成,但 staging 清理不完整: %v\n", cleanupErr)
}
return len(migrations) * 2, nil
}
func cleanupEventMiscStages(migrations []*eventMiscMigration, preserveRecovery bool, errOut io.Writer) error {
var cleanupErr error
for _, migration := range migrations {
if preserveRecovery && eventMiscMigrationNeedsRecovery(migration) {
fmt.Fprintf(errOut, " ⚠️ 已保留 Event Skill 恢复目录 %s\n", migration.stageRoot)
continue
}
if err := skillSetupRemoveAll(migration.stageRoot); err != nil {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("清理 Event Skill staging %s 失败: %w", migration.stageRoot, err))
}
}
return cleanupErr
}
func eventMiscMigrationNeedsRecovery(migration *eventMiscMigration) bool {
return migration.eventBackedUp || migration.miscBackedUp || migration.newEventEnabled || migration.newMiscEnabled
}
func commitEventMiscMigration(migration *eventMiscMigration) error {
eventExists, err := skillSetupPathExists(migration.eventPath)
if err != nil {
return fmt.Errorf("无法检查旧 dingtalk-event %s: %w", migration.dest, err)
}
miscExists, err := skillSetupPathExists(migration.miscPath)
if err != nil {
return fmt.Errorf("无法检查旧 dingtalk-misc %s: %w", migration.dest, err)
}
if !miscExists {
return fmt.Errorf("event Skill 迁移中止:折叠版 dingtalk-misc 已不存在 %s", migration.dest)
}
rollbackFailure := func(cause error) error {
if rollbackErr := rollbackEventMiscMigration(migration); rollbackErr != nil {
return errors.Join(cause, fmt.Errorf("回滚 Event/misc 失败 %s: %w", migration.dest, rollbackErr))
}
return cause
}
if eventExists {
if err := skillSetupRename(migration.eventPath, migration.backupEvent); err != nil {
return fmt.Errorf("备份旧 dingtalk-event 失败 %s: %w", migration.dest, err)
}
migration.eventBackedUp = true
}
if err := skillSetupRename(migration.stagedEvent, migration.eventPath); err != nil {
return rollbackFailure(fmt.Errorf("切换 dingtalk-event 失败 %s: %w", migration.dest, err))
}
migration.newEventEnabled = true
if err := skillSetupRename(migration.miscPath, migration.backupMisc); err != nil {
return rollbackFailure(fmt.Errorf("备份旧 dingtalk-misc 失败 %s: %w", migration.dest, err))
}
migration.miscBackedUp = true
if err := skillSetupRename(migration.stagedMisc, migration.miscPath); err != nil {
return rollbackFailure(fmt.Errorf("切换 dingtalk-misc 失败 %s: %w", migration.dest, err))
}
migration.newMiscEnabled = true
return nil
}
func rollbackEventMiscMigrations(migrations []*eventMiscMigration) error {
var rollbackErr error
for i := len(migrations) - 1; i >= 0; i-- {
if err := rollbackEventMiscMigration(migrations[i]); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
}
return rollbackErr
}
func rollbackEventMiscMigration(migration *eventMiscMigration) error {
move := func(enabled *bool, from, to, label string) error {
if !*enabled {
return nil
}
if err := skillSetupRename(from, to); err != nil {
return fmt.Errorf("%s: %w", label, err)
}
*enabled = false
return nil
}
// Stop at the first rollback failure. In particular, do not remove the
// already-working standalone Event while the folded misc route has not been
// restored: even an incomplete rollback must leave at least one Event entry
// point live and preserve the remaining assets in staging for recovery.
steps := []struct {
enabled *bool
from string
to string
label string
}{
{&migration.newMiscEnabled, migration.miscPath, migration.stagedMisc, "移出新 dingtalk-misc"},
{&migration.miscBackedUp, migration.backupMisc, migration.miscPath, "恢复旧 dingtalk-misc"},
{&migration.newEventEnabled, migration.eventPath, migration.stagedEvent, "移出新 dingtalk-event"},
{&migration.eventBackedUp, migration.backupEvent, migration.eventPath, "恢复旧 dingtalk-event"},
}
for _, step := range steps {
if err := move(step.enabled, step.from, step.to, step.label); err != nil {
return err
}
}
return nil
}
func skillSetupPathExists(path string) (bool, error) {
_, err := skillSetupStat(path)
switch {
case err == nil:
return true, nil
case errors.Is(err, os.ErrNotExist):
return false, nil
default:
return false, err
}
}
// installMultiSkillToHomes installs each subdir of src (dingtalk-*) into
// dest as a sibling skill directory. installed/skipped is counted per
// (agent-home × sub-skill) pair so the user sees granular progress.
@@ -649,6 +1218,7 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
continue
}
sharedInstalled := false
for _, name := range skillNames {
subSrc := filepath.Join(src, name)
subDest := filepath.Join(dest, name)
@@ -664,6 +1234,12 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
}
fmt.Fprintf(out, " ✓ %s\n", subDest)
installed++
if name == multiSharedSkill {
sharedInstalled = true
}
}
if sharedInstalled {
cleanupLegacyMultiSharedSkill(dest, out, errOut)
}
}
return installed, skipped, nil
+10 -2
View File
@@ -58,8 +58,9 @@ func TestMaterializeEmbeddedSkillSourceMono(t *testing.T) {
}
// TestMaterializeEmbeddedSkillSourceMulti verifies that the peer multi bundle
// contains both the shared routing skill and misc (including folded PAT docs). Structured
// Schema hints are build inputs and must not become a third installable mode.
// contains the standalone Event skill, shared routing skill, and clean misc
// (including PAT docs). Structured Schema hints are build inputs and must not
// become a third installable mode.
func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
if err != nil {
@@ -71,6 +72,8 @@ func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
t.Fatalf("extracted dir %s is not a valid multi skill source root", dir)
}
for _, rel := range []string{
filepath.Join("dingtalk-event", "SKILL.md"),
filepath.Join("dingtalk-event", "references", "event-oa.md"),
filepath.Join("dingtalk-shared", "SKILL.md"),
filepath.Join("dingtalk-misc", "SKILL.md"),
filepath.Join("dingtalk-misc", "references", "pat.md"),
@@ -79,6 +82,11 @@ func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
t.Errorf("expected embedded multi skill to contain %s: %v", rel, err)
}
}
if _, err := os.Stat(filepath.Join(dir, "dingtalk-misc", "references", "event.md")); err == nil {
t.Fatal("embedded misc must not retain the folded personal Event reference")
} else if !os.IsNotExist(err) {
t.Fatalf("stat embedded misc event reference: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "schema-hints")); err == nil {
t.Fatal("embedded multi skill must not contain build-only schema-hints")
} else if !os.IsNotExist(err) {
@@ -7,9 +7,11 @@ import (
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
)
@@ -120,12 +122,112 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
}
}
func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiSharedSkill, "dingtalk-chat"})
home := filepath.Join(t.TempDir(), "skills")
legacyPath := filepath.Join(home, legacyMultiSharedSkill)
customPath := filepath.Join(home, "custom-skill")
for _, path := range []string{legacyPath, customPath} {
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte("legacy or custom\n"), 0o644); err != nil {
t.Fatal(err)
}
}
var out, errOut bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(
src,
[]string{multiSharedSkill, "dingtalk-chat"},
[]string{home},
&out,
&errOut,
)
if err != nil || installed != 2 || skipped != 0 {
t.Fatalf("install = %d/%d, err=%v, stderr=%s", installed, skipped, err, errOut.String())
}
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
t.Fatalf("legacy shared skill still exists: %v", err)
}
if _, err := os.Stat(filepath.Join(home, multiSharedSkill, "SKILL.md")); err != nil {
t.Fatalf("replacement shared skill missing: %v", err)
}
if _, err := os.Stat(filepath.Join(customPath, "SKILL.md")); err != nil {
t.Fatalf("unrelated custom skill changed: %v", err)
}
if !strings.Contains(out.String(), "已清理已退役 Skill 残留") {
t.Fatalf("legacy cleanup was not reported: %s", out.String())
}
t.Run("failed replacement preserves legacy", func(t *testing.T) {
missingSource := t.TempDir()
failureHome := filepath.Join(t.TempDir(), "skills")
failureLegacy := filepath.Join(failureHome, legacyMultiSharedSkill)
if err := os.MkdirAll(failureLegacy, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(failureLegacy, "SKILL.md"), []byte("legacy\n"), 0o644); err != nil {
t.Fatal(err)
}
var failureOut, failureErr bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(
missingSource,
[]string{multiSharedSkill},
[]string{failureHome},
&failureOut,
&failureErr,
)
if err != nil || installed != 0 || skipped != 1 {
t.Fatalf("failed replacement = %d/%d, err=%v", installed, skipped, err)
}
if _, err := os.Stat(filepath.Join(failureLegacy, "SKILL.md")); err != nil {
t.Fatalf("failed replacement removed legacy shared skill: %v", err)
}
})
}
func TestCrossPlatformCoverageSkillSetupLegacySharedCleanupFailures(t *testing.T) {
fail := errors.New("legacy cleanup failure")
t.Run("missing legacy is a no-op", func(t *testing.T) {
var out, errOut bytes.Buffer
cleanupLegacyMultiSharedSkill(t.TempDir(), &out, &errOut)
if out.Len() != 0 || errOut.Len() != 0 {
t.Fatalf("missing legacy emitted output: stdout=%q stderr=%q", out.String(), errOut.String())
}
})
t.Run("stat failure is reported", func(t *testing.T) {
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, fail })
var out, errOut bytes.Buffer
cleanupLegacyMultiSharedSkill("dest", &out, &errOut)
if out.Len() != 0 || !strings.Contains(errOut.String(), "无法检查已退役 Skill 残留") {
t.Fatalf("stat failure output: stdout=%q stderr=%q", out.String(), errOut.String())
}
})
t.Run("remove failure is reported", func(t *testing.T) {
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
return skillSetupFileInfo{name: filepath.Base(path), mode: os.ModeDir}, nil
})
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return fail })
var out, errOut bytes.Buffer
cleanupLegacyMultiSharedSkill("dest", &out, &errOut)
if out.Len() != 0 || !strings.Contains(errOut.String(), "已退役 Skill 清理失败") {
t.Fatalf("remove failure output: stdout=%q stderr=%q", out.String(), errOut.String())
}
})
}
func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T) {
oldRunForm, oldInteractive := skillSetupRunForm, skillSetupInteractive
oldReadDir, oldStat := skillSetupReadDir, skillSetupStat
oldExecutable, oldGetwd, oldHome := skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir
oldRemove, oldMkdir := skillSetupRemoveAll, skillSetupMkdirAll
oldCopyDir, oldWalk, oldRel := skillSetupCopyDir, skillSetupWalk, skillSetupRel
oldMkdirTemp, oldRename := skillSetupMkdirTemp, skillSetupRename
oldReadlink, oldOpen, oldOpenFile, oldCopy := skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy
t.Cleanup(func() {
skillSetupRunForm, skillSetupInteractive = oldRunForm, oldInteractive
@@ -133,6 +235,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir = oldExecutable, oldGetwd, oldHome
skillSetupRemoveAll, skillSetupMkdirAll = oldRemove, oldMkdir
skillSetupCopyDir, skillSetupWalk, skillSetupRel = oldCopyDir, oldWalk, oldRel
skillSetupMkdirTemp, skillSetupRename = oldMkdirTemp, oldRename
skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy = oldReadlink, oldOpen, oldOpenFile, oldCopy
})
fail := errors.New("failure")
@@ -310,3 +413,236 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
}
_ = fs.ValidPath("path")
}
func TestCrossPlatformCoverageSkillSetupEventMigrationFailureBranches(t *testing.T) {
fail := errors.New("injected failure")
validSkill := func(name string) []byte {
return []byte("---\nname: " + name + "\ndescription: valid migration skill\n---\n\n# Skill\n")
}
t.Run("folded discovery rejects directory reference", func(t *testing.T) {
dest := t.TempDir()
miscRoot := filepath.Join(dest, multiMiscSkill)
if err := os.MkdirAll(miscRoot, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(miscRoot, "SKILL.md"), []byte("dws event\n"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
if strings.HasSuffix(path, filepath.Join("references", "event.md")) {
return skillSetupFileInfo{name: "event.md", mode: os.ModeDir}, nil
}
return os.Stat(path)
})
if got := findFoldedEventMiscTargets([]string{dest}); len(got) != 0 {
t.Fatalf("directory event reference accepted: %#v", got)
}
})
t.Run("migration root validation failures", func(t *testing.T) {
if err := validateMigrationSkillRoot(filepath.Join(t.TempDir(), "missing"), multiEventSkill, nil); err == nil {
t.Fatal("missing SKILL.md succeeded")
}
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "SKILL.md"), validSkill(multiEventSkill), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "references", "directory.md"), 0o755); err != nil {
t.Fatal(err)
}
if err := validateMigrationSkillRoot(root, multiEventSkill, []string{filepath.Join("references", "directory.md")}); err == nil || !strings.Contains(err.Error(), "is a directory") {
t.Fatalf("directory required file = %v", err)
}
missing := filepath.Join("references", "missing.md")
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
if path == filepath.Join(root, missing) {
return skillSetupFileInfo{name: "missing.md"}, nil
}
return os.Stat(path)
})
if err := validateMigrationSkillRoot(root, multiEventSkill, []string{missing}); err == nil || !strings.Contains(err.Error(), "无法读取") {
t.Fatalf("unreadable required file = %v", err)
}
})
t.Run("frontmatter validation branches", func(t *testing.T) {
validWithIgnoredLine := []byte("---\nignored line\nname: dingtalk-event\ndescription: valid\n---\n\nbody\n")
if name, err := parseMigrationSkillFrontmatter(validWithIgnoredLine); err != nil || name != multiEventSkill {
t.Fatalf("ignored frontmatter line = %q, %v", name, err)
}
for name, body := range map[string][]byte{
"duplicate name": []byte("---\nname: one\nname: two\ndescription: valid\n---\nbody\n"),
"unclosed": []byte("---\nname: one\ndescription: valid\nbody\n"),
"missing name": []byte("---\ndescription: valid\n---\nbody\n"),
"missing desc": []byte("---\nname: one\n---\nbody\n"),
"empty body": []byte("---\nname: one\ndescription: valid\n---\n \n"),
} {
t.Run(name, func(t *testing.T) {
if _, err := parseMigrationSkillFrontmatter(body); err == nil {
t.Fatal("invalid frontmatter succeeded")
}
})
}
})
t.Run("clean misc validation branches", func(t *testing.T) {
if err := validateCleanEventMiscRoot(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Fatal("missing misc root succeeded")
}
routed := t.TempDir()
if err := os.WriteFile(filepath.Join(routed, "SKILL.md"), append(validSkill(multiMiscSkill), []byte("dws event\n")...), 0o644); err != nil {
t.Fatal(err)
}
if err := validateCleanEventMiscRoot(routed); err == nil || !strings.Contains(err.Error(), "仍包含个人 Event 路由") {
t.Fatalf("routed misc = %v", err)
}
clean := t.TempDir()
if err := os.WriteFile(filepath.Join(clean, "SKILL.md"), validSkill(multiMiscSkill), 0o644); err != nil {
t.Fatal(err)
}
if err := validateCleanEventMiscRoot(clean); err != nil {
t.Fatalf("missing references should be clean: %v", err)
}
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, fail })
if err := validateCleanEventMiscRoot(clean); !errors.Is(err, fail) {
t.Fatalf("read-dir failure = %v", err)
}
})
t.Run("ordinary and prerequisite install errors", func(t *testing.T) {
testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
return 0, 0, fail
})
migration := filepath.Join(t.TempDir(), "migration")
ordinary := filepath.Join(t.TempDir(), "ordinary")
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, io.Discard, io.Discard); !errors.Is(err, fail) {
t.Fatalf("ordinary install failure = %v", err)
}
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill, multiMiscSkill, multiSharedSkill}, []string{migration}, []string{migration}, io.Discard, io.Discard); !errors.Is(err, fail) {
t.Fatalf("prerequisite install failure = %v", err)
}
})
t.Run("preparation cleanup and staged misc validation", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
dest := t.TempDir()
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return fail })
cleanupFail := errors.New("cleanup failure")
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
if _, err := prepareEventMiscMigration(src, dest); err == nil || !errors.Is(err, fail) || !errors.Is(err, cleanupFail) {
t.Fatalf("joined preparation cleanup error = %v", err)
}
})
t.Run("invalid staged misc root", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
if err := os.WriteFile(filepath.Join(src, multiMiscSkill, "SKILL.md"), validSkill(multiEventSkill), 0o644); err != nil {
t.Fatal(err)
}
if _, err := prepareEventMiscMigration(src, t.TempDir()); err == nil || !strings.Contains(err.Error(), "staging 验证失败") {
t.Fatalf("invalid staged misc = %v", err)
}
})
t.Run("later staging failure joins cleanup error", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
first := filepath.Join(t.TempDir(), "a")
second := filepath.Join(t.TempDir(), "b")
if err := os.MkdirAll(first, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(second, 0o755); err != nil {
t.Fatal(err)
}
originalMkdirTemp := skillSetupMkdirTemp
calls := 0
testseam.Swap(t, &skillSetupMkdirTemp, func(dir, pattern string) (string, error) {
calls++
if calls == 2 {
return "", fail
}
return originalMkdirTemp(dir, pattern)
})
cleanupFail := errors.New("stage cleanup failure")
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
if _, err := migrateEventMiscAtomically(src, []string{second, first}, io.Discard, io.Discard); err == nil || !errors.Is(err, fail) || !errors.Is(err, cleanupFail) {
t.Fatalf("later preparation failure = %v", err)
}
})
t.Run("successful migration reports cleanup warning", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
cleanupFail := errors.New("final cleanup failure")
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
var stderr bytes.Buffer
installed, err := migrateEventMiscAtomically(src, []string{home}, io.Discard, &stderr)
if err != nil || installed != 2 || !strings.Contains(stderr.String(), cleanupFail.Error()) {
t.Fatalf("successful migration cleanup warning: installed=%d err=%v stderr=%s", installed, err, stderr.String())
}
})
t.Run("commit rollback and cleanup failures are joined", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
root := t.TempDir()
first := filepath.Join(root, "a", "skills")
second := filepath.Join(root, "b", "skills")
for _, home := range []string{first, second} {
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
}
commitFail := errors.New("second commit failure")
rollbackFail := errors.New("first rollback failure")
originalRename := skillSetupRename
testseam.Swap(t, &skillSetupRename, func(oldPath, newPath string) error {
if filepath.Base(oldPath) == "new-misc" && newPath == filepath.Join(second, multiMiscSkill) {
return commitFail
}
if filepath.Base(oldPath) == "old-misc" && newPath == filepath.Join(first, multiMiscSkill) {
return rollbackFail
}
return originalRename(oldPath, newPath)
})
cleanupFail := errors.New("post-rollback cleanup failure")
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
if _, err := migrateEventMiscAtomically(src, []string{second, first}, io.Discard, io.Discard); err == nil || !errors.Is(err, commitFail) || !errors.Is(err, rollbackFail) || !errors.Is(err, cleanupFail) {
t.Fatalf("joined commit/rollback/cleanup error = %v", err)
}
})
t.Run("commit preflight and rollback aggregation", func(t *testing.T) {
migration := &eventMiscMigration{dest: "dest", eventPath: "event", miscPath: "misc"}
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, fail })
if err := commitEventMiscMigration(migration); !errors.Is(err, fail) {
t.Fatalf("event stat failure = %v", err)
}
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
if path == migration.eventPath {
return skillSetupFileInfo{name: "event", mode: os.ModeDir}, nil
}
return nil, fail
})
if err := commitEventMiscMigration(migration); !errors.Is(err, fail) {
t.Fatalf("misc stat failure = %v", err)
}
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
if err := commitEventMiscMigration(migration); err == nil || !strings.Contains(err.Error(), "已不存在") {
t.Fatalf("missing misc = %v", err)
}
migration.newMiscEnabled = true
testseam.Swap(t, &skillSetupRename, func(string, string) error { return fail })
if err := rollbackEventMiscMigrations([]*eventMiscMigration{migration}); !errors.Is(err, fail) {
t.Fatalf("rollback aggregation = %v", err)
}
})
}
+796 -1
View File
@@ -2,6 +2,9 @@ package app
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
@@ -184,12 +187,20 @@ func writeMultiSkillSource(t *testing.T, names []string) string {
if err := os.MkdirAll(filepath.Join(sub, "references"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte("# "+n), 0o644); err != nil {
skillBody := "---\nname: " + n + "\ndescription: test skill\n---\n\n# " + n + "\n"
if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte(skillBody), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "references", n+".md"), []byte("ref "+n), 0o644); err != nil {
t.Fatal(err)
}
if n == multiEventSkill {
for _, ref := range eventMigrationRequiredReferences {
if err := os.WriteFile(filepath.Join(sub, "references", ref), []byte("ref "+ref+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
}
}
return root
}
@@ -549,3 +560,787 @@ func TestResolveSkillSetupSourceMultiFinds(t *testing.T) {
t.Fatalf("expected %s, got %s", multiDir, got)
}
}
func executeMultiSkillSetupTest(t *testing.T, src string, dests []string, args ...string) (string, string, error) {
t.Helper()
originalTargets := skillSetupResolveTargets
skillSetupResolveTargets = func(string, string) ([]string, error) {
return append([]string(nil), dests...), nil
}
t.Cleanup(func() { skillSetupResolveTargets = originalTargets })
cmd := newSkillSetupCommand()
cmd.Flags().Bool("dry-run", false, "")
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
baseArgs := []string{"--mode", "multi", "--source", src}
cmd.SetArgs(append(baseArgs, args...))
err := cmd.Execute()
return stdout.String(), stderr.String(), err
}
func writeFoldedEventMisc(t *testing.T, agentHome string) {
t.Helper()
miscRoot := filepath.Join(agentHome, multiMiscSkill)
if err := os.MkdirAll(filepath.Join(miscRoot, "references"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(miscRoot, "SKILL.md"), []byte("personal IM route: dws event consume\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(miscRoot, "references", "event.md"), []byte("folded event docs\n"), 0o644); err != nil {
t.Fatal(err)
}
}
func writeOldStandaloneEvent(t *testing.T, agentHome string) {
t.Helper()
eventRoot := filepath.Join(agentHome, multiEventSkill)
if err := os.MkdirAll(eventRoot, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(eventRoot, "SKILL.md"), []byte("old standalone event\n"), 0o644); err != nil {
t.Fatal(err)
}
}
func assertOldEventMiscPair(t *testing.T, agentHome string) {
t.Helper()
eventBody, err := os.ReadFile(filepath.Join(agentHome, multiEventSkill, "SKILL.md"))
if err != nil || string(eventBody) != "old standalone event\n" {
t.Fatalf("old standalone event was not restored: body=%q err=%v", eventBody, err)
}
miscBody, err := os.ReadFile(filepath.Join(agentHome, multiMiscSkill, "SKILL.md"))
if err != nil || !strings.Contains(string(miscBody), "dws event") {
t.Fatalf("folded misc was not restored: body=%q err=%v", miscBody, err)
}
if _, err := os.Stat(filepath.Join(agentHome, multiMiscSkill, "references", "event.md")); err != nil {
t.Fatalf("folded Event reference was not restored: %v", err)
}
}
func assertNoEventMigrationStages(t *testing.T, agentHome string) {
t.Helper()
entries, err := os.ReadDir(agentHome)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".dws-event-migration-") {
t.Fatalf("unexpected leftover Event migration stage %s", filepath.Join(agentHome, entry.Name()))
}
}
}
func TestSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
src := writeMultiSkillSource(t, []string{
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
})
foldedHome := filepath.Join(t.TempDir(), "folded", "skills")
freshHome := filepath.Join(t.TempDir(), "fresh", "skills")
writeFoldedEventMisc(t, foldedHome)
if err := os.MkdirAll(filepath.Join(foldedHome, multiEventSkill), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(foldedHome, multiEventSkill, "SKILL.md"), []byte("old standalone event\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(foldedHome, "dingtalk-chat"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(foldedHome, "dingtalk-chat", "SKILL.md"), []byte("keep sibling\n"), 0o644); err != nil {
t.Fatal(err)
}
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event")
if err != nil {
t.Fatalf("selective event setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout)
}
if !strings.Contains(stdout, "迁移伴侣") || !strings.Contains(stdout, foldedHome) {
t.Fatalf("confirmation output should expose folded misc migration: %s", stdout)
}
if !strings.Contains(stdout, "重新加载 Skills") {
t.Fatalf("completion should tell the user to reload skills: %s", stdout)
}
for _, home := range []string{freshHome, foldedHome} {
for _, name := range []string{multiSharedSkill, multiEventSkill} {
if _, err := os.Stat(filepath.Join(home, name, "SKILL.md")); err != nil {
t.Errorf("%s missing from %s: %v", name, home, err)
}
}
if _, err := os.Stat(filepath.Join(home, "dingtalk-doc")); !os.IsNotExist(err) {
t.Errorf("unselected doc appeared in %s: %v", home, err)
}
}
if _, err := os.Stat(filepath.Join(freshHome, multiMiscSkill)); !os.IsNotExist(err) {
t.Fatalf("fresh selective target must not receive misc, stat err=%v", err)
}
if _, err := os.Stat(filepath.Join(foldedHome, multiMiscSkill, "references", "event.md")); !os.IsNotExist(err) {
t.Fatalf("folded event reference survived clean misc replacement, stat err=%v", err)
}
eventBody, err := os.ReadFile(filepath.Join(foldedHome, multiEventSkill, "SKILL.md"))
if err != nil || strings.Contains(string(eventBody), "old standalone") {
t.Fatalf("old standalone event was not replaced: body=%q err=%v", eventBody, err)
}
siblingBody, err := os.ReadFile(filepath.Join(foldedHome, "dingtalk-chat", "SKILL.md"))
if err != nil || string(siblingBody) != "keep sibling\n" {
t.Fatalf("unrelated sibling changed: body=%q err=%v", siblingBody, err)
}
// A second selective run sees the already-clean misc, does not plan another
// migration, and leaves that unselected sibling in place.
stdout, stderr, err = executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event", "--yes")
if err != nil {
t.Fatalf("idempotent event setup failed: %v\nstderr=%s", err, stderr)
}
if strings.Contains(stdout, "迁移伴侣") {
t.Fatalf("clean second run should not re-detect folded misc: %s", stdout)
}
if _, err := os.Stat(filepath.Join(foldedHome, multiMiscSkill, "SKILL.md")); err != nil {
t.Fatalf("second selective run removed clean misc: %v", err)
}
}
func TestSkillSetupEventMigrationDryRunAndExplicitExclude(t *testing.T) {
src := writeMultiSkillSource(t, []string{
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
})
t.Run("dry run reports companion without writes", func(t *testing.T) {
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--dry-run", "--yes")
if err != nil {
t.Fatalf("dry run failed: %v\nstderr=%s", err, stderr)
}
if !strings.Contains(stdout, "DRY-RUN") || !strings.Contains(stdout, "迁移伴侣") {
t.Fatalf("dry run did not expose migration: %s", stdout)
}
body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
if readErr != nil || !strings.Contains(string(body), "dws event") {
t.Fatalf("dry run changed folded misc: body=%q err=%v", body, readErr)
}
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) {
t.Fatalf("dry run installed event, stat err=%v", statErr)
}
})
t.Run("excluding required misc fails before writes", func(t *testing.T) {
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--exclude", "misc", "--yes")
if err == nil || !strings.Contains(err.Error(), "不能显式 --exclude misc") {
t.Fatalf("expected clear migration exclusion error, got %v", err)
}
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) {
t.Fatalf("failed migration installed event, stat err=%v", statErr)
}
body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
if readErr != nil || !strings.Contains(string(body), "dws event") {
t.Fatalf("failed migration changed misc: body=%q err=%v", body, readErr)
}
})
}
func TestSkillSetupEventMigrationRequiresCleanMiscInSource(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "当前 multi 源缺少") {
t.Fatalf("expected missing migration companion error, got %v", err)
}
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) {
t.Fatalf("failed preflight installed event, stat err=%v", statErr)
}
}
func TestSkillSetupEventMigrationAcceptsShippedMultiBundle(t *testing.T) {
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
src := filepath.Clean(filepath.Join(wd, "..", "..", "skills", "multi"))
if err := validateEventMiscMigrationSource(src); err != nil {
t.Fatalf("shipped multi bundle is not a valid Event migration source: %v", err)
}
}
func TestSkillSetupEventMigrationRejectsInvalidSkillBundlesBeforeWrites(t *testing.T) {
tests := []struct {
name string
mutate func(t *testing.T, src string)
}{
{
name: "empty event root",
mutate: func(t *testing.T, src string) {
t.Helper()
if err := os.WriteFile(filepath.Join(src, multiEventSkill, "SKILL.md"), nil, 0o644); err != nil {
t.Fatal(err)
}
},
},
{
name: "wrong event name",
mutate: func(t *testing.T, src string) {
t.Helper()
body := "---\nname: dingtalk-chat\ndescription: wrong skill\n---\n\n# Wrong\n"
if err := os.WriteFile(filepath.Join(src, multiEventSkill, "SKILL.md"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
},
},
{
name: "missing event reference",
mutate: func(t *testing.T, src string) {
t.Helper()
if err := os.Remove(filepath.Join(src, multiEventSkill, "references", "event-oa.md")); err != nil {
t.Fatal(err)
}
},
},
{
name: "empty event reference",
mutate: func(t *testing.T, src string) {
t.Helper()
if err := os.WriteFile(filepath.Join(src, multiEventSkill, "references", "event-im.md"), nil, 0o644); err != nil {
t.Fatal(err)
}
},
},
{
name: "wrong misc name",
mutate: func(t *testing.T, src string) {
t.Helper()
body := "---\nname: dingtalk-event\ndescription: wrong skill\n---\n\n# Wrong\n"
if err := os.WriteFile(filepath.Join(src, multiMiscSkill, "SKILL.md"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
test.mutate(t, src)
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "迁移源无效") {
t.Fatalf("invalid migration source was accepted: %v", err)
}
if strings.Contains(stdout, "Skill 安装完成") {
t.Fatalf("invalid migration source reported success: %s", stdout)
}
assertOldEventMiscPair(t, home)
if _, statErr := os.Stat(filepath.Join(home, multiSharedSkill)); !os.IsNotExist(statErr) {
t.Fatalf("invalid source wrote shared skill: %v", statErr)
}
})
}
}
func TestSkillSetupSelectiveEventPreservesFoldedMiscAfterPrimarySkip(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
originalInstallMulti := skillSetupInstallMulti
t.Cleanup(func() { skillSetupInstallMulti = originalInstallMulti })
calls := 0
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
calls++
if calls > 1 {
t.Fatal("misc migration companion ran after a primary install skip")
}
return 1, 1, nil
}
stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "已保留折叠版 Event/misc") {
t.Fatalf("expected preserved-fallback error, got %v", err)
}
if calls != 1 {
t.Fatalf("install calls = %d, want 1", calls)
}
if strings.Contains(stdout, "Skill 安装完成") {
t.Fatalf("partial migration reported success: %s", stdout)
}
body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
if readErr != nil || !strings.Contains(string(body), "dws event") {
t.Fatalf("primary skip changed folded misc: body=%q err=%v", body, readErr)
}
if _, statErr := os.Stat(filepath.Join(home, multiMiscSkill, "references", "event.md")); statErr != nil {
t.Fatalf("primary skip removed folded Event reference: %v", statErr)
}
}
func TestSkillSetupFreshTargetFailureDoesNotTouchFoldedPair(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
freshHome := filepath.Join(t.TempDir(), "fresh", "skills")
foldedHome := filepath.Join(t.TempDir(), "folded", "skills")
writeFoldedEventMisc(t, foldedHome)
writeOldStandaloneEvent(t, foldedHome)
originalInstallMulti := skillSetupInstallMulti
t.Cleanup(func() { skillSetupInstallMulti = originalInstallMulti })
calls := 0
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
calls++
if calls > 1 {
t.Fatal("folded target prerequisites ran after fresh target failure")
}
return 1, 1, nil
}
stdout, _, err := executeMultiSkillSetupTest(
t,
src,
[]string{foldedHome, freshHome},
"--skill", "event",
"--yes",
)
if err == nil || !strings.Contains(err.Error(), "已保留折叠版 Event/misc") {
t.Fatalf("fresh target failure did not block migration: %v", err)
}
if strings.Contains(stdout, "Skill 安装完成") {
t.Fatalf("partial mixed-target install reported success: %s", stdout)
}
assertOldEventMiscPair(t, foldedHome)
assertNoEventMigrationStages(t, foldedHome)
}
func TestSkillSetupUnrelatedSelectiveInstallLeavesFoldedPairUntouched(t *testing.T) {
src := writeMultiSkillSource(t, []string{
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "doc", "--yes")
if err != nil {
t.Fatalf("unrelated selective install failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
}
if strings.Contains(stdout, "Event Skill 迁移") || strings.Contains(stdout, "Event 原子迁移") {
t.Fatalf("unrelated selective install planned Event migration: %s", stdout)
}
if _, err := os.Stat(filepath.Join(home, "dingtalk-doc", "SKILL.md")); err != nil {
t.Fatalf("selected doc was not installed: %v", err)
}
assertOldEventMiscPair(t, home)
assertNoEventMigrationStages(t, home)
}
func TestSkillSetupSelectiveEventAtomicStageFailurePreservesFoldedPair(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
originalCopyDir := skillSetupCopyDir
t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
skillSetupCopyDir = func(src, dest string) error {
if strings.HasSuffix(dest, "new-misc") {
return errors.New("injected stage failure")
}
return originalCopyDir(src, dest)
}
stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "预备 dingtalk-misc 失败") {
t.Fatalf("expected atomic staging error, got %v", err)
}
if strings.Contains(stdout, "Skill 安装完成") {
t.Fatalf("partial atomic migration reported success: %s", stdout)
}
body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
if readErr != nil || !strings.Contains(string(body), "dws event") {
t.Fatalf("stage failure changed folded misc: body=%q err=%v", body, readErr)
}
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) {
t.Fatalf("stage failure installed standalone event, stat err=%v", statErr)
}
}
func TestSkillSetupEventMigrationPreparationFailuresPreserveFoldedPair(t *testing.T) {
t.Run("same-filesystem staging creation", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
originalMkdirTemp := skillSetupMkdirTemp
t.Cleanup(func() { skillSetupMkdirTemp = originalMkdirTemp })
skillSetupMkdirTemp = func(dir, pattern string) (string, error) {
if dir != home {
t.Fatalf("staging dir = %s, want target filesystem root %s", dir, home)
}
return "", errors.New("injected mkdir-temp failure")
}
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "injected mkdir-temp failure") {
t.Fatalf("staging creation failure was not returned: %v", err)
}
assertOldEventMiscPair(t, home)
assertNoEventMigrationStages(t, home)
})
t.Run("event staging copy", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
originalCopyDir := skillSetupCopyDir
t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
skillSetupCopyDir = func(src, dest string) error {
if strings.HasSuffix(dest, "new-event") {
return errors.New("injected event copy failure")
}
return originalCopyDir(src, dest)
}
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "injected event copy failure") {
t.Fatalf("event staging failure was not returned: %v", err)
}
assertOldEventMiscPair(t, home)
assertNoEventMigrationStages(t, home)
})
}
func TestSkillSetupSelectiveEventRejectsCorruptStagedMisc(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
originalCopyDir := skillSetupCopyDir
t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
skillSetupCopyDir = func(src, dest string) error {
if err := originalCopyDir(src, dest); err != nil {
return err
}
if strings.HasSuffix(dest, "new-misc") {
return os.WriteFile(filepath.Join(dest, "references", "event-partial.md"), []byte("corrupt\n"), 0o644)
}
return nil
}
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "staging 验证失败") {
t.Fatalf("corrupt staged misc was accepted: %v", err)
}
miscBody, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
if readErr != nil || !strings.Contains(string(miscBody), "dws event") {
t.Fatalf("staging validation failure changed folded misc: body=%q err=%v", miscBody, readErr)
}
if _, err := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(err) {
t.Fatalf("staging validation failure installed event: %v", err)
}
assertNoEventMigrationStages(t, home)
}
func TestSkillSetupSelectiveEventRejectsIncompleteStagedEvent(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
originalCopyDir := skillSetupCopyDir
t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
skillSetupCopyDir = func(src, dest string) error {
if err := originalCopyDir(src, dest); err != nil {
return err
}
if strings.HasSuffix(dest, "new-event") {
return os.Remove(filepath.Join(dest, "references", "event-oa.md"))
}
return nil
}
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "staging 验证失败") {
t.Fatalf("incomplete staged Event was accepted: %v", err)
}
assertOldEventMiscPair(t, home)
assertNoEventMigrationStages(t, home)
}
func TestSkillSetupFullEventMigrationIsAtomicAndPreservesSiblings(t *testing.T) {
src := writeMultiSkillSource(t, []string{
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
sibling := filepath.Join(home, "dingtalk-private-sibling")
if err := os.MkdirAll(sibling, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sibling, "SKILL.md"), []byte("keep\n"), 0o644); err != nil {
t.Fatal(err)
}
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--yes")
if err != nil {
t.Fatalf("full setup migration failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
}
if !strings.Contains(stdout, "Event 原子迁移") || !strings.Contains(stdout, "Skill 安装完成") {
t.Fatalf("full setup did not report atomic migration success: %s", stdout)
}
for _, name := range []string{multiEventSkill, multiMiscSkill, multiSharedSkill, "dingtalk-doc"} {
if _, err := os.Stat(filepath.Join(home, name, "SKILL.md")); err != nil {
t.Fatalf("full setup missing %s: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(home, multiMiscSkill, "references", "event.md")); !os.IsNotExist(err) {
t.Fatalf("full setup retained folded Event reference: %v", err)
}
body, err := os.ReadFile(filepath.Join(sibling, "SKILL.md"))
if err != nil || string(body) != "keep\n" {
t.Fatalf("full setup changed unrelated sibling: body=%q err=%v", body, err)
}
assertNoEventMigrationStages(t, home)
}
func TestSkillSetupEventMigrationWithoutSharedStillCleansMonoLeftover(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
monoLeftover := filepath.Join(home, "dws")
if err := os.MkdirAll(monoLeftover, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(monoLeftover, "SKILL.md"), []byte("old mono\n"), 0o644); err != nil {
t.Fatal(err)
}
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err != nil {
t.Fatalf("migration without shared failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
}
if _, err := os.Stat(monoLeftover); !os.IsNotExist(err) {
t.Fatalf("migration without prerequisites retained mono leftover: %v", err)
}
for _, name := range []string{multiEventSkill, multiMiscSkill} {
if _, err := os.Stat(filepath.Join(home, name, "SKILL.md")); err != nil {
t.Fatalf("migration without shared missing %s: %v", name, err)
}
}
assertNoEventMigrationStages(t, home)
}
func TestSkillSetupFoldedEventMigrationSelectionPreflight(t *testing.T) {
src := writeMultiSkillSource(t, []string{
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
})
tests := []struct {
name string
args []string
want string
}{
{name: "misc only", args: []string{"--skill", "misc", "--yes"}, want: "不能只覆盖 dingtalk-misc"},
{name: "explicitly excludes event", args: []string{"--exclude", "event", "--yes"}, want: "不能显式 --exclude event"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, tt.args...)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("preflight error = %v, want %q", err, tt.want)
}
if _, err := os.Stat(filepath.Join(home, multiSharedSkill)); !os.IsNotExist(err) {
t.Fatalf("preflight failure wrote shared skill: %v", err)
}
if _, err := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(err) {
t.Fatalf("preflight failure wrote event skill: %v", err)
}
miscBody, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
if readErr != nil || !strings.Contains(string(miscBody), "dws event") {
t.Fatalf("preflight failure changed folded misc: body=%q err=%v", miscBody, readErr)
}
})
}
}
func TestSkillSetupEventMigrationRejectsEveryFoldedReferenceVariant(t *testing.T) {
for _, filename := range []string{"event.md", "event-im.md", "event-oa.md", "EVENT-legacy.MD"} {
t.Run(filename, func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
if err := os.WriteFile(filepath.Join(src, multiMiscSkill, "references", filename), []byte("stale\n"), 0o644); err != nil {
t.Fatal(err)
}
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "仍存在折叠 Event 参考页") {
t.Fatalf("source with %s was accepted: %v", filename, err)
}
if _, err := os.Stat(filepath.Join(home, multiSharedSkill)); !os.IsNotExist(err) {
t.Fatalf("invalid source wrote shared skill: %v", err)
}
})
}
}
func TestSkillSetupEventMigrationRenameFailuresRollbackPair(t *testing.T) {
for failAt := 1; failAt <= 4; failAt++ {
t.Run(fmt.Sprintf("rename_%d", failAt), func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
originalRename := skillSetupRename
t.Cleanup(func() { skillSetupRename = originalRename })
renameCalls := 0
skillSetupRename = func(oldPath, newPath string) error {
renameCalls++
if renameCalls == failAt {
return errors.New("injected rename failure")
}
return originalRename(oldPath, newPath)
}
stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "injected rename failure") {
t.Fatalf("rename failure %d was not returned: %v", failAt, err)
}
if strings.Contains(stdout, "Skill 安装完成") {
t.Fatalf("rename failure %d reported success: %s", failAt, stdout)
}
assertOldEventMiscPair(t, home)
assertNoEventMigrationStages(t, home)
})
}
}
func TestSkillSetupEventMigrationFailureRollsBackEarlierTargets(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
root := t.TempDir()
firstHome := filepath.Join(root, "a", "skills")
secondHome := filepath.Join(root, "b", "skills")
for _, home := range []string{firstHome, secondHome} {
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
}
originalRename := skillSetupRename
t.Cleanup(func() { skillSetupRename = originalRename })
failed := false
skillSetupRename = func(oldPath, newPath string) error {
if !failed && oldPath == filepath.Join(secondHome, multiMiscSkill) {
failed = true
return errors.New("second target failure")
}
return originalRename(oldPath, newPath)
}
_, _, err := executeMultiSkillSetupTest(t, src, []string{secondHome, firstHome}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "second target failure") {
t.Fatalf("second target failure was not returned: %v", err)
}
for _, home := range []string{firstHome, secondHome} {
assertOldEventMiscPair(t, home)
assertNoEventMigrationStages(t, home)
}
}
func TestSkillSetupEventMigrationRollbackFailurePreservesRecoveryDirectory(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
originalRename := skillSetupRename
t.Cleanup(func() { skillSetupRename = originalRename })
skillSetupRename = func(oldPath, newPath string) error {
if strings.HasSuffix(oldPath, filepath.Join("new-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
return errors.New("commit failure")
}
if strings.HasSuffix(oldPath, filepath.Join("old-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
return errors.New("rollback restore failure")
}
return originalRename(oldPath, newPath)
}
_, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "回滚不完整") || !strings.Contains(err.Error(), "恢复目录") {
t.Fatalf("rollback failure did not expose recovery directory: %v", err)
}
entries, readErr := os.ReadDir(home)
if readErr != nil {
t.Fatal(readErr)
}
var recoveryRoot string
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".dws-event-migration-") {
recoveryRoot = filepath.Join(home, entry.Name())
break
}
}
if recoveryRoot == "" {
t.Fatal("rollback failure deleted the only recovery directory")
}
if !strings.Contains(err.Error(), recoveryRoot) || !strings.Contains(stderr, recoveryRoot) {
t.Fatalf("recovery directory was not reported: err=%v stderr=%s", err, stderr)
}
if _, err := os.Stat(filepath.Join(recoveryRoot, "old-misc", "SKILL.md")); err != nil {
t.Fatalf("old folded misc backup is missing from recovery directory: %v", err)
}
if _, err := os.Stat(filepath.Join(recoveryRoot, "old-event", "SKILL.md")); err != nil {
t.Fatalf("old standalone Event backup is missing from recovery directory: %v", err)
}
if _, err := os.Stat(filepath.Join(home, multiEventSkill, "SKILL.md")); err != nil {
t.Fatalf("rollback failure removed the live standalone Event entry: %v", err)
}
}
func TestSkillSetupEventMigrationRollbackFailureKeepsNewEventWithoutOldStandalone(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
originalRename := skillSetupRename
t.Cleanup(func() { skillSetupRename = originalRename })
skillSetupRename = func(oldPath, newPath string) error {
if strings.HasSuffix(oldPath, filepath.Join("new-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
return errors.New("commit failure")
}
if strings.HasSuffix(oldPath, filepath.Join("old-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
return errors.New("rollback restore failure")
}
return originalRename(oldPath, newPath)
}
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "回滚不完整") {
t.Fatalf("rollback failure was not returned: %v", err)
}
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill, "SKILL.md")); statErr != nil {
t.Fatalf("rollback failure removed the only live Event entry: %v", statErr)
}
entries, readErr := os.ReadDir(home)
if readErr != nil {
t.Fatal(readErr)
}
for _, entry := range entries {
if !strings.HasPrefix(entry.Name(), ".dws-event-migration-") {
continue
}
if _, statErr := os.Stat(filepath.Join(home, entry.Name(), "old-misc", "SKILL.md")); statErr != nil {
t.Fatalf("rollback failure lost the folded misc recovery copy: %v", statErr)
}
return
}
t.Fatal("rollback failure did not preserve a recovery directory")
}
+8 -19
View File
@@ -2003,7 +2003,6 @@ var generatedParamAliases = []ParamAliasEntry{
"next-page-token": "cursor",
"next-token": "cursor",
"node-id": "node",
"page-size": "limit",
"page-token": "cursor",
"per-page": "limit",
"size": "limit",
@@ -2459,25 +2458,15 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "doc +version-list",
Aliases: map[string]string{
"doc": "node",
"doc-id": "node",
"document-id": "node",
"file-id": "node",
"max-result": "limit",
"max-results": "limit",
"next-cursor": "cursor",
"next-page-token": "cursor",
"next-token": "cursor",
"node-id": "node",
"page-size": "limit",
"page-token": "cursor",
"per-page": "limit",
"size": "limit",
"take": "limit",
"top": "limit",
"url": "node",
"doc": "node",
"doc-id": "node",
"document-id": "node",
"file-id": "node",
"node-id": "node",
"url": "node",
},
Blocked: []string{"block-id", "comment-id", "comment-key", "count", "folder", "folder-id", "id", "job-id", "offset", "page", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"},
Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"},
Ambiguous: []string{"max-result", "max-results", "next-cursor", "next-page-token", "next-token", "per-page", "size", "take", "top"},
},
{
CLIPath: "doc +version-revert",
+1
View File
@@ -44,6 +44,7 @@
},
"command_overrides": {
"doc +version-list": {"ambiguous": ["size", "max-results", "max-result", "take", "top", "per-page", "next-cursor", "next-token", "next-page-token"], "note": "--limit/--cursor and the shipped visible compatibility flags --page-size/--page-token remain native. Other pagination spellings cannot choose between two visible real flags and must stop before execution."},
"chat group rename": {"bind": {"id": "open_conversation_id"}, "note": "This command's real --id carries one openConversationId; aliases reduce to --id without changing the value."},
"chat group members": {"bind": {"id": "open_conversation_id"}},
"chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."},
@@ -256,6 +256,10 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"chat.list_conversation_message_v2 --open-dingtalk-id": "selects the alternate list_individual_chat_message branch",
"chat.list_conversation_message_v2 --user": "selects the alternate list_individual_chat_message branch",
"chat.list_message_favorites --cursor": "Reviewed unpinned adapter: chat.list_message_favorites has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
"chat.list_message_favorites --max-items": "Reviewed helper-only pagination control: --max-items caps aggregated CLI output for chat.list_message_favorites and is not sent to the remote adapter.",
"chat.list_message_favorites --page-all": "Reviewed helper-only pagination control: --page-all enables CLI-side looping for chat.list_message_favorites and is not sent to the remote adapter.",
"chat.list_message_favorites --page-delay": "Reviewed helper-only pagination control: --page-delay controls local delay between chat.list_message_favorites pages and is not sent to the remote adapter.",
"chat.list_message_favorites --page-limit": "Reviewed helper-only pagination control: --page-limit caps CLI-side page fetches for chat.list_message_favorites and is not sent to the remote adapter.",
"chat.list_message_favorites --size": "Reviewed unpinned adapter: chat.list_message_favorites has no singular pinned interface_ref; --size is a CLI wrapper input and does not publish a direct interface property.",
"chat.query_msg_read_status --user": "conditional wrapper: parseCSVValues + appendChatIDArgs routes each supplied identifier to targetUserIds or targetOpenDingTalkIds according to its runtime ID shape; there is no single RPC property for this flag",
"chat.query_msg_read_status --users": "conditional wrapper/alias of --user: parseCSVValues + appendChatIDArgs routes each supplied identifier to targetUserIds or targetOpenDingTalkIds according to its runtime ID shape; there is no single RPC property for this flag",
+12
View File
@@ -558,6 +558,18 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
for _, alias := range flag.Aliases {
RegisterFlag(cmd, flag.Kind, alias, "", flag.Usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
if registered := cmd.Flags().Lookup(alias); registered != nil {
runtimeannotate.SetFlagAnnotation(
registered,
runtimeannotate.AnnotationFlagAliasOf,
flag.Name,
)
runtimeannotate.SetFlagAnnotation(
registered,
runtimeannotate.AnnotationFlagAliasOrigin,
runtimeannotate.FlagAliasOriginCorecmdV1,
)
}
}
if flag.MarkRequired {
_ = cmd.MarkFlagRequired(flag.Name)
+7 -1
View File
@@ -90,11 +90,17 @@ func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
}
}
// Aliases are registered with the main kind and hidden.
for _, alias := range []string{"i-alias", "sl-alias"} {
for alias, canonical := range map[string]string{"i-alias": "i", "sl-alias": "sl"} {
f := cmd.Flags().Lookup(alias)
if f == nil || !f.Hidden {
t.Fatalf("alias %q = %#v, want registered+hidden", alias, f)
}
if got := f.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != canonical {
t.Fatalf("alias %q annotation = %#v, want alias_of %q", alias, got, canonical)
}
if got := f.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
t.Fatalf("alias %q origin = %#v, want corecmd FlagSpec marker", alias, got)
}
}
if cmd.Flags().Lookup("i-alias").Value.Type() != "int" {
t.Fatal("int alias must be registered as int")
@@ -0,0 +1,25 @@
// 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 runtimeannotate
// CLI flag alias evidence is kept in this narrow file so the base-owned
// Interface Snapshot helper can add the protocol constants to an older stable
// worktree without replacing that revision's complete runtimeannotate package.
// Only corecmd.FlagSpec.Aliases writes the exact origin; neither field is a
// Schema synonym or final payload-equivalence proof.
const (
AnnotationFlagAliasOf = "dws.compat.alias_of"
AnnotationFlagAliasOrigin = "dws.compat.alias_origin"
FlagAliasOriginCorecmdV1 = "corecmd.flag_spec_aliases.v1"
)
+1 -1
View File
@@ -41,7 +41,7 @@ func TestCrossPlatformCoverageDiagnosticsAndErrorRenderingEdges(t *testing.T) {
t.Fatalf("PrintJSON friendly diagnostics = %q, %v", out.String(), err)
}
out.Reset()
if err := PrintHumanAt(&out, err, VerbosityVerbose); err != nil || !strings.Contains(out.String(), "开启地址") {
if err := PrintHumanAt(&out, err, VerbosityVerbose); err != nil || !strings.Contains(out.String(), "处理入口") {
t.Fatalf("PrintHuman friendly diagnostics = %q, %v", out.String(), err)
}
out.Reset()
+16 -2
View File
@@ -19,6 +19,7 @@ import (
stderrors "errors"
"fmt"
"io"
"net/url"
"strings"
"time"
@@ -460,7 +461,7 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
lines = append(lines, tui.Cyan("Hint: "+friendlyHint))
}
if actionURL != "" {
lines = append(lines, tui.White("Action: 开启地址: "+actionURL))
lines = append(lines, tui.White("Action: 处理入口: "+actionURL))
}
}
@@ -540,7 +541,7 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
func serverGuidance(diag ServerDiagnostics) (string, string) {
friendlyHint := strings.TrimSpace(diag.FriendlyHint)
actionURL := strings.TrimSpace(diag.ActionURL)
actionURL := safeServerActionURL(diag.ActionURL)
if friendlyHint == "" || actionURL == "" {
switch diag.ServerErrorCode {
case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED":
@@ -555,6 +556,19 @@ func serverGuidance(diag ServerDiagnostics) (string, string) {
return friendlyHint, actionURL
}
func safeServerActionURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err != nil || !strings.EqualFold(parsed.Scheme, "https") ||
parsed.Hostname() == "" || parsed.User != nil {
return ""
}
return parsed.String()
}
func category(err error) string {
var typed *Error
if stderrors.As(err, &typed) {
+30 -1
View File
@@ -336,11 +336,40 @@ func TestCrossPlatformCoveragePrintHumanIncludesServerGuidance(t *testing.T) {
if !strings.Contains(got, "Hint: 请联系管理员开通消息搜索权益") {
t.Fatalf("expected server guidance in output, got %q", got)
}
if !strings.Contains(got, "Action: 开启地址: https://example.test/enable-search") {
if !strings.Contains(got, "Action: 处理入口: https://example.test/enable-search") {
t.Fatalf("expected server action URL in output, got %q", got)
}
}
func TestCrossPlatformCoverageServerGuidanceSuppressesUnsafeActionURL(t *testing.T) {
t.Parallel()
for _, actionURL := range []string{
"http://example.test/help",
"javascript:alert(1)",
"https://user:secret@example.test/help",
"not a url",
} {
var human strings.Builder
err := NewAPI("server error", WithServerDiag(ServerDiagnostics{
FriendlyHint: "保留 Trace ID 后排查",
ActionURL: actionURL,
}))
if printErr := PrintHuman(&human, err); printErr != nil {
t.Fatal(printErr)
}
if strings.Contains(human.String(), actionURL) || strings.Contains(human.String(), "处理入口") {
t.Fatalf("unsafe action URL %q leaked to human output: %q", actionURL, human.String())
}
var jsonOutput strings.Builder
if printErr := PrintJSON(&jsonOutput, err); printErr != nil {
t.Fatal(printErr)
}
if strings.Contains(jsonOutput.String(), `"action_url"`) {
t.Fatalf("unsafe action URL %q leaked to JSON output: %q", actionURL, jsonOutput.String())
}
}
}
func TestCrossPlatformCoveragePrintJSONIncludesRPCCodeAndData(t *testing.T) {
t.Parallel()
+6
View File
@@ -128,6 +128,12 @@ func TestClientCreateRuleBasedSubscriptionsUsesDocumentedRuleParam(t *testing.T)
{"receive_user/openDingtalkId", EventFromUser, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
{"receive_o2o_all", EventAllSingleChat, RuleOptions{}, map[string]any{}},
{"receive_group_all", EventAllGroupChat, RuleOptions{}, map[string]any{}},
{"oa_approval_task_created", EventOAApprovalTaskCreated, RuleOptions{}, map[string]any{}},
{"oa_approval_task_finished", EventOAApprovalTaskFinished, RuleOptions{}, map[string]any{}},
{"oa_approval_task_redirected", EventOAApprovalTaskRedirected, RuleOptions{}, map[string]any{}},
{"oa_approval_instance_started", EventOAApprovalInstanceStarted, RuleOptions{}, map[string]any{}},
{"oa_approval_instance_terminated", EventOAApprovalInstanceTerminated, RuleOptions{}, map[string]any{}},
{"oa_approval_instance_finished", EventOAApprovalInstanceFinished, RuleOptions{}, map[string]any{}},
{"read_group", EventReadGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
{"recall_group", EventRecallGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
{"reaction_group", EventReactionGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
+241 -2
View File
@@ -121,6 +121,94 @@ type GroupLifecycleEventOutput struct {
Payload map[string]any `json:"payload" description:"群生命周期事件业务数据,字段以服务端实际推送为准" additional_properties:"true"`
}
type OAApprovalTaskCreatedOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
TaskID string `json:"task_id" description:"审批任务 ID"`
Title string `json:"title" description:"审批标题"`
Status string `json:"status" description:"审批任务状态"`
CreateTime int64 `json:"create_time" description:"审批任务创建时间" format:"timestamp_ms"`
EventTime int64 `json:"event_time" description:"审批任务事件业务时间" format:"timestamp_ms"`
}
type OAApprovalTaskFinishedOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
TaskID string `json:"task_id" description:"审批任务 ID"`
Title string `json:"title" description:"审批标题"`
Status string `json:"status" description:"审批任务状态"`
Result string `json:"result" description:"审批任务处理结果,值以服务端实际推送为准"`
CreateTime int64 `json:"create_time" description:"审批任务创建时间" format:"timestamp_ms"`
FinishTime int64 `json:"finish_time" description:"审批任务完成时间" format:"timestamp_ms"`
EventTime int64 `json:"event_time" description:"审批任务事件业务时间" format:"timestamp_ms"`
}
type OAApprovalTaskRedirectedOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
TaskID string `json:"task_id" description:"原审批任务 ID"`
Title string `json:"title" description:"审批标题"`
Status string `json:"status" description:"原审批任务状态"`
Result string `json:"result" description:"审批任务转交结果,值以服务端实际推送为准"`
CreateTime int64 `json:"create_time" description:"原审批任务创建时间" format:"timestamp_ms"`
FinishTime int64 `json:"finish_time" description:"原审批任务转交完成时间" format:"timestamp_ms"`
EventTime int64 `json:"event_time" description:"审批任务转交事件业务时间" format:"timestamp_ms"`
}
type OAApprovalInstanceStartedOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
Title string `json:"title" description:"审批标题"`
Status string `json:"status" description:"审批实例状态"`
CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"`
EventTime int64 `json:"event_time" description:"审批实例事件业务时间" format:"timestamp_ms"`
}
type OAApprovalInstanceTerminatedOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
Title string `json:"title" description:"审批标题"`
Status string `json:"status" description:"审批实例状态"`
CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"`
FinishTime int64 `json:"finish_time" description:"审批实例终止时间" format:"timestamp_ms"`
EventTime int64 `json:"event_time" description:"审批实例终止事件业务时间" format:"timestamp_ms"`
}
type OAApprovalInstanceFinishedOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
Title string `json:"title" description:"审批标题"`
Status string `json:"status" description:"审批实例状态"`
Result string `json:"result" description:"审批实例处理结果,值以服务端实际推送为准"`
CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"`
FinishTime int64 `json:"finish_time" description:"审批实例完成时间" format:"timestamp_ms"`
EventTime int64 `json:"event_time" description:"审批实例事件业务时间" format:"timestamp_ms"`
}
type GroupMemberEventOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
@@ -230,6 +318,22 @@ type personalGroupMemberRecord struct {
OpenDingTalkID string `json:"openDingTalkId"`
}
type personalOAApprovalPayload struct {
EventTime int64 `json:"event_time"`
Body personalOAApprovalBody `json:"body"`
}
type personalOAApprovalBody struct {
ProcessInstanceID string `json:"processInstanceId"`
ProcessCode string `json:"processCode"`
TaskID string `json:"taskId"`
Title string `json:"title"`
Status string `json:"status"`
Result string `json:"result"`
CreateTime int64 `json:"createTime"`
FinishTime int64 `json:"finishTime"`
}
func (b *personalReactionBody) UnmarshalJSON(data []byte) error {
// encoding/json otherwise falls back to case-insensitive field matching.
// Read this protocol field from a map so only operOpenDingtalkId is accepted.
@@ -343,7 +447,7 @@ func ProjectOutput(ev transport.Event) (any, error) {
case isGroupMemberEvent(eventType):
return projectGroupMemberEvent(ev, base, data.Payload)
case isGroupLifecycleEvent(eventType):
payload, err := decodeGroupLifecyclePayload(data.Payload)
payload, err := decodeConservativePayload(data.Payload)
if err != nil {
return ev, fmt.Errorf("decode personal group lifecycle payload: %w", err)
}
@@ -354,6 +458,8 @@ func ProjectOutput(ev transport.Event) (any, error) {
SubscribeID: base.SubscribeID,
Payload: payload,
}, nil
case isOAEvent(eventType):
return projectOAApprovalEvent(ev, base, data.Payload)
default:
return ev, fmt.Errorf("unsupported personal event type %q", eventType)
}
@@ -370,7 +476,7 @@ func projectMessageEventContext(message personalMessageContext) MessageEventCont
}
}
func decodeGroupLifecyclePayload(raw json.RawMessage) (map[string]any, error) {
func decodeConservativePayload(raw json.RawMessage) (map[string]any, error) {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return nil, fmt.Errorf("payload is missing")
@@ -489,6 +595,112 @@ func projectGroupMemberEvent(ev transport.Event, base baseEventOutput, raw json.
}, nil
}
func projectOAApprovalEvent(ev transport.Event, base baseEventOutput, raw json.RawMessage) (any, error) {
var payload personalOAApprovalPayload
if err := decodeRequiredPayload(raw, &payload); err != nil {
return ev, fmt.Errorf("decode personal OA payload: %w", err)
}
if strings.TrimSpace(payload.Body.ProcessInstanceID) == "" {
return ev, fmt.Errorf("decode personal OA payload: processInstanceId is required")
}
if isOAApprovalTaskEvent(base.Type) && strings.TrimSpace(payload.Body.TaskID) == "" {
return ev, fmt.Errorf("decode personal OA payload: taskId is required for %s", base.Type)
}
switch base.Type {
case EventOAApprovalTaskCreated:
return OAApprovalTaskCreatedOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
ProcessInstanceID: payload.Body.ProcessInstanceID,
ProcessCode: payload.Body.ProcessCode,
TaskID: payload.Body.TaskID,
Title: payload.Body.Title,
Status: payload.Body.Status,
CreateTime: payload.Body.CreateTime,
EventTime: payload.EventTime,
}, nil
case EventOAApprovalTaskFinished:
return OAApprovalTaskFinishedOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
ProcessInstanceID: payload.Body.ProcessInstanceID,
ProcessCode: payload.Body.ProcessCode,
TaskID: payload.Body.TaskID,
Title: payload.Body.Title,
Status: payload.Body.Status,
Result: payload.Body.Result,
CreateTime: payload.Body.CreateTime,
FinishTime: payload.Body.FinishTime,
EventTime: payload.EventTime,
}, nil
case EventOAApprovalTaskRedirected:
return OAApprovalTaskRedirectedOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
ProcessInstanceID: payload.Body.ProcessInstanceID,
ProcessCode: payload.Body.ProcessCode,
TaskID: payload.Body.TaskID,
Title: payload.Body.Title,
Status: payload.Body.Status,
Result: payload.Body.Result,
CreateTime: payload.Body.CreateTime,
FinishTime: payload.Body.FinishTime,
EventTime: payload.EventTime,
}, nil
case EventOAApprovalInstanceStarted:
return OAApprovalInstanceStartedOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
ProcessInstanceID: payload.Body.ProcessInstanceID,
ProcessCode: payload.Body.ProcessCode,
Title: payload.Body.Title,
Status: payload.Body.Status,
CreateTime: payload.Body.CreateTime,
EventTime: payload.EventTime,
}, nil
case EventOAApprovalInstanceTerminated:
return OAApprovalInstanceTerminatedOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
ProcessInstanceID: payload.Body.ProcessInstanceID,
ProcessCode: payload.Body.ProcessCode,
Title: payload.Body.Title,
Status: payload.Body.Status,
CreateTime: payload.Body.CreateTime,
FinishTime: payload.Body.FinishTime,
EventTime: payload.EventTime,
}, nil
case EventOAApprovalInstanceFinished:
return OAApprovalInstanceFinishedOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
ProcessInstanceID: payload.Body.ProcessInstanceID,
ProcessCode: payload.Body.ProcessCode,
Title: payload.Body.Title,
Status: payload.Body.Status,
Result: payload.Body.Result,
CreateTime: payload.Body.CreateTime,
FinishTime: payload.Body.FinishTime,
EventTime: payload.EventTime,
}, nil
default:
return ev, fmt.Errorf("unsupported personal OA event type %q", base.Type)
}
}
func decodeRequiredPayload(raw json.RawMessage, target any) error {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
@@ -651,6 +863,18 @@ func outputTypeForEvent(eventKey string) reflect.Type {
return reflect.TypeOf(GroupMemberEventOutput{})
case isGroupLifecycleEvent(eventKey):
return reflect.TypeOf(GroupLifecycleEventOutput{})
case eventKey == EventOAApprovalTaskCreated:
return reflect.TypeOf(OAApprovalTaskCreatedOutput{})
case eventKey == EventOAApprovalTaskFinished:
return reflect.TypeOf(OAApprovalTaskFinishedOutput{})
case eventKey == EventOAApprovalTaskRedirected:
return reflect.TypeOf(OAApprovalTaskRedirectedOutput{})
case eventKey == EventOAApprovalInstanceStarted:
return reflect.TypeOf(OAApprovalInstanceStartedOutput{})
case eventKey == EventOAApprovalInstanceTerminated:
return reflect.TypeOf(OAApprovalInstanceTerminatedOutput{})
case eventKey == EventOAApprovalInstanceFinished:
return reflect.TypeOf(OAApprovalInstanceFinishedOutput{})
default:
return reflect.TypeOf(baseEventOutput{})
}
@@ -677,6 +901,21 @@ func isGroupLifecycleEvent(eventKey string) bool {
eventKey == EventGroupDisbanded
}
func isOAEvent(eventKey string) bool {
return eventKey == EventOAApprovalTaskCreated ||
eventKey == EventOAApprovalTaskFinished ||
eventKey == EventOAApprovalTaskRedirected ||
eventKey == EventOAApprovalInstanceStarted ||
eventKey == EventOAApprovalInstanceTerminated ||
eventKey == EventOAApprovalInstanceFinished
}
func isOAApprovalTaskEvent(eventKey string) bool {
return eventKey == EventOAApprovalTaskCreated ||
eventKey == EventOAApprovalTaskFinished ||
eventKey == EventOAApprovalTaskRedirected
}
func schemaType(t reflect.Type) string {
switch t.Kind() {
case reflect.String:
+304
View File
@@ -150,6 +150,59 @@ func personalGroupMemberData(eventKey string) string {
}`, eventKey)
}
func personalOAData(eventKey string) string {
body := map[string]any{
"processInstanceId": "process-instance-1",
"createTime": int64(1785229100000),
"processCode": "PROC-TEST-1",
"title": "测试审批",
}
switch eventKey {
case EventOAApprovalTaskCreated:
body["taskId"] = "approval-task-1"
body["status"] = "RUNNING"
case EventOAApprovalTaskFinished:
body["taskId"] = "approval-task-1"
body["status"] = "FINISHED"
body["result"] = "agree"
body["finishTime"] = int64(1785229199000)
case EventOAApprovalTaskRedirected:
body["taskId"] = "approval-task-1"
body["status"] = "FINISHED"
body["result"] = "redirect"
body["finishTime"] = int64(1785229199000)
case EventOAApprovalInstanceStarted:
body["status"] = "RUNNING"
case EventOAApprovalInstanceTerminated:
body["status"] = "TERMINATED"
body["finishTime"] = int64(1785229199000)
case EventOAApprovalInstanceFinished:
body["status"] = "FINISHED"
body["result"] = "agree"
body["finishTime"] = int64(1785229199000)
}
data := map[string]any{
"eventId": "oa-event",
"eventKey": eventKey,
"occurredAtMs": int64(1785229200123),
"subId": "oa-data-sub",
"payload": map[string]any{
"uid": 100001,
"CORPID": "internal-corp",
"clientId": "internal-client",
"filterSubId": "internal-filter",
"bizid": "internal-biz",
"orgId": 100002,
"sourceId": "open",
"body": body,
"event_time": int64(1785229199000),
"futureField": map[string]any{"nested": true},
},
}
encoded, _ := json.Marshal(data)
return string(encoded)
}
func TestCrossPlatformCoverageProjectOutputMessageEvents(t *testing.T) {
for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser, EventAllSingleChat, EventAllGroupChat} {
t.Run(eventKey, func(t *testing.T) {
@@ -396,6 +449,170 @@ func TestCrossPlatformCoverageProjectOutputGroupLifecycleEvents(t *testing.T) {
}
}
func TestCrossPlatformCoverageProjectOutputOAEvents(t *testing.T) {
tests := []struct {
eventKey string
want any
}{
{
eventKey: EventOAApprovalTaskCreated,
want: OAApprovalTaskCreatedOutput{
Type: EventOAApprovalTaskCreated,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
TaskID: "approval-task-1",
Title: "测试审批",
Status: "RUNNING",
CreateTime: 1785229100000,
EventTime: 1785229199000,
},
},
{
eventKey: EventOAApprovalTaskFinished,
want: OAApprovalTaskFinishedOutput{
Type: EventOAApprovalTaskFinished,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
TaskID: "approval-task-1",
Title: "测试审批",
Status: "FINISHED",
Result: "agree",
CreateTime: 1785229100000,
FinishTime: 1785229199000,
EventTime: 1785229199000,
},
},
{
eventKey: EventOAApprovalTaskRedirected,
want: OAApprovalTaskRedirectedOutput{
Type: EventOAApprovalTaskRedirected,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
TaskID: "approval-task-1",
Title: "测试审批",
Status: "FINISHED",
Result: "redirect",
CreateTime: 1785229100000,
FinishTime: 1785229199000,
EventTime: 1785229199000,
},
},
{
eventKey: EventOAApprovalInstanceStarted,
want: OAApprovalInstanceStartedOutput{
Type: EventOAApprovalInstanceStarted,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
Title: "测试审批",
Status: "RUNNING",
CreateTime: 1785229100000,
EventTime: 1785229199000,
},
},
{
eventKey: EventOAApprovalInstanceTerminated,
want: OAApprovalInstanceTerminatedOutput{
Type: EventOAApprovalInstanceTerminated,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
Title: "测试审批",
Status: "TERMINATED",
CreateTime: 1785229100000,
FinishTime: 1785229199000,
EventTime: 1785229199000,
},
},
{
eventKey: EventOAApprovalInstanceFinished,
want: OAApprovalInstanceFinishedOutput{
Type: EventOAApprovalInstanceFinished,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
Title: "测试审批",
Status: "FINISHED",
Result: "agree",
CreateTime: 1785229100000,
FinishTime: 1785229199000,
EventTime: 1785229199000,
},
},
}
for _, tt := range tests {
t.Run(tt.eventKey, func(t *testing.T) {
projected, err := ProjectOutput(transport.Event{
EventID: "outer-event",
EventBornTime: 11,
EventType: tt.eventKey,
SubscribeID: "outer-sub",
Data: personalOAData(tt.eventKey),
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
if !reflect.DeepEqual(projected, tt.want) {
t.Fatalf("ProjectOutput() = %#v, want %#v", projected, tt.want)
}
assertNoInternalActionFields(t, projected)
})
}
}
func TestCrossPlatformCoverageProjectOutputRejectsUnsupportedOAType(t *testing.T) {
ev := transport.Event{EventID: "outer-event", EventType: "user_oa_approval_unknown"}
projected, err := projectOAApprovalEvent(
ev,
baseEventOutput{Type: ev.EventType, EventID: ev.EventID},
json.RawMessage(`{"body":{"processInstanceId":"process-instance-1"},"event_time":1}`),
)
if err == nil || !strings.Contains(err.Error(), `unsupported personal OA event type "user_oa_approval_unknown"`) {
t.Fatalf("projectOAApprovalEvent() error = %v", err)
}
if got, ok := projected.(transport.Event); !ok || !reflect.DeepEqual(got, ev) {
t.Fatalf("projectOAApprovalEvent() fallback = %#v, want %#v", projected, ev)
}
}
func TestCrossPlatformCoverageProjectOutputOADecodesDoublyWrappedJSONString(t *testing.T) {
once, err := json.Marshal(personalOAData(EventOAApprovalTaskCreated))
if err != nil {
t.Fatal(err)
}
twice, err := json.Marshal(string(once))
if err != nil {
t.Fatal(err)
}
projected, err := ProjectOutput(transport.Event{Data: string(twice)})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got, ok := projected.(OAApprovalTaskCreatedOutput)
if !ok {
t.Fatalf("ProjectOutput() type = %T, want OAApprovalTaskCreatedOutput", projected)
}
if got.Type != EventOAApprovalTaskCreated || got.EventID != "oa-event" || got.SubscribeID != "oa-data-sub" {
t.Fatalf("ProjectOutput() = %#v", got)
}
}
func TestCrossPlatformCoverageProjectOutputGroupMemberEvents(t *testing.T) {
for _, eventKey := range []string{EventGroupMemberAdded, EventGroupMemberExited} {
t.Run(eventKey, func(t *testing.T) {
@@ -549,6 +766,93 @@ func TestCrossPlatformCoverageProjectOutputRejectsInvalidGroupLifecyclePayloads(
}
}
func TestCrossPlatformCoverageProjectOutputRejectsInvalidOAPayloads(t *testing.T) {
tests := []struct {
name string
payload string
}{
{name: "missing"},
{name: "null", payload: `,"payload":null`},
{name: "empty object", payload: `,"payload":{}`},
{name: "array", payload: `,"payload":[]`},
{name: "string", payload: `,"payload":"invalid"`},
{name: "missing body", payload: `,"payload":{"event_time":1}`},
{name: "null body", payload: `,"payload":{"body":null,"event_time":1}`},
{name: "empty body", payload: `,"payload":{"body":{},"event_time":1}`},
}
for _, eventKey := range []string{
EventOAApprovalTaskCreated,
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
} {
for _, tt := range tests {
t.Run(eventKey+"/"+tt.name, func(t *testing.T) {
ev := transport.Event{
EventID: "outer-event",
EventType: eventKey,
Data: fmt.Sprintf(`{"eventKey":%q%s}`, eventKey, tt.payload),
}
projected, err := ProjectOutput(ev)
if err == nil {
t.Fatal("ProjectOutput() error = nil, want OA payload validation error")
}
if !strings.Contains(err.Error(), "decode personal OA payload") {
t.Fatalf("ProjectOutput() error = %v, want OA payload context", err)
}
got, ok := projected.(transport.Event)
if !ok || !reflect.DeepEqual(got, ev) {
t.Fatalf("ProjectOutput() fallback = %#v, want %#v", projected, ev)
}
})
}
}
}
func TestCrossPlatformCoverageProjectOutputRejectsOAWithoutStableIDs(t *testing.T) {
tests := []struct {
name string
eventKey string
body string
want string
}{
{
name: "missing process instance",
eventKey: EventOAApprovalInstanceStarted,
body: `{"status":"RUNNING"}`,
want: "processInstanceId is required",
},
{
name: "missing task",
eventKey: EventOAApprovalTaskCreated,
body: `{"processInstanceId":"process-instance-1","status":"RUNNING"}`,
want: "taskId is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ev := transport.Event{
EventID: "outer-event",
EventType: tt.eventKey,
Data: fmt.Sprintf(
`{"eventKey":%q,"payload":{"body":%s,"event_time":1}}`,
tt.eventKey,
tt.body,
),
}
projected, err := ProjectOutput(ev)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("ProjectOutput() error = %v, want %q", err, tt.want)
}
if got, ok := projected.(transport.Event); !ok || !reflect.DeepEqual(got, ev) {
t.Fatalf("ProjectOutput() fallback = %#v, want %#v", projected, ev)
}
})
}
}
func TestCrossPlatformCoverageProjectOutputDecodesWrappedJSONString(t *testing.T) {
wrapped, err := json.Marshal(personalMessageData(EventSingleChat))
if err != nil {
+88 -16
View File
@@ -23,22 +23,28 @@ import (
)
const (
EventMention = "user_im_message_receive_at"
EventSingleChat = "user_im_message_receive_o2o"
EventInChat = "user_im_message_receive_group"
EventFromUser = "user_im_message_receive_user"
EventAllSingleChat = "user_im_message_receive_o2o_all"
EventAllGroupChat = "user_im_message_receive_group_all"
EventReadO2O = "user_im_message_read_o2o"
EventReadGroup = "user_im_message_read_group"
EventRecallO2O = "user_im_message_recall_o2o"
EventRecallGroup = "user_im_message_recall_group"
EventReactionO2O = "user_im_message_reaction_o2o"
EventReactionGroup = "user_im_message_reaction_group"
EventGroupUpdated = "user_im_group_updated"
EventGroupMemberAdded = "user_im_group_member_added"
EventGroupMemberExited = "user_im_group_member_exited"
EventGroupDisbanded = "user_im_group_disbanded"
EventMention = "user_im_message_receive_at"
EventSingleChat = "user_im_message_receive_o2o"
EventInChat = "user_im_message_receive_group"
EventFromUser = "user_im_message_receive_user"
EventAllSingleChat = "user_im_message_receive_o2o_all"
EventAllGroupChat = "user_im_message_receive_group_all"
EventReadO2O = "user_im_message_read_o2o"
EventReadGroup = "user_im_message_read_group"
EventRecallO2O = "user_im_message_recall_o2o"
EventRecallGroup = "user_im_message_recall_group"
EventReactionO2O = "user_im_message_reaction_o2o"
EventReactionGroup = "user_im_message_reaction_group"
EventGroupUpdated = "user_im_group_updated"
EventGroupMemberAdded = "user_im_group_member_added"
EventGroupMemberExited = "user_im_group_member_exited"
EventGroupDisbanded = "user_im_group_disbanded"
EventOAApprovalTaskCreated = "user_oa_approval_task_created"
EventOAApprovalTaskFinished = "user_oa_approval_task_finished"
EventOAApprovalTaskRedirected = "user_oa_approval_task_redirected"
EventOAApprovalInstanceStarted = "user_oa_approval_instance_started"
EventOAApprovalInstanceTerminated = "user_oa_approval_instance_terminated"
EventOAApprovalInstanceFinished = "user_oa_approval_instance_finished"
)
const (
@@ -273,6 +279,72 @@ var definitions = []Definition{
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventOAApprovalTaskCreated,
DisplayName: "审批任务创建",
Description: "审批任务创建,发送给审批人",
Category: "oa",
RuleType: "all",
Status: StatusEnabled,
RequiredParams: nil,
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventOAApprovalTaskFinished,
DisplayName: "审批任务完成",
Description: "审批任务已完成",
Category: "oa",
RuleType: "all",
Status: StatusEnabled,
RequiredParams: nil,
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventOAApprovalTaskRedirected,
DisplayName: "审批人任务转交",
Description: "审批任务已转交",
Category: "oa",
RuleType: "all",
Status: StatusEnabled,
RequiredParams: nil,
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventOAApprovalInstanceStarted,
DisplayName: "发起审批单",
Description: "审批实例已发起",
Category: "oa",
RuleType: "all",
Status: StatusEnabled,
RequiredParams: nil,
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventOAApprovalInstanceTerminated,
DisplayName: "审批单终止",
Description: "审批实例已终止",
Category: "oa",
RuleType: "all",
Status: StatusEnabled,
RequiredParams: nil,
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventOAApprovalInstanceFinished,
DisplayName: "审批单完成",
Description: "审批实例完成,发送给审批单发起人",
Category: "oa",
RuleType: "all",
Status: StatusEnabled,
RequiredParams: nil,
Auth: map[string]any{"identity": "user"},
Public: true,
},
}
func targetUIDConstraints() *ParameterConstraints {
+147 -1
View File
@@ -46,12 +46,47 @@ func TestCatalogEnabledEvents(t *testing.T) {
EventGroupMemberAdded,
EventGroupMemberExited,
EventGroupDisbanded,
EventOAApprovalTaskCreated,
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
}
if !reflect.DeepEqual(keys, want) {
t.Fatalf("keys = %#v, want %#v", keys, want)
}
}
func TestOAEventCatalogDefinitions(t *testing.T) {
items := Catalog("oa", true, false)
wantKeys := []string{
EventOAApprovalTaskCreated,
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
}
if len(items) != len(wantKeys) {
t.Fatalf("Catalog(oa) = %#v, want %d events", items, len(wantKeys))
}
for i, item := range items {
if item.EventKey != wantKeys[i] {
t.Fatalf("Catalog(oa)[%d].event_key = %q, want %q", i, item.EventKey, wantKeys[i])
}
if item.Category != "oa" || item.RuleType != "all" || item.Status != StatusEnabled || !item.Public {
t.Fatalf("Catalog(oa)[%d] = %#v, want public enabled oa/all event", i, item)
}
if len(item.RequiredParams) != 0 || item.Constraints != nil {
t.Fatalf("Catalog(oa)[%d] parameters = %#v/%#v, want none", i, item.RequiredParams, item.Constraints)
}
if item.Auth["identity"] != "user" {
t.Fatalf("Catalog(oa)[%d].auth = %#v, want user identity", i, item.Auth)
}
}
}
func TestEventFromUserIsPublic(t *testing.T) {
if _, ok := Lookup(EventFromUser); !ok {
t.Fatalf("Lookup(%q) failed", EventFromUser)
@@ -119,6 +154,12 @@ func TestSchemaDocumentsDefaultToTransportEnvelope(t *testing.T) {
EventGroupMemberAdded,
EventGroupMemberExited,
EventGroupDisbanded,
EventOAApprovalTaskCreated,
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
} {
t.Run(eventKey, func(t *testing.T) {
def, ok := Lookup(eventKey)
@@ -433,6 +474,96 @@ func TestGroupLifecycleSchemaDocumentsUseConservativePayload(t *testing.T) {
}
}
func TestOAEventSchemaDocumentsMatchOutputDTO(t *testing.T) {
tests := []struct {
eventKey string
properties []string
}{
{
eventKey: EventOAApprovalTaskCreated,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "task_id", "title", "status", "create_time", "event_time",
},
},
{
eventKey: EventOAApprovalTaskFinished,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "task_id", "title", "status", "result", "create_time",
"finish_time", "event_time",
},
},
{
eventKey: EventOAApprovalTaskRedirected,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "task_id", "title", "status", "result", "create_time",
"finish_time", "event_time",
},
},
{
eventKey: EventOAApprovalInstanceStarted,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "title", "status", "create_time", "event_time",
},
},
{
eventKey: EventOAApprovalInstanceTerminated,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "title", "status", "create_time", "finish_time", "event_time",
},
},
{
eventKey: EventOAApprovalInstanceFinished,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "title", "status", "result", "create_time", "finish_time",
"event_time",
},
},
}
for _, tt := range tests {
t.Run(tt.eventKey, func(t *testing.T) {
def, ok := Lookup(tt.eventKey)
if !ok {
t.Fatalf("Lookup(%q) failed", tt.eventKey)
}
doc := BuildSchemaDocumentForMode(def, true)
if doc.JQRootPath != "." {
t.Fatalf("jq_root_path = %q, want .", doc.JQRootPath)
}
props, ok := doc.Schema["properties"].(map[string]any)
if !ok || len(props) != len(tt.properties) {
t.Fatalf("schema.properties = %#v, want exactly %d fields", doc.Schema["properties"], len(tt.properties))
}
for _, name := range tt.properties {
if _, ok := props[name].(map[string]any); !ok {
t.Fatalf("schema.properties.%s = %#v, want object", name, props[name])
}
}
eventType := props["type"].(map[string]any)
if !reflect.DeepEqual(eventType["enum"], []string{tt.eventKey}) {
t.Fatalf("schema.properties.type.enum = %#v, want %q", eventType["enum"], tt.eventKey)
}
if _, ok := props["payload"]; ok {
t.Fatalf("schema.properties exposed generic payload: %#v", props)
}
for _, name := range []string{"timestamp", "create_time", "finish_time", "event_time"} {
property, exists := props[name].(map[string]any)
if !exists {
continue
}
if property["type"] != "integer" || property["format"] != "timestamp_ms" {
t.Fatalf("schema.properties.%s = %#v, want timestamp_ms integer", name, property)
}
}
})
}
}
func TestGroupMemberSchemaDocumentsMatchOutputDTO(t *testing.T) {
wantProperties := []string{
"type", "event_id", "timestamp", "subscribe_id", "conversation_id",
@@ -495,7 +626,16 @@ func TestBuildRuleParamMention(t *testing.T) {
}
func TestBuildRuleParamAllEvents(t *testing.T) {
for _, eventKey := range []string{EventAllSingleChat, EventAllGroupChat} {
for _, eventKey := range []string{
EventAllSingleChat,
EventAllGroupChat,
EventOAApprovalTaskCreated,
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
} {
t.Run(eventKey, func(t *testing.T) {
rule, param, err := BuildRuleParam(eventKey, RuleOptions{})
if err != nil {
@@ -701,6 +841,12 @@ func TestSupportsMessageFilter(t *testing.T) {
EventReadO2O,
EventReactionGroup,
EventGroupUpdated,
EventOAApprovalTaskCreated,
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
"unknown_event",
} {
if SupportsMessageFilter(eventKey) {
+178 -2
View File
@@ -16,6 +16,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/paging"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
@@ -125,6 +126,70 @@ func resolveWorkflowDSL(cmd *cobra.Command) (map[string]any, error) {
return dsl, nil
}
func validateWorkflowRunFlags(cmd *cobra.Command, _ []string) error {
tableID, _ := cmd.Flags().GetString("table-id")
tableID = strings.TrimSpace(tableID)
recordIDs, _ := cmd.Flags().GetStringSlice("record-ids")
cleaned := make([]string, 0, len(recordIDs))
seen := make(map[string]struct{}, len(recordIDs))
for _, recordID := range recordIDs {
recordID = strings.TrimSpace(recordID)
if recordID == "" {
continue
}
if _, ok := seen[recordID]; ok {
return apperrors.NewValidation(fmt.Sprintf("--record-ids 不能包含重复值 %q", recordID))
}
seen[recordID] = struct{}{}
cleaned = append(cleaned, recordID)
}
if cmd.Flags().Changed("table-id") && tableID == "" {
return apperrors.NewValidation("--table-id 不能为空")
}
if cmd.Flags().Changed("record-ids") && len(cleaned) == 0 {
return apperrors.NewValidation("--record-ids 必须包含 1 到 5 个非空记录 ID")
}
if len(cleaned) > 5 {
return apperrors.NewValidation(fmt.Sprintf("--record-ids 最多支持 5 个记录 ID,got %d", len(cleaned)))
}
if (tableID != "") != (len(cleaned) > 0) {
return apperrors.NewValidation("--table-id 与 --record-ids 必须同时提供;定时触发工作流则两者都不传")
}
return nil
}
func validateWorkflowHistoryFlags(cmd *cobra.Command, _ []string) error {
if cmd.Flags().Changed("page") {
page, _ := cmd.Flags().GetInt("page")
if page < 0 {
return apperrors.NewValidation(fmt.Sprintf("--page 必须 >= 0,got %d", page))
}
}
if cmd.Flags().Changed("size") {
size, _ := cmd.Flags().GetInt("size")
if size < 1 || size > 100 {
return apperrors.NewValidation(fmt.Sprintf("--size 必须在 [1, 100] 范围内,got %d", size))
}
}
for _, name := range []string{"after-time", "before-time"} {
if !cmd.Flags().Changed(name) {
continue
}
value, _ := cmd.Flags().GetInt(name)
if value < 0 {
return apperrors.NewValidation(fmt.Sprintf("--%s 必须是 >= 0 的 Unix 毫秒时间戳,got %d", name, value))
}
}
if cmd.Flags().Changed("after-time") && cmd.Flags().Changed("before-time") {
afterTime, _ := cmd.Flags().GetInt("after-time")
beforeTime, _ := cmd.Flags().GetInt("before-time")
if afterTime >= beforeTime {
return apperrors.NewValidation(fmt.Sprintf("--after-time 必须小于 --before-time,got %d >= %d", afterTime, beforeTime))
}
}
return nil
}
// recordQueryFetchAll implements --all auto-pagination for record query.
// It prints only a complete result. A page limit, empty/invalid response,
// transport failure, or cursor cycle returns a non-zero structured error whose
@@ -1038,7 +1103,7 @@ func newAitableCommand() *cobra.Command {
dws aitable form [list|delete|update] 表单管理
dws aitable form field [list|update|hide] 表单字段管理
dws aitable form share [get|update|notify] 表单分享管理
dws aitable workflow [edit-example|create|update|enable|disable|get|list] 自动化工作流管理
dws aitable workflow [edit-example|create|update|enable|disable|run|history|get|list] 自动化工作流管理
dws aitable dashboard [get|create|update|delete|config-example] 仪表盘管理
dws aitable chart [get|create|update|delete|widgets-example] 图表管理
dws aitable export data 数据导出
@@ -4878,7 +4943,7 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
workflowCmd := &cobra.Command{
Use: "workflow",
Short: "自动化工作流管理(创建 / 更新 / 启停 / 查看 / 列表)",
Short: "自动化工作流管理(创建 / 更新 / 启停 / 执行 / 历史 / 查询)",
RunE: groupRunE,
}
@@ -5223,6 +5288,116 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
},
})
workflowRunCmd := NewLeafCommand(LeafSpec{
Use: "run",
Short: "执行指定自动化工作流",
Long: `立即执行指定 Base 中的自动化工作流。此命令会启动真实的异步执行,并可能产生该工作流配置的消息发送、记录写入等副作用,因此执行前需要确认;CLI 不自动重试。
记录类触发器必须同时提供 --table-id 与 --record-ids;--table-id 必须与触发器绑定的数据表一致,--record-ids 接受 1 到 5 个不重复记录 ID。定时触发器不传这两个参数。
返回每条记录的提交状态;提交成功项包含 executionId,可用 workflow history 返回项的 instanceId 匹配执行记录。`,
Example: ` dws aitable workflow run --base-id BASE_ID --workflow-id WORKFLOW_ID --table-id TABLE_ID --record-ids RECORD_ID_1,RECORD_ID_2
dws aitable workflow run --base-id BASE_ID --workflow-id WORKFLOW_ID`,
Tool: "run_workflow",
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "non_idempotent",
},
Validate: validateWorkflowRunFlags,
Flags: []LeafFlag{
{Name: "base-id", Usage: "目标 Base ID (必填)", Bind: "baseId", Trim: true, Required: true, Aliases: []string{"base"}},
{Name: "workflow-id", Usage: "目标工作流 ID (必填)", Bind: "workflowId", Trim: true, Required: true},
{Name: "table-id", Usage: "记录类触发器绑定的 Table ID;定时触发器不传", Bind: "tableId", Trim: true, OmitEmpty: true, RequiredWhen: "record-ids is provided or the workflow uses a record-based trigger"},
{Name: "record-ids", Usage: "触发工作流的记录 ID,逗号分隔;记录类触发器必填,1 到 5 个且不可重复", Kind: LeafStringSlice, Bind: "recordIds", RequiredWhen: "table-id is provided or the workflow uses a record-based trigger"},
},
Constraints: []LeafConstraint{{
Kind: corecmd.Custom,
Flags: []string{"table-id", "record-ids"},
Description: "记录类触发器必须同时提供 --table-id 与 --record-ids;定时触发器两者都不传",
}},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "workflow_run",
CanonicalPath: "aitable.workflow_run",
CLIPath: "aitable workflow run",
PrimaryCLIPath: "aitable workflow run",
},
Description: "立即执行 AI 表格自动化工作流,并返回异步执行提交结果。",
Interface: aitableMCPInterface("run_workflow"),
Selection: contract.SelectionSpec{
AgentSummary: "立即执行已知 AI 表格自动化工作流,并获取 executionId。",
UseWhen: []string{"用户明确要求立即执行已知工作流,已确认真实 base-id、workflow-id、触发类型及可能产生的业务副作用;记录类触发器还需确认绑定的 table-id 和 1 到 5 个真实 record-id"},
AvoidWhen: []string{"仅开启后续自动触发用 workflow enable;查询工作流定义用 workflow get;查询既有执行结果用 workflow history;返回 executionId 后应以 history 的 instanceId 核对,不要在结果不确定时直接重复执行"},
Examples: []string{
"dws aitable workflow run --base-id <BASE_ID> --workflow-id <WORKFLOW_ID> --table-id <TABLE_ID> --record-ids <RECORD_ID>",
"dws aitable workflow run --base-id <BASE_ID> --workflow-id <WORKFLOW_ID>",
},
},
Parameters: []contract.ParamDecl{
{Name: "base-id", Property: "baseId", Required: boolPtr(true), InterfaceType: "string"},
{Name: "workflow-id", Property: "workflowId", Required: boolPtr(true), InterfaceType: "string"},
{Name: "table-id", Property: "tableId", InterfaceType: "string", RequiredWhen: "record-ids is provided or the workflow uses a record-based trigger"},
{Name: "record-ids", Property: "recordIds", InterfaceType: "array", RequiredWhen: "table-id is provided or the workflow uses a record-based trigger"},
},
},
})
workflowHistoryCmd := NewLeafCommand(LeafSpec{
Use: "history",
Short: "查询工作流执行历史",
Long: `分页查询指定 AI 表格工作流的执行历史。
可按状态和 Unix 毫秒时间范围筛选;同时提供 --after-time 与 --before-time 时,前者必须小于后者。--page 从 0 开始,--size 默认 20、最大 100。
返回 totalCount 与 list;run 返回的 executionId 可与历史项 instanceId 匹配。`,
Example: ` dws aitable workflow history --base-id BASE_ID --workflow-id WORKFLOW_ID
dws aitable workflow history --base-id BASE_ID --workflow-id WORKFLOW_ID --status failed --after-time 1786000000000 --before-time 1787000000000 --page 0 --size 50`,
Tool: "get_flow_record_list",
Safety: aitableSafetyRead(),
Validate: validateWorkflowHistoryFlags,
Flags: []LeafFlag{
{Name: "base-id", Usage: "目标 Base ID (必填)", Bind: "baseId", Trim: true, Required: true, Aliases: []string{"base"}},
{Name: "workflow-id", Usage: "目标工作流 ID (必填)", Bind: "flowId", Trim: true, Required: true},
{Name: "status", Usage: "执行状态筛选;不传表示全部", Bind: "status", Trim: true, OmitEmpty: true, Enum: []string{"success", "failed", "running", "break", "untrigger"}},
{Name: "after-time", Usage: "开始时间(Unix 毫秒)", Kind: LeafInt, Bind: "afterTime"},
{Name: "before-time", Usage: "结束时间(Unix 毫秒)", Kind: LeafInt, Bind: "beforeTime"},
{Name: "page", Usage: "页码,从 0 开始", Kind: LeafInt, Default: "0", Bind: "page"},
{Name: "size", Usage: "每页条数 [1, 100]", Kind: LeafInt, Default: "20", Bind: "size"},
},
Constraints: []LeafConstraint{{
Kind: corecmd.Custom,
Flags: []string{"after-time", "before-time"},
Description: "同时提供 --after-time 与 --before-time 时,--after-time 必须小于 --before-time",
}},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "workflow_history",
CanonicalPath: "aitable.workflow_history",
CLIPath: "aitable workflow history",
PrimaryCLIPath: "aitable workflow history",
},
Description: "分页查询 AI 表格自动化工作流执行历史。",
Interface: aitableMCPInterface("get_flow_record_list"),
Selection: contract.SelectionSpec{
AgentSummary: "按状态、时间和分页条件查询工作流执行历史。",
UseWhen: []string{"需要核对工作流是否执行、执行结果或定位 run 返回的 executionId 时;executionId 与历史项 instanceId 相同,running 为非终态"},
AvoidWhen: []string{"查询工作流定义用 workflow get;列出工作流用 workflow list;立即发起执行用 workflow run"},
Examples: []string{
"dws aitable workflow history --base-id <BASE_ID> --workflow-id <WORKFLOW_ID>",
"dws aitable workflow history --base-id <BASE_ID> --workflow-id <WORKFLOW_ID> --status failed --page 0 --size 50",
},
},
Parameters: []contract.ParamDecl{
{Name: "base-id", Property: "baseId", Required: boolPtr(true), InterfaceType: "string"},
{Name: "workflow-id", Property: "flowId", Required: boolPtr(true), InterfaceType: "string"},
{Name: "status", Property: "status", InterfaceType: "string", Enum: []string{"success", "failed", "running", "break", "untrigger"}},
{Name: "after-time", Property: "afterTime", InterfaceType: "number"},
{Name: "before-time", Property: "beforeTime", InterfaceType: "number"},
{Name: "page", Property: "page", InterfaceType: "number"},
{Name: "size", Property: "size", InterfaceType: "number"},
},
},
})
// ── dashboard: 仪表盘管理 ────────────────────────────────────
dashboardCmd := &cobra.Command{Use: "dashboard", Short: "仪表盘管理", RunE: groupRunE}
@@ -7424,6 +7599,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
workflowCmd.AddCommand(
workflowEditExampleCmd, workflowCreateCmd, workflowUpdateCmd,
workflowEnableCmd, workflowDisableCmd,
workflowRunCmd, workflowHistoryCmd,
workflowGetCmd, workflowListCmd,
)
+162 -9
View File
@@ -12,6 +12,7 @@ import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -40,26 +41,23 @@ func (*aitableWorkflowCaller) JQ() string { return "" }
func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*aitableWorkflowCaller, error) {
t.Helper()
previousDeps := deps
previousArgs := os.Args
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
})
testseam.Protect(t, &os.Args)
caller := &aitableWorkflowCaller{}
InitDeps(caller)
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
os.Args = append([]string{"dws", "aitable", "workflow"}, args...)
cmd := newAitableCommand()
cmd.PersistentFlags().String("format", "json", "output format")
cmd.PersistentFlags().Bool("yes", false, "skip confirmation")
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs(append([]string{"workflow"}, args...))
if stdin != nil {
cmd.SetIn(stdin)
if stdin == nil {
stdin = strings.NewReader("")
}
cmd.SetIn(stdin)
return caller, cmd.Execute()
}
@@ -197,3 +195,158 @@ func TestAitableWorkflowWriteRejectsInvalidInput(t *testing.T) {
})
}
}
func TestCrossPlatformCoverageAitableWorkflowRunMapsRecordTrigger(t *testing.T) {
caller, err := runAitableWorkflowCommand(t, nil,
"run",
"--base-id", "base-run",
"--workflow-id", "workflow-run",
"--table-id", "table-run",
"--record-ids", "record-1,record-2",
"--yes",
)
if err != nil {
t.Fatalf("workflow run returned error: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
}
call := caller.calls[0]
if call.productID != "aitable" || call.toolName != "run_workflow" {
t.Fatalf("tool call = %s/%s, want aitable/run_workflow", call.productID, call.toolName)
}
wantArgs := map[string]any{
"baseId": "base-run",
"workflowId": "workflow-run",
"tableId": "table-run",
"recordIds": []string{"record-1", "record-2"},
}
if !reflect.DeepEqual(call.args, wantArgs) {
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
}
}
func TestCrossPlatformCoverageAitableWorkflowRunMapsScheduledTrigger(t *testing.T) {
caller, err := runAitableWorkflowCommand(t, nil,
"run", "--base", "base-scheduled", "--workflow-id", "workflow-scheduled", "--yes",
)
if err != nil {
t.Fatalf("scheduled workflow run returned error: %v", err)
}
wantArgs := map[string]any{
"baseId": "base-scheduled",
"workflowId": "workflow-scheduled",
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].args, wantArgs) {
t.Fatalf("calls = %#v, want one scheduled invocation %#v", caller.calls, wantArgs)
}
}
func TestCrossPlatformCoverageAitableWorkflowRunRejectsUnsafeOrInvalidInput(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "confirmation", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow"}, want: "用户确认"},
{name: "blank table", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", " ", "--yes"}, want: "--table-id 不能为空"},
{name: "blank records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--record-ids", " , ", "--yes"}, want: "--record-ids 必须包含"},
{name: "table without records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", "table", "--yes"}, want: "必须同时提供"},
{name: "records without table", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--record-ids", "record", "--yes"}, want: "必须同时提供"},
{name: "duplicate records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", "table", "--record-ids", "record,record", "--yes"}, want: "不能包含重复值"},
{name: "too many records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", "table", "--record-ids", "r1,r2,r3,r4,r5,r6", "--yes"}, want: "最多支持 5 个"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller, err := runAitableWorkflowCommand(t, nil, tc.args...)
if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tc.want)) {
t.Fatalf("error = %v, want substring %q", err, tc.want)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid run reached MCP: %#v", caller.calls)
}
})
}
}
func TestCrossPlatformCoverageAitableWorkflowHistoryMapsFilters(t *testing.T) {
caller, err := runAitableWorkflowCommand(t, nil,
"history",
"--base-id", "base-history",
"--workflow-id", "workflow-history",
"--status", "failed",
"--after-time", "1786000000000",
"--before-time", "1787000000000",
"--page", "2",
"--size", "50",
)
if err != nil {
t.Fatalf("workflow history returned error: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
}
call := caller.calls[0]
if call.productID != "aitable" || call.toolName != "get_flow_record_list" {
t.Fatalf("tool call = %s/%s, want aitable/get_flow_record_list", call.productID, call.toolName)
}
wantArgs := map[string]any{
"baseId": "base-history",
"flowId": "workflow-history",
"status": "failed",
"afterTime": 1786000000000,
"beforeTime": 1787000000000,
"page": 2,
"size": 50,
}
if !reflect.DeepEqual(call.args, wantArgs) {
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
}
}
func TestCrossPlatformCoverageAitableWorkflowHistoryMapsSingleTimeFilter(t *testing.T) {
caller, err := runAitableWorkflowCommand(t, nil,
"history",
"--base-id", "base-history",
"--workflow-id", "workflow-history",
"--after-time", "1786000000000",
)
if err != nil {
t.Fatalf("workflow history returned error: %v", err)
}
wantArgs := map[string]any{
"baseId": "base-history",
"flowId": "workflow-history",
"afterTime": 1786000000000,
"size": 20,
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].args, wantArgs) {
t.Fatalf("calls = %#v, want one history invocation %#v", caller.calls, wantArgs)
}
}
func TestCrossPlatformCoverageAitableWorkflowHistoryRejectsInvalidFilters(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "status", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--status", "unknown"}, want: "允许值"},
{name: "negative page", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--page", "-1"}, want: "--page 必须 >= 0"},
{name: "zero size", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--size", "0"}, want: "--size 必须在"},
{name: "large size", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--size", "101"}, want: "--size 必须在"},
{name: "negative after", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--after-time", "-1"}, want: "Unix 毫秒时间戳"},
{name: "reversed range", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--after-time", "200", "--before-time", "100"}, want: "必须小于"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller, err := runAitableWorkflowCommand(t, nil, tc.args...)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want substring %q", err, tc.want)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid history query reached MCP: %#v", caller.calls)
}
})
}
}
+763 -268
View File
File diff suppressed because it is too large Load Diff
+163
View File
@@ -0,0 +1,163 @@
// 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 helpers
import (
"errors"
"reflect"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
func runNativeCardUpdate(t *testing.T, caller *scriptedToolCaller, args ...string) error {
t.Helper()
installScriptedCaller(t, caller)
root := newChatCommand()
root.SilenceErrors = true
root.SilenceUsage = true
if root.PersistentFlags().Lookup("dry-run") == nil {
root.PersistentFlags().Bool("dry-run", false, "preview without executing")
}
if root.PersistentFlags().Lookup("yes") == nil {
root.PersistentFlags().Bool("yes", false, "skip confirmation")
}
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageNativeMessageUpdateCardVerifiesWrite(t *testing.T) {
t.Run("atomic command preserves no-extra-confirmation contract", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{"bizId":"biz-confirm","updated":true}}`}}}
if err := runNativeCardUpdate(t, caller,
"message", "update-card",
"--biz-id", "biz-confirm",
"--content", "原子更新",
"--flow-status", "3",
); err != nil {
t.Fatal(err)
}
wantArgs := map[string]any{
"bizId": "biz-confirm",
"msgContent": "原子更新",
"flowStatus": 3,
}
if caller.calls != 1 || caller.server != "im" || caller.tool != "update_streaming_card" || !reflect.DeepEqual(caller.args, wantArgs) {
t.Fatalf("atomic call = count:%d server:%q tool:%q args:%#v", caller.calls, caller.server, caller.tool, caller.args)
}
})
t.Run("explicit evidence succeeds", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{"bizId":"biz-1","updated":true}}`}}}
err := runNativeCardUpdate(t, caller,
"message", "update-card",
"--biz-id", "biz-1",
"--content", "完成",
"--flow-status", "3",
)
if err != nil {
t.Fatal(err)
}
if caller.calls != 1 || caller.server != "im" || caller.tool != "update_streaming_card" {
t.Fatalf("call = count:%d server:%q tool:%q", caller.calls, caller.server, caller.tool)
}
if caller.args["bizId"] != "biz-1" {
t.Fatalf("args = %#v", caller.args)
}
})
t.Run("generic success is unverified", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true,"errorCode":null}`}}}
err := runNativeCardUpdate(t, caller,
"message", "update-card",
"--biz-id", "not-a-real-card",
"--content", "完成",
"--flow-status", "3",
)
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "streaming_card_update_unverified" {
t.Fatalf("error = %#v, want streaming_card_update_unverified", err)
}
})
t.Run("lower write error is preserved", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("write unavailable")}}}
err := runNativeCardUpdate(t, caller,
"message", "update-card",
"--biz-id", "biz-1",
"--content", "完成",
"--flow-status", "3",
)
if err == nil {
t.Fatal("lower write error was ignored")
}
})
for _, test := range []struct {
name string
response string
wantReason string
}{
{name: "empty response", response: "", wantReason: "streaming_card_update_unverified"},
{name: "invalid response", response: "{", wantReason: "streaming_card_update_response_invalid"},
{name: "not applied", response: `{"result":{"updated":false}}`, wantReason: "streaming_card_update_not_applied"},
{name: "biz id drift", response: `{"result":{"bizId":"biz-other","updated":true}}`, wantReason: "streaming_card_update_biz_id_mismatch"},
} {
t.Run(test.name, func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: test.response}}}
err := runNativeCardUpdate(t, caller,
"message", "update-card",
"--biz-id", "biz-1",
"--content", "完成",
"--flow-status", "3",
)
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != test.wantReason {
t.Fatalf("error = %#v, want reason %q", err, test.wantReason)
}
})
}
t.Run("invalid arguments make no call", func(t *testing.T) {
for _, args := range [][]string{
{"message", "update-card", "--biz-id", "<bizId>", "--content", "完成", "--flow-status", "3"},
{"message", "update-card", "--biz-id", "biz-1", "--content", "完成", "--flow-status", "6"},
} {
caller := &scriptedToolCaller{}
if err := runNativeCardUpdate(t, caller, args...); err == nil {
t.Fatalf("args %v unexpectedly succeeded", args)
}
if caller.calls != 0 {
t.Fatalf("args %v made %d calls", args, caller.calls)
}
}
})
t.Run("dry run publishes unverified plan without write", func(t *testing.T) {
caller := &scriptedToolCaller{}
err := runNativeCardUpdate(t, caller,
"message", "update-card",
"--biz-id", "biz-preview",
"--content", "完成",
"--flow-status", "3",
"--dry-run",
)
if err != nil {
t.Fatal(err)
}
if caller.calls != 0 {
t.Fatalf("dry-run made %d calls", caller.calls)
}
})
}
+1 -1
View File
@@ -25,7 +25,7 @@ func runChatCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...str
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
root.SetArgs(append(append([]string(nil), args...), "--yes"))
return root.ExecuteContext(context.Background())
}
@@ -0,0 +1,267 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"io"
"reflect"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type chatMessagePaginationCaller struct {
steps []scriptedToolStep
calls []pagedCommandCall
}
func (c *chatMessagePaginationCaller) CallTool(_ context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) {
copied := map[string]any{}
for k, v := range args {
copied[k] = v
}
c.calls = append(c.calls, pagedCommandCall{server: serverID, tool: toolName, args: copied})
if len(c.steps) == 0 {
return textToolResult(`{"result":{"messages":[],"items":[],"hasMore":false,"nextCursor":"0"}}`), nil
}
step := c.steps[len(c.calls)-1]
if step.err != nil {
return nil, step.err
}
return textToolResult(step.text), nil
}
func (*chatMessagePaginationCaller) Format() string { return "json" }
func (*chatMessagePaginationCaller) DryRun() bool { return false }
func (*chatMessagePaginationCaller) Fields() string { return "" }
func (*chatMessagePaginationCaller) JQ() string { return "" }
func executeChatMessagePaginationCommand(t *testing.T, caller *chatMessagePaginationCaller, args ...string) (map[string]any, error) {
t.Helper()
oldDeps := deps
oldSleep := helperSleep
t.Cleanup(func() {
deps = oldDeps
helperSleep = oldSleep
})
InitDeps(caller)
out := &bytes.Buffer{}
deps.Out.w = out
deps.Out.errW = io.Discard
helperSleep = func(d time.Duration) {}
root := newChatCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
err := root.ExecuteContext(context.Background())
if out.Len() == 0 {
return nil, err
}
var parsed map[string]any
if unmarshalErr := json.Unmarshal(out.Bytes(), &parsed); unmarshalErr != nil {
t.Fatalf("stdout JSON = %q, err = %v", out.String(), unmarshalErr)
}
return parsed, err
}
func TestChatMessagePaginationDefaultSinglePageUnchanged(t *testing.T) {
tests := []struct {
name string
args []string
server string
tool string
want map[string]any
}{
{
name: "list-all",
args: []string{"message", "list-all", "--start", "2026-08-01 00:00:00", "--end", "2026-08-02 00:00:00"},
server: "",
tool: "search_messages_by_time_range",
want: map[string]any{"startTime": "2026-08-01 00:00:00", "endTime": "2026-08-02 00:00:00", "limit": 50, "cursor": "0"},
},
{
name: "list-by-sender",
args: []string{"message", "list-by-sender", "--sender-user-id", "u1", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
server: "",
tool: "search_messages_by_sender",
want: map[string]any{"senderUserId": "u1", "startTime": float64(1785513600000), "endTime": float64(1785600000000), "limit": 50, "cursor": "0"},
},
{
name: "list-mentions",
args: []string{"message", "list-mentions", "--group", "cid1", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
server: "",
tool: "search_at_me_message",
want: map[string]any{"openConversationId": "cid1", "startTime": float64(1785513600000), "endTime": float64(1785600000000), "limit": 50, "cursor": "0"},
},
{
name: "list-focused",
args: []string{"message", "list-focused"},
server: "",
tool: "list_special_focus_messages",
want: map[string]any{"limit": 50},
},
{
name: "search",
args: []string{"message", "search", "--query", "发布", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
server: "",
tool: "search_messages_by_keyword",
want: map[string]any{"keyword": "发布", "startTime": float64(1785513600000), "endTime": float64(1785600000000), "limit": 100, "cursor": "0"},
},
{
name: "search-advanced",
args: []string{"message", "search-advanced", "--query", "周报", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
server: "im",
tool: "search_messages",
want: map[string]any{"keyword": "周报", "startTime": float64(1785513600000), "endTime": float64(1785600000000), "limit": 100, "cursor": "0"},
},
{
name: "list-favorites",
args: []string{"message", "list-favorites"},
server: "im",
tool: "list_message_favorites",
want: map[string]any{"cursor": int64(0), "size": "20"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &chatMessagePaginationCaller{}
args := append([]string{}, tt.args...)
args = append(args, "--page-limit", "2", "--max-items", "1", "--page-delay", "0")
_, err := executeChatMessagePaginationCommand(t, caller, args...)
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want one fallback call", caller.calls)
}
got := caller.calls[0]
if got.server != tt.server || got.tool != tt.tool || !argsEqual(got.args, tt.want) {
t.Fatalf("call = %#v, want server=%s tool=%s args=%#v", got, tt.server, tt.tool, tt.want)
}
})
}
}
func TestChatMessagePaginationPageAllAggregatesSevenCommands(t *testing.T) {
tests := []struct {
name string
args []string
server string
tool string
itemPath string
cursorOne any
cursorTwo any
pageOne string
pageTwo string
}{
{
name: "list-all", args: []string{"message", "list-all", "--start", "2026-08-01 00:00:00", "--end", "2026-08-02 00:00:00"},
server: "chat", tool: "search_messages_by_time_range", itemPath: "conversationMessagesList", cursorOne: "0", cursorTwo: "c2",
pageOne: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`,
pageTwo: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`,
},
{
name: "list-by-sender", args: []string{"message", "list-by-sender", "--sender-user-id", "u1", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
server: "chat", tool: "search_messages_by_sender", itemPath: "conversationMessagesList", cursorOne: "0", cursorTwo: "c2",
pageOne: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"洄川","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`,
pageTwo: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"洄川","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`,
},
{
name: "list-mentions", args: []string{"message", "list-mentions", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
server: "chat", tool: "search_at_me_message", itemPath: "conversationMessagesList", cursorOne: "0", cursorTwo: "c2",
pageOne: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`,
pageTwo: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`,
},
{
name: "list-focused", args: []string{"message", "list-focused"},
server: "chat", tool: "list_special_focus_messages", itemPath: "messages", cursorOne: nil, cursorTwo: int64(2),
pageOne: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":2}}`,
pageTwo: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":0}}`,
},
{
name: "search", args: []string{"message", "search", "--query", "发布", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
server: "chat", tool: "search_messages_by_keyword", itemPath: "conversationMessagesList", cursorOne: "0", cursorTwo: "c2",
pageOne: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`,
pageTwo: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`,
},
{
name: "search-advanced", args: []string{"message", "search-advanced", "--query", "周报"},
server: "im", tool: "search_messages", itemPath: "conversationMessagesList", cursorOne: "0", cursorTwo: "c2",
pageOne: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`,
pageTwo: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`,
},
{
name: "list-favorites", args: []string{"message", "list-favorites"},
server: "im", tool: "list_message_favorites", itemPath: "items", cursorOne: int64(0), cursorTwo: int64(20),
pageOne: `{"result":{"items":[{"id":"f1"}],"hasMore":true,"nextCursor":20}}`,
pageTwo: `{"result":{"items":[{"id":"f2"}],"hasMore":false,"nextCursor":0}}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &chatMessagePaginationCaller{steps: []scriptedToolStep{{text: tt.pageOne}, {text: tt.pageTwo}}}
args := append([]string{}, tt.args...)
args = append(args, "--page-all", "--page-delay", "0")
got, err := executeChatMessagePaginationCommand(t, caller, args...)
if err != nil {
t.Fatal(err)
}
items := got["result"].(map[string]any)[tt.itemPath].([]any)
if tt.itemPath == "conversationMessagesList" {
messages := items[0].(map[string]any)["messages"].([]any)
if len(items) != 1 || len(messages) != 2 {
t.Fatalf("conversation items = %#v", items)
}
} else if len(items) != 2 {
t.Fatalf("items = %#v", items)
}
if len(caller.calls) != 2 {
t.Fatalf("calls = %#v, want two pages", caller.calls)
}
if caller.calls[0].server != tt.server || caller.calls[0].tool != tt.tool {
t.Fatalf("first call = %#v", caller.calls[0])
}
if !reflect.DeepEqual(caller.calls[0].args["cursor"], tt.cursorOne) {
t.Fatalf("first cursor = %#v, want %#v", caller.calls[0].args["cursor"], tt.cursorOne)
}
if !reflect.DeepEqual(caller.calls[1].args["cursor"], tt.cursorTwo) {
t.Fatalf("second cursor = %#v, want %#v", caller.calls[1].args["cursor"], tt.cursorTwo)
}
paging := got["paging"].(map[string]any)
if paging["pages"].(float64) != 2 || paging["total"].(float64) != 2 || paging["truncated"] != false {
t.Fatalf("paging = %#v", paging)
}
})
}
}
func argsEqual(got, want map[string]any) bool {
if len(got) != len(want) {
return false
}
for key, wantValue := range want {
gotValue, ok := got[key]
if !ok {
return false
}
switch w := wantValue.(type) {
case float64:
g, ok := gotValue.(int64)
if !ok || float64(g) != w {
return false
}
default:
if !reflect.DeepEqual(gotValue, wantValue) {
return false
}
}
}
return true
}
+565 -21
View File
@@ -16,6 +16,7 @@ package helpers
import (
"context"
"encoding/json"
"errors"
"io"
"os"
"reflect"
@@ -23,8 +24,10 @@ import (
"testing"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type chatMessageSearchCall struct {
@@ -34,12 +37,41 @@ type chatMessageSearchCall struct {
}
type chatMessageSearchCaller struct {
calls []chatMessageSearchCall
calls []chatMessageSearchCall
searchResponse string
searchResponses []string
searchCalls int
searchError error
failPreflight bool
preflightError error
}
func (c *chatMessageSearchCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, chatMessageSearchCall{productID: productID, toolName: toolName, args: args})
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
text := `{}`
if toolName == "get_conversation_info" {
if c.preflightError != nil {
return nil, c.preflightError
}
if c.failPreflight {
return nil, errors.New("conversation not found")
}
text = `{"result":{"openConversationId":"` + args["openConversationId"].(string) + `"}}`
}
if toolName == "search_messages_by_keyword" || toolName == "search_messages" {
if c.searchError != nil {
c.searchCalls++
return nil, c.searchError
}
text = `{"result":{"messages":[],"hasMore":false}}`
if c.searchCalls < len(c.searchResponses) {
text = c.searchResponses[c.searchCalls]
} else if c.searchResponse != "" {
text = c.searchResponse
}
c.searchCalls++
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
func (*chatMessageSearchCaller) Format() string { return "json" }
@@ -72,6 +104,7 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) {
productID string
toolName string
wantToolArg map[string]any
preflight []string
}{
{
name: "keyword search",
@@ -79,13 +112,13 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) {
productID: "chat",
toolName: "search_messages_by_keyword",
wantToolArg: map[string]any{
"keyword": "categoryName",
"openConversationId": "cid-1",
"startTime": startTime.UnixMilli(),
"endTime": endTime.UnixMilli(),
"limit": 100,
"cursor": "0",
"keyword": "categoryName",
"startTime": startTime.UnixMilli(),
"endTime": endTime.UnixMilli(),
"limit": 100,
"cursor": "0",
},
preflight: []string{"cid-1"},
},
{
name: "advanced search",
@@ -93,16 +126,16 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) {
productID: "im",
toolName: "search_messages",
wantToolArg: map[string]any{
"keyword": "categoryName",
"openConversationIds": []string{"cid-1", "cid-2"},
"messageType": "text",
"onlyRobotMessages": true,
"searchConvType": "group",
"startTime": startTime.UnixMilli(),
"endTime": endTime.UnixMilli(),
"limit": 100,
"cursor": "0",
"keyword": "categoryName",
"messageType": "text",
"onlyRobotMessages": true,
"searchConvType": "group",
"startTime": startTime.UnixMilli(),
"endTime": endTime.UnixMilli(),
"limit": 100,
"cursor": "0",
},
preflight: []string{"cid-1", "cid-2"},
},
}
@@ -116,14 +149,21 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) {
cmd := newChatCommand()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetOut(io.Discard)
cmd.SetArgs(tt.args)
if err := cmd.Execute(); err != nil {
t.Fatalf("chat search returned error: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
if len(caller.calls) != len(tt.preflight)+1 {
t.Fatalf("tool calls = %#v", caller.calls)
}
call := caller.calls[0]
for index, conversationID := range tt.preflight {
call := caller.calls[index]
if call.productID != "chat" || call.toolName != "get_conversation_info" || call.args["openConversationId"] != conversationID {
t.Fatalf("preflight[%d] = %#v", index, call)
}
}
call := caller.calls[len(caller.calls)-1]
if call.productID != tt.productID || call.toolName != tt.toolName {
t.Fatalf("tool call = %s/%s, want %s/%s", call.productID, call.toolName, tt.productID, tt.toolName)
}
@@ -134,6 +174,507 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) {
}
}
func executeNativeScopedSearch(t *testing.T, caller *chatMessageSearchCaller, args ...string) (map[string]any, error) {
t.Helper()
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
deps.Out.w = io.Discard
cmd := newChatCommand()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
var output strings.Builder
cmd.SetOut(&output)
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
return nil, err
}
var payload map[string]any
if err := json.Unmarshal([]byte(output.String()), &payload); err != nil {
return nil, err
}
return payload, nil
}
func TestNativeScopedSearchFiltersGlobalResultsForBothEntries(t *testing.T) {
start := "2026-07-09T00:00:00+08:00"
end := "2026-07-11T00:00:00+08:00"
for _, tt := range []struct {
name string
args []string
tool string
scopeParam string
}{
{
name: "keyword search",
args: []string{"message", "search", "--query", "周报", "--group", "cid-target", "--start", start, "--end", end},
tool: "search_messages_by_keyword",
scopeParam: "openConversationId",
},
{
name: "advanced search",
args: []string{"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target"},
tool: "search_messages",
scopeParam: "openConversationIds",
},
} {
t.Run(tt.name, func(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponse: `{
"result": {
"conversationMessagesList": [
{"openConversationId":"cid-target","title":"目标群","messages":[{"openMessageId":"m-target","content":"目标"}]},
{"openConversationId":"cid-other","title":"其他群","messages":[{"openMessageId":"m-other","content":"越界"}]}
],
"hasMore": false
}
}`}
payload, err := executeNativeScopedSearch(t, caller, tt.args...)
if err != nil {
t.Fatal(err)
}
result, _ := payload["result"].(map[string]any)
groups, _ := result["conversationMessagesList"].([]any)
if len(groups) != 1 {
t.Fatalf("result = %#v", result)
}
group, _ := groups[0].(map[string]any)
if group["openConversationId"] != "cid-target" {
t.Fatalf("group = %#v", group)
}
scope, _ := payload["scope"].(map[string]any)
if scope["targetsValidated"] != true || scope["resultsWithinScope"] != true || scope["filterMode"] != "client" {
t.Fatalf("scope = %#v", scope)
}
searchCall := caller.calls[len(caller.calls)-1]
if searchCall.toolName != tt.tool {
t.Fatalf("search call = %#v", searchCall)
}
if _, exists := searchCall.args[tt.scopeParam]; exists {
t.Fatalf("global fallback unexpectedly forwarded %s: %#v", tt.scopeParam, searchCall.args)
}
})
}
}
func TestNativeScopedSearchInvalidCIDStopsBeforeSearch(t *testing.T) {
caller := &chatMessageSearchCaller{failPreflight: true}
_, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-invalid")
if err == nil {
t.Fatal("invalid CID unexpectedly succeeded")
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_invalid" {
t.Fatalf("error = %#v", err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "get_conversation_info" {
t.Fatalf("calls = %#v", caller.calls)
}
}
func TestNativeScopedSearchPreservesPreflightAuthError(t *testing.T) {
want := &CLIError{Code: CodeAuthNotConfigured, Message: "当前未登录"}
caller := &chatMessageSearchCaller{preflightError: want}
_, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
if err == nil {
t.Fatal("auth failure unexpectedly succeeded")
}
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeAuthNotConfigured {
t.Fatalf("error = %#v", err)
}
}
func TestCrossPlatformCoverageNativeScopedSearchPreservesAmbiguousMCPToolErrors(t *testing.T) {
for _, test := range []struct {
name string
want *CLIError
}{
{
name: "rate limited",
want: &CLIError{
Code: CodeMCPToolError,
Message: `{"success":false,"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down"}`,
},
},
{
name: "permission denied",
want: &CLIError{
Code: CodeMCPToolError,
Message: `{"success":false,"errorCode":"forbidden.noPermission","errorMsg":"permission denied"}`,
},
},
} {
t.Run(test.name, func(t *testing.T) {
caller := &chatMessageSearchCaller{preflightError: test.want}
_, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
if err != test.want {
t.Fatalf("error = %#v, want original %#v", err, test.want)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "get_conversation_info" {
t.Fatalf("calls = %#v", caller.calls)
}
})
}
}
func TestNativeScopedSearchScansUntilTargetConversationAppears(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponses: []string{
`{"result":{"conversationMessagesList":[{"openConversationId":"cid-other","messages":[{"openMessageId":"m-other"}]}],"hasMore":true,"nextCursor":"c2"}}`,
`{"result":{"conversationMessagesList":[{"openConversationId":"cid-target","messages":[{"openMessageId":"m-target"}]}],"hasMore":false}}`,
}}
payload, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
if err != nil {
t.Fatal(err)
}
result, _ := payload["result"].(map[string]any)
if result["pagesFetched"] != float64(2) || result["complete"] != true {
t.Fatalf("result = %#v", result)
}
groups, _ := result["conversationMessagesList"].([]any)
if len(groups) != 1 {
t.Fatalf("groups = %#v", groups)
}
searchCalls := make([]chatMessageSearchCall, 0, 2)
for _, call := range caller.calls {
if call.toolName == "search_messages" {
searchCalls = append(searchCalls, call)
}
}
if len(searchCalls) != 2 || searchCalls[1].args["cursor"] != "c2" {
t.Fatalf("search calls = %#v", searchCalls)
}
}
func TestCrossPlatformCoverageNativeScopedSearchPageAllOptions(t *testing.T) {
t.Run("page limit preserves continuation", func(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponse: `{
"result": {
"conversationMessagesList": [
{"openConversationId":"cid-target","messages":[{"openMessageId":"m1"}]}
],
"hasMore": true,
"nextCursor": "c2"
}
}`}
payload, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target",
"--page-all", "--page-limit", "1", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
paging, _ := payload["paging"].(map[string]any)
if paging["pages"] != float64(1) || paging["total"] != float64(1) || paging["truncated"] != true {
t.Fatalf("paging = %#v", paging)
}
if caller.searchCalls != 1 {
t.Fatalf("search calls = %d, want 1", caller.searchCalls)
}
})
t.Run("max items truncates within filtered page", func(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponse: `{
"result": {
"conversationMessagesList": [
{"openConversationId":"cid-target","messages":[
{"openMessageId":"m1"},
{"openMessageId":"m2"}
]}
],
"hasMore": false
}
}`}
payload, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target",
"--page-all", "--max-items", "1", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
result, _ := payload["result"].(map[string]any)
groups, _ := result["conversationMessagesList"].([]any)
group, _ := groups[0].(map[string]any)
messages, _ := group["messages"].([]any)
if len(messages) != 1 {
t.Fatalf("messages = %#v", messages)
}
paging, _ := payload["paging"].(map[string]any)
if paging["total"] != float64(1) || paging["truncatedWithinPage"] != true || paging["resumeCursorReliable"] != false {
t.Fatalf("paging = %#v", paging)
}
})
}
func TestNativeScopedSearchMissingConversationIdentityFailsClosed(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponse: `{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":false}}`}
_, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
if err == nil {
t.Fatal("unverifiable scoped result unexpectedly succeeded")
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_unverified" {
t.Fatalf("error = %#v", err)
}
}
func TestNativeScopedSearchValidEmptyResultIsComplete(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
payload, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-empty")
if err != nil {
t.Fatal(err)
}
result, _ := payload["result"].(map[string]any)
if result["complete"] != true || result["hasMore"] != false {
t.Fatalf("result = %#v", result)
}
scope, _ := payload["scope"].(map[string]any)
if scope["targetsValidated"] != true || scope["sourceComplete"] != true {
t.Fatalf("scope = %#v", scope)
}
}
func TestCrossPlatformCoverageNativeScopedSearchFailureAndPaginationBranches(t *testing.T) {
t.Run("empty scope uses the native search call", func(t *testing.T) {
caller := &chatMessageSearchCaller{}
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
deps.Out.w = io.Discard
cmd := &cobra.Command{Use: "search"}
err := runConversationScopedMessageSearch(
cmd,
"im",
"search_messages",
"openConversationIds",
map[string]any{"keyword": "周报"},
[]string{"", " "},
)
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "search_messages" {
t.Fatalf("calls = %#v", caller.calls)
}
})
t.Run("invalid page options fail before preflight", func(t *testing.T) {
caller := &chatMessageSearchCaller{}
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
cmd := &cobra.Command{Use: "search"}
AddPagedMCPFlags(cmd)
if err := cmd.Flags().Set("page-all", "true"); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("page-limit", "0"); err != nil {
t.Fatal(err)
}
err := runConversationScopedMessageSearch(
cmd,
"im",
"search_messages",
"openConversationIds",
map[string]any{"keyword": "周报"},
[]string{"cid-target"},
)
if err == nil || !strings.Contains(err.Error(), "--page-limit must be between 1 and 500") {
t.Fatalf("error = %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid paging made calls: %#v", caller.calls)
}
})
t.Run("cancelled context interrupts page delay", func(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponse: `{
"result": {
"messages": [{"openMessageId":"m1","openConversationId":"cid-target"}],
"hasMore": true,
"nextCursor": "c2"
}
}`}
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
deps.Out.w = io.Discard
cmd := &cobra.Command{Use: "search"}
AddPagedMCPFlags(cmd)
if err := cmd.Flags().Set("page-all", "true"); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("page-delay", "60000"); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
cmd.SetContext(ctx)
err := runConversationScopedMessageSearch(
cmd,
"im",
"search_messages",
"openConversationIds",
map[string]any{"keyword": "周报", "limit": 100, "cursor": "0"},
[]string{"cid-target"},
)
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want context canceled", err)
}
if caller.searchCalls != 1 {
t.Fatalf("search calls = %d, want 1", caller.searchCalls)
}
})
t.Run("lower search error", func(t *testing.T) {
caller := &chatMessageSearchCaller{searchError: errors.New("search unavailable")}
_, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
if err == nil {
t.Fatal("lower search error was ignored")
}
})
t.Run("stalled cursor fails closed", func(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponse: `{
"result": {
"messages": [{"openMessageId":"m1","openConversationId":"cid-target"}],
"hasMore": true
}
}`}
_, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_cursor_stalled" {
t.Fatalf("error = %#v", err)
}
})
t.Run("result limit preserves continuation", func(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponse: `{
"result": {
"messages": [{"openMessageId":"m1","openConversationId":"cid-target"}],
"hasMore": true,
"nextCursor": "c2"
}
}`}
payload, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target", "--limit", "1")
if err != nil {
t.Fatal(err)
}
result, _ := payload["result"].(map[string]any)
if result["complete"] != false || result["hasMore"] != true || result["nextCursor"] != "c2" {
t.Fatalf("result = %#v", result)
}
})
t.Run("duplicate message ids are removed across pages", func(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponses: []string{
`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-target"}],"hasMore":true,"nextCursor":"c2"}}`,
`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-target"},{"openMessageId":"m2","openConversationId":"cid-target"}],"hasMore":false}}`,
}}
payload, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
if err != nil {
t.Fatal(err)
}
result, _ := payload["result"].(map[string]any)
groups, _ := result["conversationMessagesList"].([]any)
group, _ := groups[0].(map[string]any)
messages, _ := group["messages"].([]any)
if len(messages) != 2 {
t.Fatalf("deduplicated messages = %#v", messages)
}
})
if got := uniqueNonEmptyStrings([]string{" cid ", "", "cid"}); !reflect.DeepEqual(got, []string{"cid"}) {
t.Fatalf("uniqueNonEmptyStrings = %#v", got)
}
for _, test := range []struct {
value any
want int
}{
{value: int64(7), want: 7},
{value: json.Number("8"), want: 8},
{value: float64(9), want: 9},
{value: int64(0), want: 11},
} {
if got := positiveSearchLimit(test.value, 11); got != test.want {
t.Errorf("positiveSearchLimit(%#v) = %d, want %d", test.value, got, test.want)
}
}
if cleanSearchCursor(nil) != "" || cleanSearchCursor(" null ") != "" || cleanSearchCursor(" c2 ") != "c2" {
t.Fatal("cleanSearchCursor did not normalize sentinel values")
}
}
func TestCrossPlatformCoverageNativeScopedSearchDryRunShowsCompositePlanWithoutCallingTools(t *testing.T) {
caller := &chatMessageSearchCaller{}
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
cmd := &cobra.Command{Use: "search"}
cmd.Flags().Bool("dry-run", true, "")
AddPagedMCPFlags(cmd)
for name, value := range map[string]string{
"page-all": "true",
"page-limit": "7",
"max-items": "9",
"page-delay": "11",
} {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
var output strings.Builder
cmd.SetOut(&output)
err := runConversationScopedMessageSearch(
cmd,
"im",
"search_messages",
"openConversationIds",
map[string]any{
"keyword": "周报",
"openConversationIds": []string{"cid-target"},
"limit": 100,
"cursor": "0",
},
[]string{"cid-target"},
)
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run made calls: %#v", caller.calls)
}
var payload map[string]any
if err := json.Unmarshal([]byte(output.String()), &payload); err != nil {
t.Fatal(err)
}
plan, _ := payload["plan"].([]any)
if payload["dry_run"] != true || payload["executed"] != false || len(plan) != 3 {
t.Fatalf("payload = %#v", payload)
}
searchStage, _ := plan[1].(map[string]any)
arguments, _ := searchStage["arguments"].(map[string]any)
if _, exists := arguments["openConversationIds"]; exists {
t.Fatalf("dry-run global search still carries scope: %#v", searchStage)
}
if searchStage["pageAll"] != true ||
searchStage["pageLimit"] != float64(7) ||
searchStage["maxItems"] != float64(9) ||
searchStage["pageDelay"] != float64(11) {
t.Fatalf("dry-run paging = %#v", searchStage)
}
}
type chatChangedContractCaller struct {
calls []chatMessageSearchCall
resolveUsers bool
@@ -160,9 +701,12 @@ func executeChatChangedContract(t *testing.T, caller *chatChangedContractCaller,
InitDeps(caller)
deps.Out.w = io.Discard
cmd := newChatCommand()
if cmd.PersistentFlags().Lookup("yes") == nil {
cmd.PersistentFlags().Bool("yes", false, "skip confirmation")
}
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs(args)
cmd.SetArgs(append(append([]string(nil), args...), "--yes"))
return cmd.Execute()
}
+123
View File
@@ -0,0 +1,123 @@
// 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 helpers
import (
"encoding/json"
"errors"
"fmt"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
// NormalizeSearchConversationScopeError maps only errors that prove the
// requested conversation does not exist or that openConversationId itself is
// invalid. Unknown MCP tool failures must remain unchanged: the legacy
// CodeMCPToolError bucket also carries permission, throttling, and transient
// backend failures, none of which proves that the caller supplied a bad CID.
func NormalizeSearchConversationScopeError(conversationID string, err error) error {
if err == nil || !isDefinitiveInvalidSearchConversationError(err) {
return err
}
return apperrors.NewValidation(
fmt.Sprintf("无法验证会话 CID %q;已停止搜索,避免过滤失效后返回其他会话消息", conversationID),
apperrors.WithReason("search_conversation_scope_invalid"),
apperrors.WithDetails(map[string]any{"conversationId": conversationID}),
apperrors.WithRetryable(false),
apperrors.WithHint("确认 openConversationId 存在且当前账号可访问后重试"),
apperrors.WithCause(err),
)
}
func isDefinitiveInvalidSearchConversationError(err error) bool {
var cliErr *CLIError
if errors.As(err, &cliErr) {
switch cliErr.Code {
case CodeResourceNotFound, CodeInvalidParam:
return true
}
code, message := searchConversationErrorFacts(cliErr.Message)
if isExplicitInvalidConversationCode(code) || isConversationParameterError(code, message) {
return true
}
}
// Transport errors can survive below a legacy CLIError in the cause chain.
// Inspect their structured diagnostics, but require conversation-specific
// evidence before treating a generic PARAM_ERROR as an invalid CID.
var appErr *apperrors.Error
if errors.As(err, &appErr) {
code := strings.TrimSpace(appErr.ServerDiag.ServerErrorCode)
message := strings.Join([]string{
appErr.Message,
appErr.ServerDiag.TechnicalDetail,
appErr.Reason,
appErr.FailureStage,
}, " ")
return isExplicitInvalidConversationCode(code) || isConversationParameterError(code, message)
}
return false
}
func searchConversationErrorFacts(raw string) (string, string) {
var body map[string]any
if json.Unmarshal([]byte(raw), &body) != nil {
return "", raw
}
code := firstSearchConversationErrorString(body, "errorCode", "error_code", "code")
message := firstSearchConversationErrorString(body, "errorMsg", "error_msg", "message", "error")
return code, message
}
func firstSearchConversationErrorString(body map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := body[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func isExplicitInvalidConversationCode(code string) bool {
switch strings.ToUpper(strings.TrimSpace(code)) {
case "INVALID_OPEN_CONVERSATION_ID", "OPEN_CONVERSATION_NOT_FOUND", "CONVERSATION_NOT_FOUND":
return true
default:
return false
}
}
func isConversationParameterError(code, message string) bool {
normalizedCode := strings.ToUpper(strings.TrimSpace(code))
if normalizedCode != "PARAM_ERROR" && normalizedCode != "PARAMETER_ERROR" && normalizedCode != "INVALID_ARGUMENT" {
return false
}
normalizedMessage := strings.ToLower(strings.TrimSpace(message))
mentionsConversationID := strings.Contains(normalizedMessage, "openconversationid") ||
strings.Contains(normalizedMessage, "open conversation id") ||
strings.Contains(normalizedMessage, "conversation id") ||
strings.Contains(normalizedMessage, "cid")
if !mentionsConversationID {
return false
}
for _, marker := range []string{
"invalid", "illegal", "malformed", "required", "missing", "not found", "不存在", "无效", "非法", "缺少", "必填",
} {
if strings.Contains(normalizedMessage, marker) {
return true
}
}
return false
}
@@ -0,0 +1,123 @@
// 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 helpers
import (
"errors"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
func TestCrossPlatformCoverageNormalizeSearchConversationScopeError(t *testing.T) {
if got := NormalizeSearchConversationScopeError("cid", nil); got != nil {
t.Fatalf("nil error normalized to %#v", got)
}
invalidCases := []struct {
name string
err error
}{
{
name: "classified resource not found",
err: &CLIError{Code: CodeResourceNotFound, Message: "conversation not found"},
},
{
name: "explicit conversation error code",
err: &CLIError{
Code: CodeMCPToolError,
Message: `{"success":false,"errorCode":"INVALID_OPEN_CONVERSATION_ID","errorMsg":"invalid conversation"}`,
},
},
{
name: "conversation specific parameter error",
err: &CLIError{
Code: CodeMCPToolError,
Message: `{"success":false,"errorCode":"PARAM_ERROR","errorMsg":"openConversationId is invalid"}`,
},
},
{
name: "transport diagnostic proves invalid conversation",
err: apperrors.NewAPI(
"conversation validation failed",
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
ServerErrorCode: "INVALID_OPEN_CONVERSATION_ID",
}),
),
},
}
for _, test := range invalidCases {
t.Run(test.name, func(t *testing.T) {
got := NormalizeSearchConversationScopeError("cid-invalid", test.err)
var typed *apperrors.Error
if !errors.As(got, &typed) || typed.Reason != "search_conversation_scope_invalid" {
t.Fatalf("normalized error = %#v", got)
}
if !typed.RetryableSet || typed.Retryable {
t.Fatalf("retryable = (%t, set=%t), want false and set", typed.Retryable, typed.RetryableSet)
}
if typed.Details["conversationId"] != "cid-invalid" || !errors.Is(got, test.err) {
t.Fatalf("normalized error lost details or cause: %#v", typed)
}
})
}
preservedCases := []struct {
name string
err error
}{
{
name: "rate limit",
err: &CLIError{
Code: CodeMCPToolError,
Message: `{"success":false,"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down","retryable":true}`,
},
},
{
name: "permission denied",
err: &CLIError{
Code: CodeMCPToolError,
Message: `{"success":false,"errorCode":"forbidden.noPermission","errorMsg":"permission denied"}`,
},
},
{
name: "generic parameter error without CID evidence",
err: &CLIError{
Code: CodeMCPToolError,
Message: `{"success":false,"errorCode":"PARAM_ERROR","errorMsg":"未找到指定工具"}`,
},
},
{
name: "structured error without recognized facts",
err: &CLIError{
Code: CodeMCPToolError,
Message: `{"success":false,"retryable":true}`,
},
},
{
name: "parameter error mentions CID without invalid evidence",
err: &CLIError{
Code: CodeMCPToolError,
Message: `{"success":false,"errorCode":"PARAM_ERROR","errorMsg":"openConversationId could not be processed"}`,
},
},
}
for _, test := range preservedCases {
t.Run(test.name, func(t *testing.T) {
if got := NormalizeSearchConversationScopeError("cid-target", test.err); got != test.err {
t.Fatalf("error = %#v, want original %#v", got, test.err)
}
})
}
}
+2 -2
View File
@@ -101,7 +101,7 @@ func TestCrossPlatformCoverageChatMessageSendLocation(t *testing.T) {
caller = &guardedMutationCaller{}
err = executeGuardedMutationCommand(t, caller, newChatCommand,
"message", "send", "--group", "cid1", "--msg-type", "location",
"--latitude", "39.9", "--longitude", "116.4", "--location-name", "国贸", "--map-thumbnail-url", "@media1")
"--latitude", "39.9", "--longitude", "116.4", "--location-name", "国贸", "--map-thumbnail-url", "@media1", "--yes")
if err != nil {
t.Fatal(err)
}
@@ -128,7 +128,7 @@ func TestCrossPlatformCoverageChatMessageSendProfile(t *testing.T) {
caller = &guardedMutationCaller{}
err = executeGuardedMutationCommand(t, caller, newChatCommand,
"message", "send", "--group", "cid1", "--msg-type", "profile", "--contact-id", "od123")
"message", "send", "--group", "cid1", "--msg-type", "profile", "--contact-id", "od123", "--yes")
if err != nil {
t.Fatal(err)
}
+4 -4
View File
@@ -94,7 +94,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
"请求涉及企业内部应用的查询、创建、配置、成员权限、机器人、事件订阅或版本管理",
},
AvoidWhen: []string{
"开放平台接口文档搜索使用 devdoc;普通钉钉业务数据使用对应产品命令",
"个人 IM/OA 实时事件监听使用 event;开放平台接口文档搜索使用 devdoc;普通钉钉业务数据使用对应产品命令",
},
},
})
@@ -293,7 +293,7 @@ func newDevAppEventListCommand(runner executor.Runner) *cobra.Command {
Selection: contract.SelectionSpec{
AgentSummary: "列出或搜索应用可订阅的事件",
UseWhen: []string{"需要查事件码、事件名称或当前事件列表时"},
AvoidWhen: []string{"订阅或退订事件使用对应写命令"},
AvoidWhen: []string{"订阅或退订应用回调事件使用对应写命令;监听当前用户个人 IM/OA 事件使用 event"},
Examples: []string{`dws dev app event list --unified-app-id <unifiedAppId> --keyword "审批" --page-size 20`},
},
},
@@ -329,7 +329,7 @@ func newDevAppEventSubscribeCommand(runner executor.Runner) *cobra.Command {
Selection: contract.SelectionSpec{
AgentSummary: "为应用订阅指定事件码",
UseWhen: []string{"已确认事件码并需要新增事件订阅时"},
AvoidWhen: []string{"查询事件码或已有订阅时先使用 dev app event list"},
AvoidWhen: []string{"查询事件码或已有订阅时先使用 dev app event list;个人 IM/OA 事件长连接监听使用 event"},
Examples: []string{"dws dev app event subscribe --unified-app-id <unifiedAppId> --event-codes bpms_task_change --dry-run"},
},
},
@@ -365,7 +365,7 @@ func newDevAppEventUnsubscribeCommand(runner executor.Runner) *cobra.Command {
Selection: contract.SelectionSpec{
AgentSummary: "取消应用的指定事件订阅",
UseWhen: []string{"需要停止接收一个或多个已订阅事件时"},
AvoidWhen: []string{"只是查看事件订阅时使用 dev app event list"},
AvoidWhen: []string{"只是查看应用事件订阅时使用 dev app event list;停止个人事件监听使用 event stop"},
Examples: []string{"dws dev app event unsubscribe --unified-app-id <unifiedAppId> --event-codes bpms_task_change --dry-run"},
},
},
+99 -23
View File
@@ -532,16 +532,23 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
fileSize := fileInfo.Size()
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "上传附件并插入文档")
deps.Out.PrintKeyValue("文档", nodeID)
deps.Out.PrintKeyValue("文件", filePath)
deps.Out.PrintKeyValue("名称", fileName)
deps.Out.PrintKeyValue("类型", mimeType)
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", fileSize))
return nil
return deps.Out.PrintJSON(map[string]any{
"contractVersion": "doc.operation.v1",
"dry_run": true,
"preview_kind": "plan",
"ok": true,
"status": "success",
"complete": true,
"operation": "doc.media_insert",
"data": map[string]any{
"executed": false, "nodeId": nodeID, "file": filePath,
"fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize,
},
"steps": []map[string]any{{"name": "validate_local_file", "status": "success"}},
})
}
ctx := context.Background()
ctx := cmd.Context()
// Step 1: get upload credentials (uploadUrl + resourceId)
deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
@@ -568,7 +575,20 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
"Content-Type": mimeType,
}
if err := httpPutFile(ctx, uploadURL, ossHeaders, filePath, fileSize); err != nil {
return err
return apperrors.NewAPI(
"附件上传结果未知;尚未确认正文 block 已插入,请先检查文档媒体列表,禁止改用手写 HTTP",
apperrors.WithOperation("doc.media_insert"),
apperrors.WithReason("doc_media_upload_unknown"),
apperrors.WithFailureStage("upload_oss"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithActions("运行 dws doc +media-list 检查当前文档", "确认没有对应媒体后才重新执行 +media-insert", "不要 curl 上传地址或安装本地依赖"),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.operation.v1", "status": "unknown", "nodeId": nodeID,
"resourceId": resourceID, "fileName": fileName, "stage": "upload_oss",
}),
apperrors.WithCause(err),
)
}
// Step 3: insert block into document
@@ -625,15 +645,43 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
}
if err := callMCPTool("insert_document_block", insertArgs); err != nil {
return err
return apperrors.NewAPI(
"附件已上传,但正文 block 插入结果未知;请先检查媒体列表,不要重复上传或插入",
apperrors.WithOperation("doc.media_insert"),
apperrors.WithReason("doc_media_insert_partial"),
apperrors.WithFailureStage("insert_block"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithActions("运行 dws doc +media-list 检查 resourceId/blockId", "不要直接重试 +media-insert,不要使用 resourceUrl 手写请求"),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.operation.v1", "status": "partial_success", "nodeId": nodeID,
"resourceId": resourceID, "resourceUrl": resourceURL, "fileName": fileName,
"steps": []map[string]any{
{"name": "resolve_upload", "status": "success"},
{"name": "upload_oss", "status": "success"},
{"name": "insert_block", "status": "unknown"},
},
}),
apperrors.WithCause(err),
)
}
if strings.HasPrefix(mimeType, "image/") {
deps.Out.PrintInfo(fmt.Sprintf("图片已插入文档: %s (resourceUrl=%s)", fileName, resourceURL))
} else {
deps.Out.PrintInfo(fmt.Sprintf("附件已插入文档: %s (resourceId=%s)", fileName, resourceID))
}
return nil
return deps.Out.PrintJSON(map[string]any{
"contractVersion": "doc.operation.v1",
"ok": true,
"status": "success",
"complete": true,
"operation": "doc.media_insert",
"data": map[string]any{
"nodeId": nodeID, "resourceId": resourceID, "resourceUrl": resourceURL,
"fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize, "inserted": true,
},
"steps": []map[string]any{
{"name": "resolve_upload", "status": "success"},
{"name": "upload_oss", "status": "success"},
{"name": "insert_block", "status": "success"},
},
})
}
// parseAttachmentUploadInfo extracts uploadUrl, resourceId and resourceUrl from the MCP tool response.
@@ -3620,6 +3668,17 @@ CLI 内部自动完成全部流程:
}
if deps.Caller.DryRun() {
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"operation": "doc_export",
"nodeId": node,
"exportFormat": format,
"savedPath": outputPath,
})
}
deps.Out.PrintKeyValue("操作", "导出文档(提交+轮询+下载)")
deps.Out.PrintKeyValue("文档", node)
deps.Out.PrintKeyValue("输出", outputPath)
@@ -3630,7 +3689,7 @@ CLI 内部自动完成全部流程:
ctx := context.Background()
// ── Step 1: 提交导出任务 ──
deps.Out.PrintInfo("[1/3] 提交导出任务...")
printJSONSafeInfo("[1/3] 提交导出任务...")
submitText, err := callMCPToolReturnText(ctx, "submit_export_job", submitArgs)
if err != nil {
return fmt.Errorf("提交导出任务失败: %w", err)
@@ -3645,10 +3704,10 @@ CLI 内部自动完成全部流程:
deps.Out.PrintRaw(submitText)
return fmt.Errorf("提交导出任务成功但未返回 jobId")
}
deps.Out.PrintInfo(fmt.Sprintf(" 任务已提交,jobId: %s", jobID))
printJSONSafeInfo(fmt.Sprintf(" 任务已提交,jobId: %s", jobID))
// ── Step 2: 渐进式退避轮询 ──
deps.Out.PrintInfo("[2/3] 等待导出完成...")
printJSONSafeInfo("[2/3] 等待导出完成...")
downloadURL, err := pollDocExportJob(ctx, jobID)
if err != nil {
return err
@@ -3667,11 +3726,28 @@ CLI 内部自动完成全部流程:
outputPath = filepath.Join(outputPath, filename)
}
deps.Out.PrintInfo(fmt.Sprintf("[3/3] 下载文件到 %s ...", outputPath))
printJSONSafeInfo(fmt.Sprintf("[3/3] 下载文件到 %s ...", outputPath))
if err := httpGetFile(ctx, downloadURL, nil, outputPath); err != nil {
return fmt.Errorf("文件下载失败 (jobId=%s): %w", jobID, err)
}
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
info, err := os.Stat(outputPath)
if err != nil {
return fmt.Errorf("读取导出产物信息失败 (jobId=%s): %w", jobID, err)
}
return deps.Out.PrintJSON(map[string]any{
"success": true,
"nodeId": node,
"exportFormat": format,
"jobId": jobID,
"taskId": jobID,
"status": "SUCCESS",
"savedPath": outputPath,
"sizeBytes": info.Size(),
})
}
deps.Out.PrintInfo(fmt.Sprintf("导出完成: %s", outputPath))
return nil
},
@@ -3831,8 +3907,8 @@ CLI 内部自动完成全部流程:
},
}
importCmd.Flags().String("file", "", "本地文件路径 (必填)")
importCmd.Flags().String("folder", "", "目标文件夹 ID 或 URL (可选,与 --workspace 至少传一个)")
importCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL (可选,与 --folder 至少传一个)")
importCmd.Flags().String("folder", "", "目标文件夹 ID 或 URL (可选;folder/workspace 都不传时导入到默认根目录)")
importCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL (可选;folder/workspace 都不传时导入到默认根目录)")
importCmd.Flags().StringP("name", "n", "", "导入后文档名称 (可选,默认取文件名)")
importCmd.Flags().String("folder-id", "", "")
_ = importCmd.Flags().MarkHidden("folder-id")
@@ -4609,7 +4685,7 @@ func pollDocExportJob(ctx context.Context, jobID string) (downloadURL string, er
for attempt := 1; attempt <= maxPolls; attempt++ {
interval := pollInterval(attempt)
deps.Out.PrintInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, maxPolls, interval))
printJSONSafeInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, maxPolls, interval))
select {
case <-ctx.Done():
+36 -30
View File
@@ -9,6 +9,7 @@ import (
"syscall"
"unicode/utf8"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -17,9 +18,6 @@ const (
// Server-side OSS delta resolution is now fixed, so large chunks are safe.
initialChunkSize = 10000
// minChunkSize is the floor; below this we report an error instead of retrying.
minChunkSize = 5000
// longContentWarningThreshold triggers a hint to use --content-file.
longContentWarningThreshold = 2048
)
@@ -95,10 +93,11 @@ func docWritePipeline(cmd *cobra.Command, toolName string, toolArgs map[string]a
// Single write path
nodeID, lastResponse, writeErr = singleWrite(ctx, toolName, toolArgs)
chunksWritten = 1
if writeErr != nil && isTimeoutError(writeErr.Error()) && runeCount > minChunkSize {
// Timeout on single write — fallback to chunked with halved size
deps.Out.PrintInfo("[INFO] 单次写入超时,自动切换为分片写入...")
nodeID, chunksWritten, lastResponse, writeErr = chunkedWrite(ctx, toolName, toolArgs, markdown, operation, initialChunkSize/2)
if writeErr != nil && isTimeoutError(writeErr.Error()) {
// The server may have committed the write before the client observed the
// timeout. Replaying create/append here can duplicate a document or
// content, so fail closed and require inspection before any retry.
writeErr = docWriteUnknownStateError(operation, nodeID, "single_write", 0, 1, writeErr)
}
} else {
// Chunked write path
@@ -156,6 +155,9 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
len(chunks), utf8.RuneCountInString(chunks[0])))
resultText, err := callMCPToolReturnText(ctx, "create_document", createArgs)
if err != nil {
if isTimeoutError(err.Error()) {
return "", 0, resultText, docWriteUnknownStateError(operation, "", "chunk_1", 0, len(chunks), err)
}
return "", 0, resultText, fmt.Errorf("创建文档失败: %w", err)
}
nodeID = extractNodeIDFromResult(resultText)
@@ -181,6 +183,9 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
len(chunks), utf8.RuneCountInString(chunks[0]), firstMode))
resultText, err := callMCPToolReturnText(ctx, "update_document", updateArgs)
if err != nil {
if isTimeoutError(err.Error()) {
return nodeID, 0, resultText, docWriteUnknownStateError(operation, nodeID, "chunk_1", 0, len(chunks), err)
}
return nodeID, 0, resultText, fmt.Errorf("第 1 片写入失败: %w", err)
}
lastResponse = resultText
@@ -209,29 +214,9 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
resultText, err := callMCPToolReturnText(ctx, "update_document", updateArgs)
if err != nil {
if isTimeoutError(err.Error()) {
newSize := chunkSize / 2
if newSize < minChunkSize {
return nodeID, writtenCount, resultText, &CLIError{
Code: CodeContentTruncated,
Message: fmt.Sprintf("分片写入持续超时,已写入 %d 片。当前分片大小 %d 字符已低于最小阈值 %d",
writtenCount, chunkSize, minChunkSize),
Suggestion: fmt.Sprintf("后端写入超时无法恢复。已成功写入部分内容到 nodeId=%s,请使用 dws doc read --node %s 查看已写入部分",
nodeID, nodeID),
Operation: operation,
}
}
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 写入超时,分片大小减半为 %d 字符后重试...", newSize))
chunkSize = newSize
var remaining strings.Builder
remaining.WriteString(chunk)
for j := i + 1; j < len(chunks); j++ {
remaining.WriteString(chunks[j])
}
newChunks := splitMarkdownSafe(remaining.String(), chunkSize)
chunks = append(chunks[:i], newChunks...)
i-- // retry current index
continue
return nodeID, writtenCount, resultText, docWriteUnknownStateError(
operation, nodeID, fmt.Sprintf("chunk_%d", i+1), writtenCount, len(chunks), err,
)
}
return nodeID, writtenCount, resultText, fmt.Errorf("分片 %d 写入失败: %w", writtenCount+1, err)
}
@@ -243,6 +228,27 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
return nodeID, writtenCount, lastResponse, nil
}
func docWriteUnknownStateError(operation, nodeID, stage string, written, total int, cause error) error {
details := map[string]any{
"status": "unknown",
"nodeId": nodeID,
"chunksWritten": written,
"chunksTotal": total,
"failedStage": stage,
}
return apperrors.NewAPI(
"文档写入响应超时,服务端提交状态未知;为避免重复创建或重复追加,已停止自动重试",
apperrors.WithOperation(operation),
apperrors.WithReason("doc_write_commit_unknown"),
apperrors.WithFailureStage(stage),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithActions("先读取目标文档确认实际写入状态", "仅在确认服务端未提交后重新执行"),
apperrors.WithDetails(details),
apperrors.WithCause(cause),
)
}
// isTimeoutError checks if an error message indicates a server-side timeout.
func isTimeoutError(msg string) bool {
lower := strings.ToLower(msg)
@@ -7,6 +7,7 @@ import (
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -40,10 +41,12 @@ func TestCrossPlatformCoverageDocWritePipelineStrategyRemainingCoverage(t *testi
t.Fatalf("long literal single write: %v", err)
}
fallback := strings.Repeat("x", minChunkSize+100)
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("timeout")}, {text: `{}`}, {text: `{}`}}})
if err := docWritePipeline(docWriteCoverageCommand(), "update_document", map[string]any{"nodeId": "node", "mode": "overwrite", "markdown": fallback}, fallback, "update"); err != nil {
t.Fatalf("single timeout fallback: %v", err)
fallback := strings.Repeat("x", 5100)
timeoutCaller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("timeout")}, {text: `{}`}}}
installScriptedCaller(t, timeoutCaller)
err := docWritePipeline(docWriteCoverageCommand(), "update_document", map[string]any{"nodeId": "node", "mode": "overwrite", "markdown": fallback}, fallback, "update")
if err == nil || !strings.Contains(err.Error(), "提交状态未知") || timeoutCaller.calls != 1 {
t.Fatalf("single timeout must stop without replay: err=%v calls=%d", err, timeoutCaller.calls)
}
chunked := strings.Repeat("x", initialChunkSize+100)
@@ -58,9 +61,43 @@ func TestCrossPlatformCoverageChunkedWriteAdaptiveRetryRemainingCoverage(t *test
os.Args = []string{"dws", "doc"}
t.Cleanup(func() { os.Args = oldArgs })
markdown := strings.Repeat("x", 24000)
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {err: errors.New("HSFTimeoutException")}, {text: `{}`}}})
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {err: errors.New("HSFTimeoutException")}, {text: `{}`}}}
installScriptedCaller(t, caller)
_, written, _, err := chunkedWrite(context.Background(), "update_document", map[string]any{"nodeId": "node"}, markdown, "update", 10000)
if err != nil || written < 3 {
t.Fatalf("adaptive retry written=%d err=%v", written, err)
if err == nil || written != 1 || caller.calls != 2 || !strings.Contains(err.Error(), "提交状态未知") {
t.Fatalf("timeout must stop without replay: written=%d calls=%d err=%v", written, caller.calls, err)
}
}
func TestCrossPlatformCoverageDocWriteFirstChunkTimeoutIsUnknown(t *testing.T) {
oldArgs := os.Args
os.Args = []string{"dws", "doc"}
t.Cleanup(func() { os.Args = oldArgs })
markdown := strings.Repeat("x", 24000)
tests := []struct {
name string
tool string
args map[string]any
wantNode string
}{
{name: "create", tool: "create_document", args: map[string]any{"name": "doc"}},
{name: "update", tool: "update_document", args: map[string]any{"nodeId": "node", "mode": "overwrite"}, wantNode: "node"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("HSFTimeoutException")}, {text: `{}`}}}
installScriptedCaller(t, caller)
nodeID, written, _, err := chunkedWrite(context.Background(), tc.tool, tc.args, markdown, tc.name, 10000)
var typed *apperrors.Error
if err == nil || !errors.As(err, &typed) {
t.Fatalf("error = %#v", err)
}
if nodeID != tc.wantNode || written != 0 || caller.calls != 1 {
t.Fatalf("node=%q written=%d calls=%d", nodeID, written, caller.calls)
}
if typed.Reason != "doc_write_commit_unknown" || typed.FailureStage != "chunk_1" || typed.ExecutionStarted == nil || !*typed.ExecutionStarted || !typed.RetryableSet || typed.Retryable {
t.Fatalf("unknown commit metadata = %#v", typed)
}
})
}
}
+53 -6
View File
@@ -619,10 +619,20 @@ func newDriveCommand() *cobra.Command {
return err
}
dlOpts.logf = func(format string, a ...any) {
deps.Out.PrintInfo(fmt.Sprintf(format, a...))
printJSONSafeInfo(fmt.Sprintf(format, a...))
}
if deps.Caller.DryRun() {
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"operation": "drive_download",
"nodeId": fileID,
"savedPath": outputPath,
})
}
deps.Out.PrintKeyValue("操作", "下载钉盘文件")
deps.Out.PrintKeyValue("文件ID", fileID)
deps.Out.PrintKeyValue("输出", outputPath)
@@ -632,7 +642,7 @@ func newDriveCommand() *cobra.Command {
ctx := cmd.Context()
// Step 1: 获取下载 URL 和签名请求头
deps.Out.PrintInfo("[1/2] 获取下载链接...")
printJSONSafeInfo("[1/2] 获取下载链接...")
text, err := callMCPToolReturnText(ctx, "download_file", argsMap)
if err != nil {
return err
@@ -654,7 +664,7 @@ func newDriveCommand() *cobra.Command {
}
// Step 2: 分片下载(自动分派 + 401/403 凭证刷新重试)
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
printJSONSafeInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
dlOpts.knownSize = parseDownloadFileSize(text)
dlOpts.nodeID = fileID
dlOpts.version = parseDownloadFileVersion(text)
@@ -684,6 +694,19 @@ func newDriveCommand() *cobra.Command {
return err
}
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
info, err := os.Stat(outputPath)
if err != nil {
return fmt.Errorf("读取下载产物信息失败: %w", err)
}
return deps.Out.PrintJSON(map[string]any{
"success": true,
"nodeId": fileID,
"version": dlOpts.version,
"savedPath": outputPath,
"sizeBytes": info.Size(),
})
}
deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath))
return nil
},
@@ -772,10 +795,21 @@ func newDriveCommand() *cobra.Command {
return err
}
dlOpts.logf = func(format string, a ...any) {
deps.Out.PrintInfo(fmt.Sprintf(format, a...))
printJSONSafeInfo(fmt.Sprintf(format, a...))
}
if deps.Caller.DryRun() {
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"operation": "drive_download_version",
"nodeId": fileID,
"version": versionNum,
"savedPath": outputPath,
})
}
deps.Out.PrintKeyValue("操作", "下载文件历史版本")
deps.Out.PrintKeyValue("节点ID", fileID)
deps.Out.PrintKeyValue("版本号", fmt.Sprintf("%d", versionNum))
@@ -784,7 +818,7 @@ func newDriveCommand() *cobra.Command {
}
ctx := cmd.Context()
deps.Out.PrintInfo("[1/2] 获取历史版本下载链接...")
printJSONSafeInfo("[1/2] 获取历史版本下载链接...")
dlArgsMap := map[string]any{
"nodeId": fileID,
"version": versionNum,
@@ -804,7 +838,7 @@ func newDriveCommand() *cobra.Command {
}
outputPath = filepath.Join(outputPath, filename)
}
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
printJSONSafeInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
dlOpts.knownSize = parseDownloadFileSize(text)
dlOpts.nodeID = fileID
dlOpts.version = versionNum
@@ -833,6 +867,19 @@ func newDriveCommand() *cobra.Command {
}
return err
}
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
info, err := os.Stat(outputPath)
if err != nil {
return fmt.Errorf("读取下载产物信息失败: %w", err)
}
return deps.Out.PrintJSON(map[string]any{
"success": true,
"nodeId": fileID,
"version": versionNum,
"savedPath": outputPath,
"sizeBytes": info.Size(),
})
}
deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath))
return nil
},
@@ -0,0 +1,198 @@
// 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 helpers
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func executeJSONOutputContractCommand(t *testing.T, caller *scriptedToolCaller, build func() *cobra.Command, args ...string) (string, string, error) {
t.Helper()
testseam.Protect(t, &deps)
testseam.Protect(t, &os.Args)
InitDeps(caller)
var stdout, stderr bytes.Buffer
deps.Out.w = &stdout
deps.Out.errW = &stderr
root := build()
installExampleGlobalFlags(root)
os.Args = append([]string{"dws", root.Name()}, args...)
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs(args)
if err := root.Execute(); err != nil {
return stdout.String(), stderr.String(), err
}
return stdout.String(), stderr.String(), nil
}
func assertJSONOutputPayload(t *testing.T, stdout string) map[string]any {
t.Helper()
var payload map[string]any
if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
t.Fatalf("stdout is not JSON: %v\n%s", err, stdout)
}
return payload
}
func TestCrossPlatformCoverageJSONOutputContractForCompletedFileTransfers(t *testing.T) {
testseam.Swap(t, &httpGetFile, func(_ context.Context, _ string, _ map[string]string, destination string) error {
return os.WriteFile(destination, []byte("payload"), 0o600)
})
t.Run("drive latest download", func(t *testing.T) {
outputPath := filepath.Join(t.TempDir(), "latest.txt")
stdout, stderr, err := executeJSONOutputContractCommand(t,
&scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"downloadUrl":"https://example.test/latest.txt","fileSize":7,"version":9}`}}},
newDriveCommand,
"download", "--node", "node-latest", "--output", outputPath)
if err != nil {
t.Fatal(err)
}
payload := assertJSONOutputPayload(t, stdout)
if payload["nodeId"] != "node-latest" || payload["savedPath"] != outputPath || payload["sizeBytes"] != float64(7) || payload["version"] != float64(9) {
t.Fatalf("payload = %#v", payload)
}
if !strings.Contains(stderr, "下载完成") && !strings.Contains(stderr, "下载文件到") {
t.Fatalf("expected progress on stderr, got %q", stderr)
}
})
t.Run("drive historical download through compatibility flag", func(t *testing.T) {
outputPath := filepath.Join(t.TempDir(), "versioned.txt")
stdout, _, err := executeJSONOutputContractCommand(t,
&scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"downloadUrl":"https://example.test/versioned.txt","fileSize":7}`}}},
newDriveCommand,
"download", "--node", "node-versioned", "--version", "4", "--output", outputPath)
if err != nil {
t.Fatal(err)
}
payload := assertJSONOutputPayload(t, stdout)
if payload["nodeId"] != "node-versioned" || payload["version"] != float64(4) || payload["sizeBytes"] != float64(7) {
t.Fatalf("payload = %#v", payload)
}
})
t.Run("doc export", func(t *testing.T) {
testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time {
ch := make(chan time.Time, 1)
ch <- time.Now()
return ch
})
outputPath := filepath.Join(t.TempDir(), "export.md")
stdout, stderr, err := executeJSONOutputContractCommand(t,
&scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"jobId":"export-job-1"}`},
{text: `{"status":"SUCCESS","downloadUrl":"https://example.test/export.md"}`},
}},
newDocCommand,
"export", "--node", "doc-node", "--export-format", "markdown", "--output", outputPath)
if err != nil {
t.Fatal(err)
}
payload := assertJSONOutputPayload(t, stdout)
if payload["nodeId"] != "doc-node" || payload["exportFormat"] != "markdown" || payload["jobId"] != "export-job-1" || payload["taskId"] != "export-job-1" || payload["status"] != "SUCCESS" || payload["sizeBytes"] != float64(7) {
t.Fatalf("payload = %#v", payload)
}
if !strings.Contains(stderr, "提交导出任务") {
t.Fatalf("expected export progress on stderr, got %q", stderr)
}
})
}
func TestCrossPlatformCoverageJSONOutputContractDryRunIsMachineReadable(t *testing.T) {
stdout, _, err := executeJSONOutputContractCommand(t,
&scriptedToolCaller{format: "json", dry: true},
newDriveCommand,
"download", "--node", "node-dry-run", "--output", filepath.Join(t.TempDir(), "out.txt"), "--dry-run")
if err != nil {
t.Fatal(err)
}
payload := assertJSONOutputPayload(t, stdout)
if payload["dry_run"] != true || payload["executed"] != false || payload["nodeId"] != "node-dry-run" {
t.Fatalf("payload = %#v", payload)
}
stdout, _, err = executeJSONOutputContractCommand(t,
&scriptedToolCaller{format: "json", dry: true},
newDocCommand,
"export", "--node", "doc-dry-run", "--export-format", "markdown", "--output", filepath.Join(t.TempDir(), "export.md"), "--dry-run")
if err != nil {
t.Fatal(err)
}
payload = assertJSONOutputPayload(t, stdout)
if payload["dry_run"] != true || payload["executed"] != false || payload["nodeId"] != "doc-dry-run" || payload["operation"] != "doc_export" {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageJSONOutputContractReportsMissingLocalArtifact(t *testing.T) {
testseam.Swap(t, &httpGetFile, func(context.Context, string, map[string]string, string) error {
return nil
})
testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time {
ch := make(chan time.Time, 1)
ch <- time.Now()
return ch
})
tests := []struct {
name string
build func() *cobra.Command
args []string
steps []scriptedToolStep
}{
{
name: "latest drive download",
build: newDriveCommand,
args: []string{"download", "--node", "node-latest", "--output", filepath.Join(t.TempDir(), "latest.txt")},
steps: []scriptedToolStep{{text: `{"downloadUrl":"https://example.test/latest.txt","fileSize":7,"version":9}`}},
},
{
name: "versioned drive download",
build: newDriveCommand,
args: []string{"download", "--node", "node-versioned", "--version", "4", "--output", filepath.Join(t.TempDir(), "versioned.txt")},
steps: []scriptedToolStep{{text: `{"downloadUrl":"https://example.test/versioned.txt","fileSize":7}`}},
},
{
name: "doc export",
build: newDocCommand,
args: []string{"export", "--node", "doc-node", "--export-format", "markdown", "--output", filepath.Join(t.TempDir(), "export.md")},
steps: []scriptedToolStep{
{text: `{"jobId":"export-job-1"}`},
{text: `{"status":"SUCCESS","downloadUrl":"https://example.test/export.md"}`},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, err := executeJSONOutputContractCommand(t, &scriptedToolCaller{format: "json", steps: tt.steps}, tt.build, tt.args...)
if err == nil || !strings.Contains(err.Error(), "读取") {
t.Fatalf("expected missing local artifact error, got %v", err)
}
})
}
}
+7 -6
View File
@@ -1346,7 +1346,7 @@ func newMinutesCommand() *cobra.Command {
Short: "批量删除个人热词",
Long: `批量删除听记个人热词。
支持一次删除多个热词(逗号分隔)。删除后对应热词不再参与后续语音识别优化。`,
Example: ` dws minutes hot-word delete --words "钉钉"
Example: ` dws minutes hot-word delete --words "天气"
dws minutes hot-word delete --words "OKR,钉钉,Copilot"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "words"); err != nil {
@@ -1384,7 +1384,7 @@ func newMinutesCommand() *cobra.Command {
"不确定现有热词时先用 hot-word list",
},
Examples: []string{
"dws minutes hot-word delete --words \"钉钉\"",
"dws minutes hot-word delete --words \"天气\"",
"dws minutes hot-word delete --words \"OKR,钉钉,Copilot\"",
},
},
@@ -1883,11 +1883,12 @@ func newMinutesCommand() *cobra.Command {
if err := validateRequiredFlagWithAliases(cmd, "id", "url", "task-uuid", "uuid"); err != nil {
return err
}
if err := validateRequiredFlags(cmd, "policy"); err != nil {
return err
// 手动校验必填参数(避免 validateRequiredFlags 对 Int 的误判)
if !cmd.Flags().Changed("policy") {
return fmt.Errorf("missing required flag --policy")
}
policyID, err := strconv.ParseInt(mustGetFlag(cmd, "policy"), 10, 64)
policyID, err := cmd.Flags().GetInt("policy")
if err != nil || policyID < 2 || policyID > 4 {
return fmt.Errorf("flag --policy must be an integer between 2 and 4 (2=可编辑, 3=可查看/下载, 4=仅查看)")
}
@@ -1942,7 +1943,7 @@ func newMinutesCommand() *cobra.Command {
_ = permissionApplyCmd.Flags().MarkHidden("task-uuid")
permissionApplyCmd.Flags().String("uuid", "", "--id 的别名")
_ = permissionApplyCmd.Flags().MarkHidden("uuid")
permissionApplyCmd.Flags().String("policy", "", "权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填)")
permissionApplyCmd.Flags().Int("policy", 0, "权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填)")
permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd, permissionApplyCmd)
+1 -1
View File
@@ -63,7 +63,7 @@ func newOaCommand() *cobra.Command {
"查看待审、已办、已发起或抄送审批,并执行同意、拒绝、撤销、转交等审批动作时",
},
AvoidWhen: []string{
"不要用于普通待办任务或工作日志;需要创建审批实例时先确认当前命令面是否支持",
"不要用于普通待办任务或工作日志;需要实时监听未来的审批任务/实例事件时使用 event consume",
},
},
})
+11
View File
@@ -151,6 +151,17 @@ func (f *Formatter) PrintInfo(msg string) { fmt.Fprintf(f.w, "[INFO] %s\n",
func (f *Formatter) PrintProgress(msg string) { fmt.Fprintf(f.errW, "%s\n", msg) }
func (f *Formatter) PrintDim(msg string) { fmt.Fprintf(f.w, " %s\n", msg) }
// printJSONSafeInfo keeps command progress out of stdout when the caller
// requested JSON. A successful JSON command must leave stdout parseable; its
// human-readable progress belongs on stderr instead.
func printJSONSafeInfo(msg string) {
if deps != nil && deps.Caller != nil && strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
deps.Out.PrintProgress("[INFO] " + msg)
return
}
deps.Out.PrintInfo(msg)
}
func (f *Formatter) PrintKeyValue(key, value string) {
fmt.Fprintf(f.w, "%-16s%s\n", key+":", value)
}
+521
View File
@@ -0,0 +1,521 @@
package helpers
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
)
const (
defaultPagedCommandPageLimit = 50
maxPagedCommandPageLimit = 500
defaultPagedCommandDelayMS = 200
)
type PagedCursorKind int
const (
PagedCursorString PagedCursorKind = iota
PagedCursorInt64
)
type PagedAggregationMode int
const (
PagedAggregationArray PagedAggregationMode = iota
PagedAggregationConversationMessages
)
type PagedMCPCommandConfig struct {
ServerID string
ToolName string
ItemPath string
CursorPath string
HasMorePath string
CursorArg string
CursorKind PagedCursorKind
AggregationMode PagedAggregationMode
BuildArgs func(*cobra.Command) (map[string]any, error)
Fallback func(map[string]any) error
}
type pagedCommandOptions struct {
pageAll bool
pageLimit int
maxItems int
delayMS int
}
func AddPagedMCPFlags(cmd *cobra.Command) {
cmd.Flags().Bool("page-all", false, "自动按 nextCursor 拉取所有分页;未设置时保持单页调用")
cmd.Flags().Int("page-limit", defaultPagedCommandPageLimit, "自动翻页最多请求页数(默认 50,范围 1-500;仅 --page-all 生效)")
cmd.Flags().Int("max-items", 0, "自动翻页最多返回条数(默认 0 表示不限制;仅 --page-all 生效)")
cmd.Flags().Int("page-delay", defaultPagedCommandDelayMS, "自动翻页每页之间等待毫秒数(默认 200;0 表示不等待;仅 --page-all 生效)")
}
func RunPagedMCPCommand(cmd *cobra.Command, cfg PagedMCPCommandConfig) error {
args, err := cfg.BuildArgs(cmd)
if err != nil {
return err
}
opts, err := readPagedCommandOptions(cmd)
if err != nil {
return err
}
if !opts.pageAll {
return cfg.Fallback(args)
}
if err := validatePagedConfig(cfg); err != nil {
return err
}
if deps.Caller.DryRun() {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
"request": map[string]any{
"server": cfg.ServerID,
"name": cfg.ToolName,
"args": args,
},
"paging": map[string]any{
"pageAll": true,
"pageLimit": opts.pageLimit,
"maxItems": opts.maxItems,
"pageDelay": opts.delayMS,
},
})
}
return runPagedMCPCommand(cmd, cfg, opts, args)
}
func readPagedCommandOptions(cmd *cobra.Command) (pagedCommandOptions, error) {
pageAll, _ := cmd.Flags().GetBool("page-all")
opts := pagedCommandOptions{pageAll: pageAll}
if !pageAll {
return opts, nil
}
opts.pageLimit, _ = cmd.Flags().GetInt("page-limit")
if opts.pageLimit < 1 || opts.pageLimit > maxPagedCommandPageLimit {
return opts, fmt.Errorf("--page-limit must be between 1 and 500")
}
opts.maxItems, _ = cmd.Flags().GetInt("max-items")
if opts.maxItems < 0 {
return opts, fmt.Errorf("--max-items must be greater than or equal to 0")
}
opts.delayMS, _ = cmd.Flags().GetInt("page-delay")
if opts.delayMS < 0 {
return opts, fmt.Errorf("--page-delay must be greater than or equal to 0")
}
return opts, nil
}
func validatePagedConfig(cfg PagedMCPCommandConfig) error {
switch {
case strings.TrimSpace(cfg.ServerID) == "":
return fmt.Errorf("paged command server is required")
case strings.TrimSpace(cfg.ToolName) == "":
return fmt.Errorf("paged command tool is required")
case strings.TrimSpace(cfg.ItemPath) == "":
return fmt.Errorf("paged command item path is required")
case strings.TrimSpace(cfg.CursorPath) == "":
return fmt.Errorf("paged command cursor path is required")
case strings.TrimSpace(cfg.HasMorePath) == "":
return fmt.Errorf("paged command hasMore path is required")
case strings.TrimSpace(cfg.CursorArg) == "":
return fmt.Errorf("paged command cursor arg is required")
case cfg.BuildArgs == nil || cfg.Fallback == nil:
return fmt.Errorf("paged command callbacks are required")
default:
return nil
}
}
func runPagedMCPCommand(cmd *cobra.Command, cfg PagedMCPCommandConfig, opts pagedCommandOptions, args map[string]any) error {
var envelope map[string]any
ctx := cmd.Context()
items := newPagedCollection(cfg)
seenCursors := map[string]bool{}
currentCursor := cursorValueKey(args[cfg.CursorArg], cfg.CursorKind)
lastCursor := args[cfg.CursorArg]
hasMore := true
for page := 1; page <= opts.pageLimit && hasMore; page++ {
pageCursor := args[cfg.CursorArg]
seenCursors[currentCursor] = true
text, err := callMCPToolReturnTextOnServer(ctx, cfg.ServerID, cfg.ToolName, args)
if err != nil {
return handlePagedCommandError(cmd, envelope, cfg, items, page, currentCursor, err)
}
parsed, pageItems, nextCursor, more, err := parsePagedCommandPage(text, cfg)
if err != nil {
return handlePagedCommandError(cmd, envelope, cfg, items, page, currentCursor, err)
}
if envelope == nil {
envelope = parsed
}
if err := items.Add(pageItems); err != nil {
return handlePagedCommandError(cmd, envelope, cfg, items, page, currentCursor, err)
}
hasMore = more
if opts.maxItems > 0 && items.Total() > opts.maxItems {
items.Truncate(opts.maxItems)
return writePagedCommandResult(envelope, cfg, items, pagingMetadata{
Truncated: true,
HasMore: true,
LastCursor: pageCursor,
Pages: page,
Total: items.Total(),
TruncatedWithinPage: true,
})
}
lastCursor = nextCursor
if opts.maxItems > 0 && items.Total() == opts.maxItems && hasMore {
return writePagedCommandResult(envelope, cfg, items, pagingMetadata{
Truncated: true,
HasMore: true,
LastCursor: lastCursor,
Pages: page,
Total: items.Total(),
})
}
if !hasMore {
return writePagedCommandResult(envelope, cfg, items, pagingMetadata{
Truncated: false,
HasMore: false,
LastCursor: lastCursor,
Pages: page,
Total: items.Total(),
})
}
nextKey := cursorValueKey(nextCursor, cfg.CursorKind)
if nextKey == "" || nextKey == currentCursor || seenCursors[nextKey] {
err := fmt.Errorf("pagination cursor did not advance: %s", nextKey)
return handlePagedCommandError(cmd, envelope, cfg, items, page+1, nextKey, err)
}
normalizedCursor, err := normalizeCursorArg(nextCursor, cfg.CursorKind)
if err != nil {
return handlePagedCommandError(cmd, envelope, cfg, items, page+1, nextKey, err)
}
currentCursor = nextKey
args[cfg.CursorArg] = normalizedCursor
if opts.delayMS > 0 {
if err := sleepPagedCommandDelay(ctx, time.Duration(opts.delayMS)*time.Millisecond); err != nil {
return handlePagedCommandError(cmd, envelope, cfg, items, page+1, currentCursor, err)
}
}
}
return writePagedCommandResult(envelope, cfg, items, pagingMetadata{
Truncated: hasMore,
HasMore: hasMore,
LastCursor: lastCursor,
Pages: opts.pageLimit,
Total: items.Total(),
})
}
func parsePagedCommandPage(text string, cfg PagedMCPCommandConfig) (map[string]any, []any, any, bool, error) {
var parsed map[string]any
if err := json.Unmarshal([]byte(text), &parsed); err != nil {
return nil, nil, nil, false, fmt.Errorf("parse paged response JSON: %w", err)
}
rawHasMore, ok := getJSONPath(parsed, cfg.HasMorePath)
if !ok {
return nil, nil, nil, false, fmt.Errorf("paged response missing %s", cfg.HasMorePath)
}
hasMore, ok := rawHasMore.(bool)
if !ok {
return nil, nil, nil, false, fmt.Errorf("paged response %s must be boolean", cfg.HasMorePath)
}
rawItems, ok := getJSONPath(parsed, cfg.ItemPath)
if !ok && cfg.AggregationMode == PagedAggregationConversationMessages && !hasMore {
rawItems = []any{}
ok = true
}
if !ok {
return nil, nil, nil, false, fmt.Errorf("paged response missing %s", cfg.ItemPath)
}
items, ok := rawItems.([]any)
if !ok {
return nil, nil, nil, false, fmt.Errorf("paged response %s must be array", cfg.ItemPath)
}
nextCursor, ok := getJSONPath(parsed, cfg.CursorPath)
if hasMore && !ok {
return nil, nil, nil, false, fmt.Errorf("paged response missing %s", cfg.CursorPath)
}
return parsed, items, nextCursor, hasMore, nil
}
type pagingMetadata struct {
Truncated bool
HasMore bool
LastCursor any
Pages int
Total int
TruncatedWithinPage bool
Partial bool
FailedPage int
FailedCursor string
PagesFetched int
ItemsFetched int
Error string
}
func handlePagedCommandError(cmd *cobra.Command, envelope map[string]any, cfg PagedMCPCommandConfig, items *pagedCollection, failedPage int, failedCursor string, err error) error {
if envelope == nil {
return err
}
fmt.Fprintf(cmd.ErrOrStderr(), "pagination stopped at page %d: %v\n", failedPage, err)
if outputErr := writePagedCommandResult(envelope, cfg, items, pagingMetadata{
Truncated: true,
HasMore: true,
LastCursor: failedCursor,
Pages: failedPage - 1,
Total: items.Total(),
Partial: true,
FailedPage: failedPage,
FailedCursor: failedCursor,
PagesFetched: failedPage - 1,
ItemsFetched: items.Total(),
Error: err.Error(),
}); outputErr != nil {
return errors.Join(err, outputErr)
}
return err
}
func writePagedCommandResult(envelope map[string]any, cfg PagedMCPCommandConfig, items *pagedCollection, meta pagingMetadata) error {
_ = setJSONPath(envelope, cfg.ItemPath, items.Values())
_ = setJSONPath(envelope, cfg.HasMorePath, meta.HasMore)
_ = setJSONPath(envelope, cfg.CursorPath, meta.LastCursor)
paging := map[string]any{
"truncated": meta.Truncated,
"hasMore": meta.HasMore,
"lastCursor": meta.LastCursor,
"pages": meta.Pages,
"total": meta.Total,
}
if meta.Partial {
paging["partial"] = true
paging["failedPage"] = meta.FailedPage
paging["failedCursor"] = meta.FailedCursor
paging["pagesFetched"] = meta.PagesFetched
paging["itemsFetched"] = meta.ItemsFetched
paging["error"] = meta.Error
}
if meta.TruncatedWithinPage {
paging["truncatedWithinPage"] = true
paging["resumeCursorReliable"] = false
}
envelope["paging"] = paging
return deps.Out.PrintJSON(envelope)
}
func sleepPagedCommandDelay(ctx context.Context, delay time.Duration) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-helperAfter(delay):
return nil
}
}
type pagedCollection struct {
mode PagedAggregationMode
items []any
conversationIndex map[string]int
total int
}
func newPagedCollection(cfg PagedMCPCommandConfig) *pagedCollection {
return &pagedCollection{
mode: cfg.AggregationMode,
conversationIndex: map[string]int{},
}
}
func (c *pagedCollection) Add(items []any) error {
if c.mode != PagedAggregationConversationMessages {
c.items = append(c.items, items...)
c.total = len(c.items)
return nil
}
for _, item := range items {
if err := c.addConversation(item); err != nil {
return err
}
}
return nil
}
func (c *pagedCollection) Values() []any {
if c.items == nil {
return []any{}
}
return c.items
}
func (c *pagedCollection) Total() int {
return c.total
}
func (c *pagedCollection) Truncate(maxItems int) bool {
if maxItems <= 0 || c.total <= maxItems {
return false
}
if c.mode != PagedAggregationConversationMessages {
c.items = c.items[:maxItems]
c.total = len(c.items)
return true
}
c.truncateConversationMessages(maxItems)
return true
}
func (c *pagedCollection) addConversation(item any) error {
conversation, ok := item.(map[string]any)
if !ok {
return fmt.Errorf("paged response conversation item must be object")
}
key, _ := conversation["openConversationId"].(string)
if strings.TrimSpace(key) == "" {
return fmt.Errorf("paged response conversation item missing openConversationId")
}
messages, err := conversationMessages(conversation)
if err != nil {
return err
}
if idx, ok := c.conversationIndex[key]; ok {
existing := c.items[idx].(map[string]any)
existingMessages, err := conversationMessages(existing)
if err != nil {
return err
}
existing["messages"] = append(existingMessages, messages...)
c.total += len(messages)
return nil
}
c.conversationIndex[key] = len(c.items)
c.items = append(c.items, conversation)
c.total += len(messages)
return nil
}
func (c *pagedCollection) truncateConversationMessages(maxItems int) {
remaining := maxItems
for i, item := range c.items {
conversation := item.(map[string]any)
messages, _ := conversationMessages(conversation)
if remaining >= len(messages) {
remaining -= len(messages)
continue
}
if remaining == 0 {
c.items = c.items[:i]
c.total = maxItems
return
}
conversation["messages"] = messages[:remaining]
c.items = c.items[:i+1]
c.total = maxItems
return
}
}
func conversationMessages(conversation map[string]any) ([]any, error) {
raw, ok := conversation["messages"]
if !ok {
return []any{}, nil
}
messages, ok := raw.([]any)
if !ok {
return nil, fmt.Errorf("paged response conversation messages must be array")
}
return messages, nil
}
func getJSONPath(root map[string]any, path string) (any, bool) {
var current any = root
for _, part := range strings.Split(path, ".") {
obj, ok := current.(map[string]any)
if !ok {
return nil, false
}
current, ok = obj[part]
if !ok {
return nil, false
}
}
return current, true
}
func setJSONPath(root map[string]any, path string, value any) bool {
parts := strings.Split(path, ".")
current := root
for _, part := range parts[:len(parts)-1] {
next, ok := current[part].(map[string]any)
if !ok {
return false
}
current = next
}
current[parts[len(parts)-1]] = value
return true
}
func cursorValueKey(value any, kind PagedCursorKind) string {
switch kind {
case PagedCursorInt64:
switch v := value.(type) {
case int64:
return strconv.FormatInt(v, 10)
case int:
return strconv.Itoa(v)
case float64:
return strconv.FormatInt(int64(v), 10)
case string:
return strings.TrimSpace(v)
default:
return ""
}
default:
if value == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(value))
}
}
func normalizeCursorArg(value any, kind PagedCursorKind) (any, error) {
if kind != PagedCursorInt64 {
if value == nil {
return "", nil
}
return fmt.Sprint(value), nil
}
switch v := value.(type) {
case int64:
return v, nil
case int:
return int64(v), nil
case float64:
converted := int64(v)
if float64(converted) != v {
return nil, fmt.Errorf("paged response cursor must be an integer, got %v", v)
}
return converted, nil
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err == nil {
return parsed, nil
}
return nil, fmt.Errorf("paged response cursor must be a base-10 int64 string, got %q", v)
}
return nil, fmt.Errorf("paged response cursor must be int64-compatible, got %T", value)
}
+973
View File
@@ -0,0 +1,973 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type pagedCommandCall struct {
server string
tool string
args map[string]any
ctx context.Context
}
type pagedCommandCaller struct {
steps []scriptedToolStep
calls []pagedCommandCall
format string
dry bool
}
func (c *pagedCommandCaller) CallTool(ctx context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) {
copied := map[string]any{}
for k, v := range args {
copied[k] = v
}
c.calls = append(c.calls, pagedCommandCall{server: serverID, tool: toolName, args: copied, ctx: ctx})
if len(c.steps) == 0 {
return textToolResult(`{"result":{"messages":[],"hasMore":false}}`), nil
}
step := c.steps[len(c.calls)-1]
if step.err != nil {
return nil, step.err
}
return textToolResult(step.text), nil
}
func (c *pagedCommandCaller) Format() string { return c.format }
func (c *pagedCommandCaller) DryRun() bool { return c.dry }
func (*pagedCommandCaller) Fields() string { return "" }
func (*pagedCommandCaller) JQ() string { return "" }
func runPagedCommandTest(t *testing.T, caller *pagedCommandCaller, cfg PagedMCPCommandConfig, args ...string) (map[string]any, string, error) {
t.Helper()
return runPagedCommandTestWithSleep(t, caller, cfg, func(time.Duration) {}, args...)
}
func runPagedCommandTestWithSleep(t *testing.T, caller *pagedCommandCaller, cfg PagedMCPCommandConfig, sleep func(time.Duration), args ...string) (map[string]any, string, error) {
t.Helper()
out, stderr, err := executePagedCommandTest(t, caller, cfg, sleep, &bytes.Buffer{}, args...)
if strings.TrimSpace(out) == "" {
return nil, stderr, err
}
var parsed map[string]any
if unmarshalErr := json.Unmarshal([]byte(out), &parsed); unmarshalErr != nil {
t.Fatalf("stdout JSON = %q, err = %v", out, unmarshalErr)
}
return parsed, stderr, err
}
func executePagedCommandTest(t *testing.T, caller *pagedCommandCaller, cfg PagedMCPCommandConfig, sleep func(time.Duration), stdout io.Writer, args ...string) (string, string, error) {
t.Helper()
return executePagedCommandTestWithContext(t, context.Background(), caller, cfg, sleep, stdout, args...)
}
func executePagedCommandTestWithContext(t *testing.T, ctx context.Context, caller *pagedCommandCaller, cfg PagedMCPCommandConfig, sleep func(time.Duration), stdout io.Writer, args ...string) (string, string, error) {
t.Helper()
oldDeps := deps
oldSleep := helperSleep
oldAfter := helperAfter
t.Cleanup(func() {
deps = oldDeps
helperSleep = oldSleep
helperAfter = oldAfter
})
InitDeps(caller)
out := stdout
errOut := &bytes.Buffer{}
deps.Out.w = out
deps.Out.errW = errOut
if sleep != nil {
helperSleep = sleep
helperAfter = func(d time.Duration) <-chan time.Time {
sleep(d)
ch := make(chan time.Time, 1)
ch <- time.Now()
return ch
}
}
cmd := &cobra.Command{
Use: "paged",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, _ []string) error {
return RunPagedMCPCommand(cmd, cfg)
},
}
cmd.SetContext(ctx)
cmd.Flags().String("cursor", "0", "")
AddPagedMCPFlags(cmd)
cmd.SetErr(errOut)
cmd.SetArgs(args)
err := cmd.Execute()
if buf, ok := out.(*bytes.Buffer); ok {
return buf.String(), errOut.String(), err
}
return "", errOut.String(), err
}
func pagedCommandMessagesConfig(fallback func(map[string]any) error) PagedMCPCommandConfig {
if fallback == nil {
fallback = func(map[string]any) error { return nil }
}
return PagedMCPCommandConfig{
ServerID: "chat",
ToolName: "search_messages_by_time_range",
ItemPath: "result.messages",
CursorPath: "result.nextCursor",
HasMorePath: "result.hasMore",
CursorArg: "cursor",
CursorKind: PagedCursorString,
BuildArgs: func(cmd *cobra.Command) (map[string]any, error) {
cursor, _ := cmd.Flags().GetString("cursor")
return map[string]any{"cursor": cursor, "limit": 2}, nil
},
Fallback: fallback,
}
}
func pagedCommandConversationMessagesConfig(fallback func(map[string]any) error) PagedMCPCommandConfig {
cfg := pagedCommandMessagesConfig(fallback)
cfg.ItemPath = "result.conversationMessagesList"
cfg.AggregationMode = PagedAggregationConversationMessages
return cfg
}
func TestPagedMCPCommandDefaultUsesFallbackOnly(t *testing.T) {
caller := &pagedCommandCaller{}
fallbackCalls := 0
cfg := pagedCommandMessagesConfig(func(args map[string]any) error {
fallbackCalls++
if args["cursor"] != "0" {
t.Fatalf("fallback args = %#v", args)
}
return nil
})
_, _, err := runPagedCommandTest(t, caller, cfg, "--page-limit", "2", "--max-items", "1", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
if fallbackCalls != 1 || len(caller.calls) != 0 {
t.Fatalf("fallback=%d remote=%d, want fallback only", fallbackCalls, len(caller.calls))
}
}
func TestPagedMCPCommandRejectsInvalidConfigWhenPageAll(t *testing.T) {
caller := &pagedCommandCaller{}
cfg := pagedCommandMessagesConfig(nil)
cfg.ServerID = " "
got, _, err := runPagedCommandTest(t, caller, cfg, "--page-all")
if err == nil || !strings.Contains(err.Error(), "server is required") {
t.Fatalf("result=%#v err=%v, want config error", got, err)
}
if got != nil {
t.Fatalf("result=%#v, want no stdout", got)
}
if len(caller.calls) != 0 {
t.Fatalf("calls=%#v, want no remote call", caller.calls)
}
}
func TestPagedMCPCommandDryRunPrintsRequestAndSkipsRemote(t *testing.T) {
caller := &pagedCommandCaller{dry: true}
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-limit", "3", "--max-items", "7", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 0 {
t.Fatalf("calls=%#v, want no remote call", caller.calls)
}
if got["dry_run"] != true {
t.Fatalf("dry_run=%#v, want true", got["dry_run"])
}
request := got["request"].(map[string]any)
if request["server"] != "chat" || request["name"] != "search_messages_by_time_range" {
t.Fatalf("request=%#v", request)
}
args := request["args"].(map[string]any)
if args["cursor"] != "0" || args["limit"].(float64) != 2 {
t.Fatalf("args=%#v", args)
}
paging := got["paging"].(map[string]any)
if paging["pageAll"] != true || paging["pageLimit"].(float64) != 3 || paging["maxItems"].(float64) != 7 || paging["pageDelay"].(float64) != 0 {
t.Fatalf("paging=%#v", paging)
}
}
func TestPagedMCPCommandValidateConfigRejectsMissingRequiredFields(t *testing.T) {
tests := []struct {
name string
edit func(*PagedMCPCommandConfig)
want string
}{
{
name: "server",
edit: func(cfg *PagedMCPCommandConfig) {
cfg.ServerID = ""
},
want: "server is required",
},
{
name: "tool",
edit: func(cfg *PagedMCPCommandConfig) {
cfg.ToolName = ""
},
want: "tool is required",
},
{
name: "item path",
edit: func(cfg *PagedMCPCommandConfig) {
cfg.ItemPath = ""
},
want: "item path is required",
},
{
name: "cursor path",
edit: func(cfg *PagedMCPCommandConfig) {
cfg.CursorPath = ""
},
want: "cursor path is required",
},
{
name: "hasMore path",
edit: func(cfg *PagedMCPCommandConfig) {
cfg.HasMorePath = ""
},
want: "hasMore path is required",
},
{
name: "cursor arg",
edit: func(cfg *PagedMCPCommandConfig) {
cfg.CursorArg = ""
},
want: "cursor arg is required",
},
{
name: "build args callback",
edit: func(cfg *PagedMCPCommandConfig) {
cfg.BuildArgs = nil
},
want: "callbacks are required",
},
{
name: "fallback callback",
edit: func(cfg *PagedMCPCommandConfig) {
cfg.Fallback = nil
},
want: "callbacks are required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := pagedCommandMessagesConfig(nil)
tt.edit(&cfg)
err := validatePagedConfig(cfg)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("err=%v, want %q", err, tt.want)
}
})
}
}
func TestPagedMCPCommandStringCursorAggregatesAndPageLimit(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`},
{text: `{"result":{"messages":[{"id":"m2"}],"hasMore":true,"nextCursor":"c3"}}`},
}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-limit", "2", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
items := got["result"].(map[string]any)["messages"].([]any)
result := got["result"].(map[string]any)
paging := got["paging"].(map[string]any)
if len(items) != 2 || paging["truncated"] != true || paging["pages"].(float64) != 2 {
t.Fatalf("result = %#v", got)
}
if result["hasMore"] != true || result["nextCursor"] != "c3" {
t.Fatalf("result=%#v, want final page-limit cursor state", result)
}
if caller.calls[0].args["cursor"] != "0" || caller.calls[1].args["cursor"] != "c2" {
t.Fatalf("call args = %#v", caller.calls)
}
}
func TestPagedMCPCommandStringCursorAggregatesAndSyncsCompletionFields(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`},
{text: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":""}}`},
}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
result := got["result"].(map[string]any)
items := result["messages"].([]any)
if len(items) != 2 || result["hasMore"] != false || result["nextCursor"] != "" {
t.Fatalf("result=%#v, want complete aggregate with final cursor state", result)
}
paging := got["paging"].(map[string]any)
if paging["truncated"] != false || paging["hasMore"] != false || paging["lastCursor"] != "" {
t.Fatalf("paging=%#v, want complete pagination metadata", paging)
}
}
func TestPagedMCPCommandConversationMessagesMergeSameConversation(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`},
{text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"ignored","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`},
}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any)
if len(conversations) != 1 {
t.Fatalf("conversations=%#v, want one merged conversation", conversations)
}
conversation := conversations[0].(map[string]any)
messages := conversation["messages"].([]any)
if conversation["title"] != "群1" || len(messages) != 2 {
t.Fatalf("conversation=%#v, want preserved title and two messages", conversation)
}
paging := got["paging"].(map[string]any)
if paging["total"].(float64) != 2 {
t.Fatalf("paging=%#v, want total message count 2", paging)
}
}
func TestPagedMCPCommandConversationMessagesPreserveFirstConversationOrder(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid2","messages":[{"id":"m2"}]},{"openConversationId":"cid1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`},
{text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid3","messages":[{"id":"m3"}]}],"hasMore":false,"nextCursor":""}}`},
}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any)
gotIDs := []string{
conversations[0].(map[string]any)["openConversationId"].(string),
conversations[1].(map[string]any)["openConversationId"].(string),
conversations[2].(map[string]any)["openConversationId"].(string),
}
if strings.Join(gotIDs, ",") != "cid2,cid1,cid3" {
t.Fatalf("conversation order=%v", gotIDs)
}
}
func TestPagedMCPCommandConversationMessagesMaxItemsTruncatesMessages(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","messages":[{"id":"m1"},{"id":"m2"}]},{"openConversationId":"cid2","messages":[{"id":"m3"},{"id":"m4"}]}],"hasMore":true,"nextCursor":"c2"}}`},
}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--max-items", "3", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any)
if len(conversations) != 2 {
t.Fatalf("conversations=%#v, want two conversations", conversations)
}
secondMessages := conversations[1].(map[string]any)["messages"].([]any)
paging := got["paging"].(map[string]any)
if len(secondMessages) != 1 || paging["total"].(float64) != 3 || paging["truncated"] != true {
t.Fatalf("result=%#v", got)
}
}
func TestPagedMCPCommandConversationMessagesMissingListOnFinalPageIsEmpty(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"hasMore":false,"nextCursor":""}}`},
}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any)
if len(conversations) != 0 {
t.Fatalf("conversations=%#v, want empty", conversations)
}
}
func TestPagedMCPCommandConversationMessagesLaterFailureOutputsPartial(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`},
{err: errors.New("page failed")},
}}
got, stderr, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--page-delay", "0")
if err == nil || !strings.Contains(stderr, "pagination stopped") {
t.Fatalf("err=%v stderr=%q", err, stderr)
}
conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any)
paging := got["paging"].(map[string]any)
if len(conversations) != 1 || paging["partial"] != true || paging["itemsFetched"].(float64) != 1 {
t.Fatalf("result=%#v", got)
}
}
func TestPagedMCPCommandConversationMessagesAddErrorsOutputPartial(t *testing.T) {
tests := []struct {
name string
response string
want string
}{
{
name: "conversation item not object",
response: `{"result":{"conversationMessagesList":["bad"],"hasMore":false,"nextCursor":""}}`,
want: "conversation item must be object",
},
{
name: "conversation missing openConversationId",
response: `{"result":{"conversationMessagesList":[{"messages":[]}],"hasMore":false,"nextCursor":""}}`,
want: "missing openConversationId",
},
{
name: "conversation messages not array",
response: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","messages":"bad"}],"hasMore":false,"nextCursor":""}}`,
want: "conversation messages must be array",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{{text: tt.response}}}
got, stderr, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--page-delay", "0")
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("result=%#v err=%v, want %q", got, err, tt.want)
}
if !strings.Contains(stderr, "pagination stopped at page 1") {
t.Fatalf("stderr=%q", stderr)
}
paging := got["paging"].(map[string]any)
if paging["partial"] != true || paging["itemsFetched"].(float64) != 0 {
t.Fatalf("paging=%#v", paging)
}
})
}
}
func TestPagedMCPCommandConversationMessagesMaxItemsTruncatesAtConversationBoundary(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","messages":[{"id":"m1"},{"id":"m2"}]},{"openConversationId":"cid2","messages":[{"id":"m3"},{"id":"m4"}]}],"hasMore":true,"nextCursor":"c2"}}`},
}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--max-items", "2", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any)
paging := got["paging"].(map[string]any)
if len(conversations) != 1 || paging["total"].(float64) != 2 || paging["truncated"] != true {
t.Fatalf("result=%#v", got)
}
}
func TestPagedMCPCommandConversationMessagesAcceptsMissingMessages(t *testing.T) {
messages, err := conversationMessages(map[string]any{"openConversationId": "cid1"})
if err != nil {
t.Fatal(err)
}
if len(messages) != 0 {
t.Fatalf("messages=%#v, want empty", messages)
}
}
func TestPagedMCPCommandConversationMessagesRejectsCorruptExistingMessages(t *testing.T) {
collection := newPagedCollection(PagedMCPCommandConfig{AggregationMode: PagedAggregationConversationMessages})
collection.items = []any{map[string]any{"openConversationId": "cid1", "messages": "bad"}}
collection.conversationIndex["cid1"] = 0
err := collection.Add([]any{map[string]any{"openConversationId": "cid1", "messages": []any{map[string]any{"id": "m2"}}}})
if err == nil || !strings.Contains(err.Error(), "conversation messages must be array") {
t.Fatalf("err=%v, want corrupt existing messages error", err)
}
}
func TestPagedMCPCommandResponseShapeErrors(t *testing.T) {
tests := []struct {
name string
response string
want string
}{
{
name: "items not array",
response: `{"result":{"messages":"bad","hasMore":false}}`,
want: "result.messages must be array",
},
{
name: "missing hasMore",
response: `{"result":{"messages":[]}}`,
want: "missing result.hasMore",
},
{
name: "hasMore not bool",
response: `{"result":{"messages":[],"hasMore":"yes"}}`,
want: "result.hasMore must be boolean",
},
{
name: "missing next cursor",
response: `{"result":{"messages":[{"id":"m1"}],"hasMore":true}}`,
want: "missing result.nextCursor",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{{text: tt.response}}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-delay", "0")
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("result=%#v err=%v, want %q", got, err, tt.want)
}
if got != nil {
t.Fatalf("result=%#v, want no partial stdout", got)
}
})
}
}
func TestPagedMCPCommandPageDelayControlsSleep(t *testing.T) {
tests := []struct {
name string
delay string
wantSleeps []time.Duration
}{
{
name: "non zero delay sleeps between pages",
delay: "200",
wantSleeps: []time.Duration{200 * time.Millisecond},
},
{
name: "zero delay skips sleep",
delay: "0",
wantSleeps: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`},
{text: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":""}}`},
}}
var sleeps []time.Duration
got, _, err := runPagedCommandTestWithSleep(t, caller, pagedCommandMessagesConfig(nil), func(d time.Duration) {
sleeps = append(sleeps, d)
}, "--page-all", "--page-delay", tt.delay)
if err != nil {
t.Fatal(err)
}
items := got["result"].(map[string]any)["messages"].([]any)
if len(items) != 2 || len(caller.calls) != 2 {
t.Fatalf("items=%#v calls=%#v", items, caller.calls)
}
if len(sleeps) != len(tt.wantSleeps) {
t.Fatalf("sleeps=%v, want %v", sleeps, tt.wantSleeps)
}
for i := range tt.wantSleeps {
if sleeps[i] != tt.wantSleeps[i] {
t.Fatalf("sleeps=%v, want %v", sleeps, tt.wantSleeps)
}
}
})
}
}
func TestPagedMCPCommandMaxItemsTruncatesPrecisely(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"},{"id":"m2"}],"hasMore":true,"nextCursor":"c2"}}`},
}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--max-items", "1", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
items := got["result"].(map[string]any)["messages"].([]any)
paging := got["paging"].(map[string]any)
if len(items) != 1 || paging["total"].(float64) != 1 || paging["truncated"] != true {
t.Fatalf("result = %#v", got)
}
}
func TestPagedMCPCommandMaxItemsStopsWhenPageExactlyReachesLimit(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"},{"id":"m2"}],"hasMore":true,"nextCursor":"c2"}}`},
{err: errors.New("second page should not run")},
}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--max-items", "2", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
items := got["result"].(map[string]any)["messages"].([]any)
result := got["result"].(map[string]any)
paging := got["paging"].(map[string]any)
if len(caller.calls) != 1 || len(items) != 2 {
t.Fatalf("calls=%#v items=%#v, want one full page", caller.calls, items)
}
if paging["truncated"] != true || paging["hasMore"] != true || paging["lastCursor"] != "c2" {
t.Fatalf("paging=%#v, want safe page-boundary cursor", paging)
}
if result["hasMore"] != true || result["nextCursor"] != "c2" {
t.Fatalf("result=%#v, want safe page-boundary cursor fields", result)
}
if _, ok := paging["truncatedWithinPage"]; ok {
t.Fatalf("paging=%#v, want no within-page truncation marker", paging)
}
}
func TestPagedMCPCommandMaxItemsWithinPageKeepsCurrentCursor(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"},{"id":"m2"}],"hasMore":true,"nextCursor":"c2"}}`},
}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--max-items", "1", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
items := got["result"].(map[string]any)["messages"].([]any)
result := got["result"].(map[string]any)
paging := got["paging"].(map[string]any)
if len(items) != 1 || paging["lastCursor"] != "0" {
t.Fatalf("result=%#v, want current-page cursor after within-page truncation", got)
}
if result["hasMore"] != true || result["nextCursor"] != "0" {
t.Fatalf("result=%#v, want unreliable current-page cursor fields", result)
}
if paging["truncatedWithinPage"] != true || paging["resumeCursorReliable"] != false {
t.Fatalf("paging=%#v, want unreliable resume marker", paging)
}
}
func TestPagedCollectionTruncateReturnsFalseWhenLimitDoesNotTrim(t *testing.T) {
collection := newPagedCollection(PagedMCPCommandConfig{})
if err := collection.Add([]any{map[string]any{"id": "m1"}}); err != nil {
t.Fatal(err)
}
if collection.Truncate(0) {
t.Fatal("Truncate(0) should not trim")
}
if collection.Truncate(1) {
t.Fatal("Truncate(total) should not trim")
}
if collection.Total() != 1 || len(collection.Values()) != 1 {
t.Fatalf("collection=%#v, want unchanged single item", collection.Values())
}
}
func TestPagedMCPCommandConversationMessagesMaxItemsWithinPageKeepsCurrentCursor(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","messages":[{"id":"m1"},{"id":"m2"}]}],"hasMore":true,"nextCursor":"c2"}}`},
}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--max-items", "1", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any)
messages := conversations[0].(map[string]any)["messages"].([]any)
paging := got["paging"].(map[string]any)
if len(messages) != 1 || paging["lastCursor"] != "0" {
t.Fatalf("result=%#v, want truncated conversation with current-page cursor", got)
}
if paging["truncatedWithinPage"] != true || paging["resumeCursorReliable"] != false {
t.Fatalf("paging=%#v, want unreliable resume marker", paging)
}
}
func TestPagedMCPCommandPassesCommandContextToCaller(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":false,"nextCursor":""}}`},
}}
out, _, err := executePagedCommandTestWithContext(t, ctx, caller, pagedCommandMessagesConfig(nil), func(time.Duration) {}, &bytes.Buffer{}, "--page-all", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(out) == "" || len(caller.calls) != 1 {
t.Fatalf("stdout=%q calls=%#v, want one successful call", out, caller.calls)
}
if caller.calls[0].ctx != ctx || caller.calls[0].ctx.Err() != context.Canceled {
t.Fatalf("call ctx=%#v err=%v, want canceled command context", caller.calls[0].ctx, caller.calls[0].ctx.Err())
}
}
func TestPagedMCPCommandPageDelayStopsWhenContextCanceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`},
{text: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":""}}`},
}}
var out bytes.Buffer
stdout, stderr, err := executePagedCommandTestWithContext(t, ctx, caller, pagedCommandMessagesConfig(nil), nil, &out, "--page-all", "--page-delay", "10")
if strings.TrimSpace(stdout) == "" {
t.Fatal("stdout is empty, want partial pagination JSON")
}
if err == nil || !errors.Is(err, context.Canceled) {
t.Fatalf("err=%v, want context canceled", err)
}
if !strings.Contains(stderr, "pagination stopped at page 2") || len(caller.calls) != 1 {
t.Fatalf("stderr=%q calls=%#v, want cancellation before second call", stderr, caller.calls)
}
var got map[string]any
if unmarshalErr := json.Unmarshal([]byte(stdout), &got); unmarshalErr != nil {
t.Fatalf("stdout JSON = %q, err = %v", stdout, unmarshalErr)
}
paging := got["paging"].(map[string]any)
if paging["partial"] != true || paging["failedPage"].(float64) != 2 || paging["itemsFetched"].(float64) != 1 {
t.Fatalf("paging=%#v, want partial cancellation metadata", paging)
}
}
func TestPagedMCPCommandPropagatesAggregatedOutputErrors(t *testing.T) {
tests := []struct {
name string
steps []scriptedToolStep
args []string
wantStderr string
}{
{
name: "normal end",
steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":false,"nextCursor":""}}`},
},
args: []string{"--page-all", "--page-delay", "0"},
},
{
name: "max items truncation",
steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"},{"id":"m2"}],"hasMore":true,"nextCursor":"c2"}}`},
},
args: []string{"--page-all", "--max-items", "1", "--page-delay", "0"},
},
{
name: "page limit truncation",
steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`},
},
args: []string{"--page-all", "--page-limit", "1", "--page-delay", "0"},
},
{
name: "partial result after later failure",
steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`},
{err: errors.New("page failed")},
},
args: []string{"--page-all", "--page-delay", "0"},
wantStderr: "pagination stopped at page 2",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// TC-output-error: aggregate stdout write failures must fail the command.
caller := &pagedCommandCaller{steps: tt.steps}
_, stderr, err := executePagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), func(time.Duration) {}, failingWriter{}, tt.args...)
if err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("err=%v, want propagated write failure", err)
}
if tt.wantStderr != "" && !strings.Contains(stderr, tt.wantStderr) {
t.Fatalf("stderr=%q, want %q", stderr, tt.wantStderr)
}
})
}
}
func TestPagedMCPCommandInt64CursorAndItemsPath(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"items":[{"id":"f1"}],"hasMore":true,"nextCursor":20}}`},
{text: `{"result":{"items":[{"id":"f2"}],"hasMore":false,"nextCursor":0}}`},
}}
cfg := PagedMCPCommandConfig{
ServerID: "im",
ToolName: "list_message_favorites",
ItemPath: "result.items",
CursorPath: "result.nextCursor",
HasMorePath: "result.hasMore",
CursorArg: "cursor",
CursorKind: PagedCursorInt64,
BuildArgs: func(cmd *cobra.Command) (map[string]any, error) {
return map[string]any{"cursor": int64(0), "size": "20"}, nil
},
Fallback: func(map[string]any) error { return nil },
}
got, _, err := runPagedCommandTest(t, caller, cfg, "--page-all", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
items := got["result"].(map[string]any)["items"].([]any)
if len(items) != 2 || caller.calls[1].args["cursor"] != int64(20) {
t.Fatalf("items=%#v calls=%#v", items, caller.calls)
}
}
func TestPagedMCPCommandInt64CursorRejectsNonNumericNextCursor(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"items":[{"id":"f1"}],"hasMore":true,"nextCursor":"not-a-number"}}`},
}}
cfg := PagedMCPCommandConfig{
ServerID: "im",
ToolName: "list_message_favorites",
ItemPath: "result.items",
CursorPath: "result.nextCursor",
HasMorePath: "result.hasMore",
CursorArg: "cursor",
CursorKind: PagedCursorInt64,
BuildArgs: func(cmd *cobra.Command) (map[string]any, error) {
return map[string]any{"cursor": int64(0), "size": "20"}, nil
},
Fallback: func(map[string]any) error { return nil },
}
got, stderr, err := runPagedCommandTest(t, caller, cfg, "--page-all", "--page-delay", "0")
if err == nil || !strings.Contains(err.Error(), "base-10 int64 string") {
t.Fatalf("err=%v, want invalid int64 cursor error", err)
}
if !strings.Contains(stderr, "pagination stopped at page 2") {
t.Fatalf("stderr=%q", stderr)
}
paging := got["paging"].(map[string]any)
if paging["partial"] != true || paging["failedCursor"] != "not-a-number" || paging["pagesFetched"].(float64) != 1 {
t.Fatalf("paging = %#v", paging)
}
if len(caller.calls) != 1 {
t.Fatalf("calls=%#v, want no second call with cursor 0", caller.calls)
}
}
func TestPagedMCPCommandFirstPageFailureReturnsNoPartial(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{{err: errors.New("boom")}}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all")
if err == nil || got != nil {
t.Fatalf("result=%#v err=%v, want first-page error without stdout", got, err)
}
}
func TestPagedMCPCommandLaterFailureOutputsPartial(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`},
{err: errors.New("page failed")},
}}
got, stderr, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-delay", "0")
if err == nil || !strings.Contains(stderr, "pagination stopped") {
t.Fatalf("err=%v stderr=%q", err, stderr)
}
paging := got["paging"].(map[string]any)
if paging["partial"] != true || paging["failedPage"].(float64) != 2 || paging["itemsFetched"].(float64) != 1 {
t.Fatalf("paging = %#v", paging)
}
}
func TestPagedMCPCommandCursorCycleOutputsPartial(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"0"}}`},
}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-delay", "0")
if err == nil {
t.Fatal("cursor cycle should return error")
}
paging := got["paging"].(map[string]any)
if paging["partial"] != true || paging["pagesFetched"].(float64) != 1 {
t.Fatalf("paging = %#v", paging)
}
}
func TestPagedMCPCommandSetJSONPathRejectsNonObjectIntermediate(t *testing.T) {
root := map[string]any{"result": "not-object"}
if setJSONPath(root, "result.messages", []any{}) {
t.Fatal("setJSONPath should reject a non-object intermediate")
}
if root["result"] != "not-object" {
t.Fatalf("root=%#v, want original intermediate preserved", root)
}
}
func TestPagedMCPCommandCursorValueKeyCoversBoundaryKinds(t *testing.T) {
if got := cursorValueKey(7, PagedCursorInt64); got != "7" {
t.Fatalf("int cursor key=%q, want 7", got)
}
if got := cursorValueKey(nil, PagedCursorString); got != "" {
t.Fatalf("nil string cursor key=%q, want empty", got)
}
}
func TestPagedMCPCommandNormalizeCursorArgCoversBoundaryKinds(t *testing.T) {
tests := []struct {
name string
value any
kind PagedCursorKind
want any
wantErr string
}{
{
name: "nil string cursor",
value: nil,
kind: PagedCursorString,
want: "",
},
{
name: "int64 cursor",
value: int64(9),
kind: PagedCursorInt64,
want: int64(9),
},
{
name: "int cursor",
value: 10,
kind: PagedCursorInt64,
want: int64(10),
},
{
name: "numeric string cursor",
value: " 11 ",
kind: PagedCursorInt64,
want: int64(11),
},
{
name: "fractional float cursor",
value: 1.5,
kind: PagedCursorInt64,
wantErr: "must be an integer",
},
{
name: "unsupported cursor type",
value: []string{"bad"},
kind: PagedCursorInt64,
wantErr: "int64-compatible",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := normalizeCursorArg(tt.value, tt.kind)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("value=%#v err=%v, want %q", tt.value, err, tt.wantErr)
}
return
}
if err != nil {
t.Fatal(err)
}
if got != tt.want {
t.Fatalf("value=%#v got=%#v, want %#v", tt.value, got, tt.want)
}
})
}
}
@@ -31,6 +31,17 @@ func executePR868Command(t *testing.T, root *cobra.Command, args ...string) erro
}
func TestCrossPlatformCoverageMinutesNewSurfaces(t *testing.T) {
t.Run("permission add requires explicit policy", func(t *testing.T) {
caller := &scriptedToolCaller{}
installScriptedCaller(t, caller)
err := executePR868Command(t, newMinutesCommand(), "permission", "add", "--ids", "task-1", "--member-uids", "user-1")
if err == nil || !strings.Contains(err.Error(), "--policy") {
t.Fatalf("permission add without --policy error = %v", err)
}
if caller.calls != 0 {
t.Fatalf("permission add called MCP %d times before required policy validation", caller.calls)
}
})
t.Run("hot-word delete dry-run", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete", "--words", "钉钉,OKR"); err != nil {
@@ -91,6 +102,21 @@ func TestCrossPlatformCoverageMinutesNewSurfaces(t *testing.T) {
t.Fatal("expected missing policy")
}
})
t.Run("permission apply policy flag is int", func(t *testing.T) {
// 数值参数声明为 int 类型 flag;必填校验走 cmd.Flags().Changed,
// 不能用 validateRequiredFlags(它把 int 零值当成未传)。
cmd, _, err := newMinutesCommand().Find([]string{"permission", "apply"})
if err != nil {
t.Fatalf("find permission apply: %v", err)
}
flag := cmd.Flags().Lookup("policy")
if flag == nil {
t.Fatal("flag --policy not found")
}
if flag.Value.Type() != "int" {
t.Fatalf("flag --policy type = %q, want %q", flag.Value.Type(), "int")
}
})
t.Run("audio-memo list default", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"items":[]}`}}}
@@ -11,6 +11,7 @@ import (
"time"
"unicode"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
@@ -157,7 +158,15 @@ func TestCrossPlatformCoverageProductCommandExamplesAreExecutableContracts(t *te
deps.Out.errW = io.Discard
// Product examples execute real RunE paths; whiteboard insert retries must
// not burn the suite timeout on real sleep (race CI uses a 12m package cap).
// Sheet export uses helperAfter for progressive polling, so keep that clock
// deterministic too; context deadlines alone still accumulate heavily under
// Windows coverage instrumentation.
whiteboardSleep = func(time.Duration) {}
testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time {
ch := make(chan time.Time, 1)
ch <- time.Now()
return ch
})
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
httpGetFile = func(_ context.Context, _ string, _ map[string]string, destPath string) error {
if destPath == "" {
+9
View File
@@ -229,6 +229,15 @@ func compareEffectiveFlags(result *Comparison, acceptedPath string, oldCommand,
After: newFlag.NoOpt,
})
}
if oldFlag.AliasOf != "" && newFlag.AliasOf != oldFlag.AliasOf {
result.Blocking = append(result.Blocking, Change{
Kind: "flag_alias_target_changed",
Path: acceptedPath,
Flag: name,
Before: oldFlag.AliasOf,
After: newFlag.AliasOf,
})
}
if !oldFlag.Hidden && newFlag.Hidden {
result.Blocking = append(result.Blocking, Change{
Kind: "flag_became_hidden",
+408 -2
View File
@@ -251,6 +251,40 @@ func TestCrossPlatformCoverageCompareBlocksCallableMetadataRegressions(t *testin
}
}
func TestCrossPlatformCoverageCompareBlocksFlagAliasTargetRegression(t *testing.T) {
baseLegacy := testFlag("legacy-id", "string", false)
baseLegacy.AliasOf = "message-id"
base := testSnapshot(
testCommand("dws"),
testCommand("dws send", baseLegacy, testFlag("message-id", "string", false)),
)
for _, target := range []string{"", "other-id"} {
t.Run("target_"+target, func(t *testing.T) {
currentLegacy := baseLegacy
currentLegacy.AliasOf = target
current := testSnapshot(
testCommand("dws"),
testCommand(
"dws send",
currentLegacy,
testFlag("message-id", "string", false),
testFlag("other-id", "string", false),
),
)
comparison := Compare(current, base, "base")
if comparison.Compatible || !hasFlagChange(
comparison.Blocking,
"flag_alias_target_changed",
"dws send",
"legacy-id",
) {
t.Fatalf("alias target regression was not blocked: %#v", comparison)
}
})
}
}
func TestCrossPlatformCoverageCompareUsesEffectiveFlagsAtEveryCommandPath(t *testing.T) {
base := testSnapshot(
testCommand("dws", testFlag("profile", "string", false)),
@@ -314,6 +348,58 @@ func TestCrossPlatformCoverageCompareAllRequiresBothReferencesToPass(t *testing.
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsAuthorizesOnlyTheExactTransition(t *testing.T) {
before := testFlagMigrationSnapshot(false, true)
authority := testFlagMigrationManifest(FlagMigrationPending)
candidate := testFlagMigrationManifest(FlagMigrationConsumed)
report, err := CompareAllWithFlagMigrations(
testFlagMigrationSnapshot(true, true),
map[string]Snapshot{
"merge-base": before,
"stable": before,
},
authority,
candidate,
)
if err != nil {
t.Fatalf("exact base-owned migration was rejected: %v", err)
}
if !report.Compatible {
t.Fatalf("exact migration remained incompatible: %#v", report.Comparisons)
}
for _, comparison := range report.Comparisons {
if len(comparison.Blocking) != 0 {
t.Fatalf("reference %q retained blocking changes: %#v", comparison.Reference, comparison.Blocking)
}
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsComposesWithReviewedTypeChange(t *testing.T) {
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
before := testFlagMigrationSnapshot(false, true)
before.Commands = append(before.Commands, testCommand("dws search", testFlag("query", "string", false)))
after := testFlagMigrationSnapshot(true, true)
after.Commands = append(after.Commands, testCommand("dws search", testFlag("query", "int", false)))
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{
"merge-base": before,
"stable": before,
},
testFlagMigrationManifest(FlagMigrationPending),
testFlagMigrationManifest(FlagMigrationConsumed),
)
if err != nil {
t.Fatalf("composed reviewed migrations were rejected: %v", err)
}
if !report.Compatible {
t.Fatalf("composed reviewed migrations remained incompatible: %#v", report.Comparisons)
}
}
// registerReviewedFixture puts a fixture command in the reviewed table for the
// duration of one test, so the behaviour tests exercise the real lookup instead
// of depending on whichever production entries happen to exist.
@@ -426,6 +512,12 @@ func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeRejectsBundledRegress
next: Flag{Name: "query", Type: "int", Hidden: true},
kind: "flag_became_hidden",
},
{
name: "changed alias target",
base: Flag{Name: "query", Type: "string", AliasOf: "old-query"},
next: Flag{Name: "query", Type: "int", AliasOf: "new-query"},
kind: "flag_alias_target_changed",
},
}
for _, test := range tests {
@@ -501,8 +593,8 @@ var reviewedEntryPattern = regexp.MustCompile(
const interfaceBaselineReviewedPath = "../../scripts/policy/interface-baseline/reviewed.go"
// The two copies of the table must stay identical, otherwise a migration passes
// one gate and is blocked by the other in the same CI job.
// The modern authority and legacy smoke helper keep mirrored tables so local
// smoke checks cannot disagree with the authoritative comparison.
func TestCrossPlatformCoverageReviewedFlagTypeTableMatchesInterfaceBaseline(t *testing.T) {
source, err := os.ReadFile(interfaceBaselineReviewedPath)
if err != nil {
@@ -533,6 +625,234 @@ func TestCrossPlatformCoverageReviewedFlagTypeTableMatchesInterfaceBaseline(t *t
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRejectsInvalidLifecycleTransitions(t *testing.T) {
empty := testEmptyFlagMigrationManifest()
pending := testFlagMigrationManifest(FlagMigrationPending)
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
before := testFlagMigrationSnapshot(false, true)
after := testFlagMigrationSnapshot(true, true)
partial := testFlagMigrationSnapshot(false, true)
partial.Commands[1].LocalFlags[0].Hidden = true
partial.Commands[1].LocalFlags[0].Required = false
partial.Commands[1].LocalFlags[0].AliasOf = "message-id"
typeDrift := testFlagMigrationSnapshot(true, true)
typeDrift.Commands[1].LocalFlags[0].Type = "stringSlice"
noOptDrift := testFlagMigrationSnapshot(true, true)
noOptDrift.Commands[1].LocalFlags[0].NoOpt = ""
shorthandDrift := testFlagMigrationSnapshot(true, true)
shorthandDrift.Commands[1].LocalFlags[0].Shorthand = "x"
tests := []struct {
name string
current Snapshot
authority FlagMigrationManifest
candidate FlagMigrationManifest
}{
{
name: "candidate-added pending record cannot approve its own surface change",
current: after,
authority: empty,
candidate: pending,
},
{
name: "candidate-added consumed record cannot approve its own surface change",
current: after,
authority: empty,
candidate: consumed,
},
{
name: "partial migration is rejected",
current: partial,
authority: pending,
candidate: consumed,
},
{
name: "pending record cannot be falsely consumed before the surface changes",
current: before,
authority: pending,
candidate: consumed,
},
{
name: "completed surface change must consume its pending record",
current: after,
authority: pending,
candidate: pending,
},
{
name: "legacy type drift is outside the approval",
current: typeDrift,
authority: pending,
candidate: consumed,
},
{
name: "legacy no-opt drift is outside the approval",
current: noOptDrift,
authority: pending,
candidate: consumed,
},
{
name: "legacy shorthand drift is outside the approval",
current: shorthandDrift,
authority: pending,
candidate: consumed,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
report, err := CompareAllWithFlagMigrations(
test.current,
map[string]Snapshot{
"merge-base": before,
"stable": before,
},
test.authority,
test.candidate,
)
if err == nil {
t.Fatalf("invalid transition passed: %#v", report)
}
})
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsAllowsApprovalCreationBeforeSurfaceChange(t *testing.T) {
before := testFlagMigrationSnapshot(false, true)
report, err := CompareAllWithFlagMigrations(
before,
map[string]Snapshot{
"merge-base": before,
"stable": before,
},
testEmptyFlagMigrationManifest(),
testFlagMigrationManifest(FlagMigrationPending),
)
if err != nil {
t.Fatalf("pending approval creation was rejected before the surface changed: %v", err)
}
if !report.Compatible {
t.Fatalf("pending approval creation changed compatibility: %#v", report.Comparisons)
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsKeepsUnrelatedBreakageBlocking(t *testing.T) {
before := testFlagMigrationSnapshot(false, true)
pending := testFlagMigrationManifest(FlagMigrationPending)
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
tests := []struct {
name string
mutate func(*Snapshot)
kind string
flag string
}{
{
name: "unrelated flag removal",
mutate: func(snapshot *Snapshot) {
snapshot.Commands[1].LocalFlags = snapshot.Commands[1].LocalFlags[:2]
},
kind: "flag_removed",
flag: "format",
},
{
name: "unrelated flag type change",
mutate: func(snapshot *Snapshot) {
snapshot.Commands[1].LocalFlags[2].Type = "stringSlice"
},
kind: "flag_type_changed",
flag: "format",
},
{
name: "unrelated flag no-opt change",
mutate: func(snapshot *Snapshot) {
snapshot.Commands[1].LocalFlags[2].NoOpt = ""
},
kind: "flag_no_opt_changed",
flag: "format",
},
{
name: "unrelated flag shorthand change",
mutate: func(snapshot *Snapshot) {
snapshot.Commands[1].LocalFlags[2].Shorthand = ""
},
kind: "flag_shorthand_changed",
flag: "format",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
current := testFlagMigrationSnapshot(true, true)
test.mutate(&current)
report, err := CompareAllWithFlagMigrations(
current,
map[string]Snapshot{
"merge-base": before,
"stable": before,
},
pending,
consumed,
)
if err != nil {
t.Fatalf("valid migration with unrelated breakage returned a lifecycle error: %v", err)
}
if report.Compatible {
t.Fatalf("unrelated breakage was hidden by migration approval: %#v", report)
}
for _, comparison := range report.Comparisons {
if !hasFlagChange(comparison.Blocking, test.kind, "dws chat send", test.flag) {
t.Fatalf("reference %q blocking=%#v, want %s for --%s", comparison.Reference, comparison.Blocking, test.kind, test.flag)
}
}
})
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRetainsConsumedReceiptUntilStableCatchesUp(t *testing.T) {
before := testFlagMigrationSnapshot(false, true)
after := testFlagMigrationSnapshot(true, true)
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{
"merge-base": after,
"stable": before,
},
consumed,
consumed,
)
if err != nil {
t.Fatalf("consumed receipt was rejected while stable still needed it: %v", err)
}
if !report.Compatible {
t.Fatalf("stable comparison did not honor the consumed receipt: %#v", report.Comparisons)
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresCleanupAfterAllReferencesCatchUp(t *testing.T) {
after := testFlagMigrationSnapshot(true, true)
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
references := map[string]Snapshot{
"merge-base": after,
"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, testEmptyFlagMigrationManifest())
if err != nil {
t.Fatalf("cleanup of stale consumed receipt was rejected: %v", err)
}
if !report.Compatible {
t.Fatalf("cleanup-only candidate was incompatible: %#v", report.Comparisons)
}
}
func testSnapshot(commands ...Command) Snapshot {
return Snapshot{
SchemaVersion: SchemaVersion,
@@ -571,6 +891,92 @@ func testFlag(name, flagType string, required bool) Flag {
return Flag{Name: name, Type: flagType, Required: required}
}
func testFlagMigrationSnapshot(after, includeUnrelated bool) Snapshot {
legacy := Flag{
Name: "legacy-id",
Shorthand: "l",
Type: "string",
NoOpt: "auto",
Required: true,
}
flags := []Flag{legacy}
if after {
legacy.Required = false
legacy.Hidden = true
legacy.AliasOf = "message-id"
flags = []Flag{
legacy,
{
Name: "message-id",
Type: "string",
Required: true,
},
}
}
if includeUnrelated {
flags = append(flags, Flag{
Name: "format",
Shorthand: "f",
Type: "string",
NoOpt: "json",
})
}
return testSnapshot(
testCommand("dws"),
testCommand("dws chat send", flags...),
)
}
func testFlagMigrationManifest(state string) FlagMigrationManifest {
return FlagMigrationManifest{
Version: FlagMigrationManifestVersion,
Migrations: []FlagMigration{
{
Command: "dws chat send",
Legacy: FlagMigrationSide{
Name: "legacy-id",
Before: FlagMigrationState{
Present: true,
Type: "string",
Required: true,
Shorthand: "l",
NoOpt: "auto",
Scope: "local",
},
After: FlagMigrationState{
Present: true,
Type: "string",
Hidden: true,
Shorthand: "l",
NoOpt: "auto",
Scope: "local",
AliasOf: "message-id",
},
},
Canonical: FlagMigrationSide{
Name: "message-id",
Before: FlagMigrationState{},
After: FlagMigrationState{
Present: true,
Type: "string",
Required: true,
Scope: "local",
},
},
State: state,
Reason: "rename the public flag while preserving the executable legacy alias",
},
},
}
}
func testEmptyFlagMigrationManifest() FlagMigrationManifest {
return FlagMigrationManifest{
Version: FlagMigrationManifestVersion,
Migrations: []FlagMigration{},
}
}
func hasChangeKind(changes []Change, kind string) bool {
for _, change := range changes {
if change.Kind == kind {
+696
View File
@@ -0,0 +1,696 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package interfacesnapshot
import (
"bytes"
"encoding/json"
"fmt"
"io"
"reflect"
"strings"
)
const FlagMigrationManifestVersion = 1
const (
FlagMigrationPending = "pending"
FlagMigrationConsumed = "consumed"
)
type FlagMigrationManifest struct {
Version int `json:"version"`
Migrations []FlagMigration `json:"migrations"`
}
type FlagMigration struct {
Command string `json:"command"`
Legacy FlagMigrationSide `json:"legacy"`
Canonical FlagMigrationSide `json:"canonical"`
State string `json:"state"`
Reason string `json:"reason"`
}
type FlagMigrationSide struct {
Name string `json:"name"`
Before FlagMigrationState `json:"before"`
After FlagMigrationState `json:"after"`
}
type FlagMigrationState struct {
Present bool `json:"present"`
Type string `json:"type,omitempty"`
Required bool `json:"required,omitempty"`
Hidden bool `json:"hidden,omitempty"`
Shorthand string `json:"shorthand,omitempty"`
NoOpt string `json:"no_opt,omitempty"`
Scope string `json:"scope,omitempty"`
AliasOf string `json:"alias_of,omitempty"`
}
func ReadFlagMigrationManifest(r io.Reader) (FlagMigrationManifest, error) {
data, err := io.ReadAll(r)
if err != nil {
return FlagMigrationManifest{}, fmt.Errorf("read flag migration manifest: %w", err)
}
var manifest FlagMigrationManifest
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&manifest); err != nil {
return FlagMigrationManifest{}, err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return FlagMigrationManifest{}, fmt.Errorf("flag migration manifest contains trailing multiple JSON values")
}
return FlagMigrationManifest{}, fmt.Errorf("read trailing flag migration manifest data: %w", err)
}
if err := validateFlagMigrationJSONSchema(data); err != nil {
return FlagMigrationManifest{}, err
}
if err := manifest.Validate(); err != nil {
return FlagMigrationManifest{}, err
}
return manifest, nil
}
func validateFlagMigrationJSONSchema(data []byte) error {
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
if err := validateMigrationJSONValue(decoder, "$", reflect.TypeOf(FlagMigrationManifest{})); err != nil {
return err
}
if token, err := decoder.Token(); err != io.EOF {
if err == nil {
return fmt.Errorf("flag migration manifest contains trailing JSON value %v", token)
}
return fmt.Errorf("read trailing flag migration manifest data: %w", err)
}
return nil
}
func validateMigrationJSONValue(decoder *json.Decoder, path string, schema reflect.Type) error {
token, err := decoder.Token()
if err != nil {
return fmt.Errorf("read flag migration manifest value at %s: %w", path, err)
}
switch schema.Kind() {
case reflect.Struct:
if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' {
return migrationJSONTypeError(path, schema, token)
}
fields := migrationJSONFields(schema)
seen := make(map[string]bool, len(fields))
for decoder.More() {
keyToken, keyErr := decoder.Token()
if keyErr != nil {
return fmt.Errorf("read flag migration manifest field at %s: %w", path, keyErr)
}
// encoding/json guarantees object member names are string tokens.
key := keyToken.(string)
fieldSchema, exists := fields[key]
if !exists {
for canonical := range fields {
if strings.EqualFold(key, canonical) {
return fmt.Errorf(
"flag migration manifest contains non-canonical field %q at %s (want %q)",
key,
path,
canonical,
)
}
}
return fmt.Errorf("flag migration manifest contains unknown field %q at %s", key, path)
}
if seen[key] {
return fmt.Errorf("flag migration manifest contains duplicate field %q at %s", key, path)
}
seen[key] = true
if err := validateMigrationJSONValue(decoder, path+"."+key, fieldSchema); err != nil {
return err
}
}
if _, closeErr := decoder.Token(); closeErr != nil {
return fmt.Errorf("close flag migration manifest object at %s: %w", path, closeErr)
}
return nil
case reflect.Slice:
if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' {
return migrationJSONTypeError(path, schema, token)
}
for index := 0; decoder.More(); index++ {
if err := validateMigrationJSONValue(decoder, fmt.Sprintf("%s[%d]", path, index), schema.Elem()); err != nil {
return err
}
}
if _, closeErr := decoder.Token(); closeErr != nil {
return fmt.Errorf("close flag migration manifest array at %s: %w", path, closeErr)
}
return nil
case reflect.String:
if _, ok := token.(string); !ok {
return migrationJSONTypeError(path, schema, token)
}
return nil
case reflect.Int:
if _, ok := token.(json.Number); !ok {
return migrationJSONTypeError(path, schema, token)
}
return nil
case reflect.Bool:
if _, ok := token.(bool); !ok {
return migrationJSONTypeError(path, schema, token)
}
return nil
default:
return fmt.Errorf("flag migration manifest value at %s has unsupported Go schema type %s", path, schema)
}
}
func migrationJSONFields(schema reflect.Type) map[string]reflect.Type {
fields := make(map[string]reflect.Type, schema.NumField())
for index := 0; index < schema.NumField(); index++ {
field := schema.Field(index)
name := field.Tag.Get("json")
if comma := strings.IndexByte(name, ','); comma >= 0 {
name = name[:comma]
}
if name == "-" {
continue
}
if name == "" {
name = field.Name
}
fields[name] = field.Type
}
return fields
}
func migrationJSONTypeError(path string, want reflect.Type, token json.Token) error {
return fmt.Errorf(
"flag migration manifest value at %s must be %s, got %s",
path,
migrationJSONKindDescription(want),
migrationJSONTokenDescription(token),
)
}
func migrationJSONKindDescription(schema reflect.Type) string {
switch schema.Kind() {
case reflect.Struct:
return "an object"
case reflect.Slice:
return "an array"
case reflect.String:
return "a string"
case reflect.Int:
return "a number"
case reflect.Bool:
return "a boolean"
default:
return "the declared JSON type"
}
}
func migrationJSONTokenDescription(token json.Token) string {
if token == nil {
return "null"
}
if delimiter, ok := token.(json.Delim); ok {
switch delimiter {
case '{':
return "object"
case '[':
return "array"
default:
return fmt.Sprintf("delimiter %q", delimiter)
}
}
switch token.(type) {
case string:
return "string"
case json.Number, float64:
return "number"
case bool:
return "boolean"
default:
return fmt.Sprintf("%T", token)
}
}
func (m FlagMigrationManifest) Validate() error {
if m.Version != FlagMigrationManifestVersion {
return fmt.Errorf(
"unsupported flag migration manifest version %d (want %d)",
m.Version,
FlagMigrationManifestVersion,
)
}
if m.Migrations == nil {
return fmt.Errorf("flag migration manifest migrations must be an array")
}
seen := make(map[string]bool, len(m.Migrations))
legacyTargets := make(map[string]string, len(m.Migrations))
for index, migration := range m.Migrations {
if err := migration.validate(); err != nil {
return fmt.Errorf("flag migration %d: %w", index, err)
}
key := migration.key()
if seen[key] {
return fmt.Errorf("flag migration %d duplicates %s", index, key)
}
seen[key] = true
legacyKey := migration.Command + "\x00" + migration.Legacy.Name
if canonical, exists := legacyTargets[legacyKey]; exists && canonical != migration.Canonical.Name {
return fmt.Errorf(
"flag migration %d maps %s --%s to both --%s and --%s",
index,
migration.Command,
migration.Legacy.Name,
canonical,
migration.Canonical.Name,
)
}
legacyTargets[legacyKey] = migration.Canonical.Name
}
return nil
}
func (m FlagMigration) validate() error {
if !isExactCommandPath(m.Command) {
return fmt.Errorf("command must be an exact command path rooted at dws: %q", m.Command)
}
if !isExactFlagName(m.Legacy.Name) {
return fmt.Errorf("legacy name must be an exact legacy flag: %q", m.Legacy.Name)
}
if !isExactFlagName(m.Canonical.Name) {
return fmt.Errorf("canonical name must be an exact canonical flag: %q", m.Canonical.Name)
}
if m.Legacy.Name == m.Canonical.Name {
return fmt.Errorf("legacy and canonical flags must differ: --%s", m.Legacy.Name)
}
if strings.TrimSpace(m.Reason) == "" {
return fmt.Errorf("migration must include a non-empty reason")
}
if m.Reason != strings.TrimSpace(m.Reason) {
return fmt.Errorf("migration reason must be trimmed")
}
if m.State != FlagMigrationPending && m.State != FlagMigrationConsumed {
return fmt.Errorf("invalid state %q", m.State)
}
if err := m.Legacy.Before.validate("legacy before"); err != nil {
return err
}
if err := m.Legacy.After.validate("legacy after"); err != nil {
return err
}
if err := m.Canonical.Before.validate("canonical before"); err != nil {
return err
}
if err := m.Canonical.After.validate("canonical after"); err != nil {
return err
}
if !m.Legacy.Before.Present || !m.Legacy.After.Present {
return fmt.Errorf("legacy flag must remain present before and after migration")
}
if m.Legacy.Before.Hidden || !m.Legacy.After.Hidden {
return fmt.Errorf("legacy flag must migrate exactly from visible to hidden")
}
if !m.Canonical.After.Present {
return fmt.Errorf("canonical flag must be present after migration")
}
if m.Canonical.After.Hidden {
return fmt.Errorf("canonical flag must remain visible")
}
if !m.Canonical.After.Required {
return fmt.Errorf("canonical flag must be required after migration")
}
if m.Canonical.Before.Present && m.Canonical.Before.Required {
return fmt.Errorf("canonical flag must be absent or optional before migration")
}
if m.Legacy.After.AliasOf != m.Canonical.Name {
return fmt.Errorf(
"legacy flag after state must declare alias_of %q",
m.Canonical.Name,
)
}
if m.Legacy.Before.AliasOf != "" || m.Canonical.Before.AliasOf != "" || m.Canonical.After.AliasOf != "" {
return fmt.Errorf("alias_of is only valid on the legacy after state")
}
if m.Canonical.Before.Present {
if m.Canonical.Before.Type != m.Canonical.After.Type {
return fmt.Errorf("canonical flag type must remain unchanged")
}
if m.Canonical.Before.Shorthand != m.Canonical.After.Shorthand {
return fmt.Errorf("canonical flag shorthand must remain unchanged")
}
if m.Canonical.Before.NoOpt != m.Canonical.After.NoOpt {
return fmt.Errorf("canonical flag no_opt must remain unchanged")
}
if m.Canonical.Before.Scope != m.Canonical.After.Scope {
return fmt.Errorf("canonical flag scope must remain unchanged")
}
}
if m.Legacy.Before.Type != m.Legacy.After.Type || m.Legacy.After.Type != m.Canonical.After.Type {
return fmt.Errorf("legacy and canonical flag types must match exactly")
}
if m.Legacy.Before.Shorthand != m.Legacy.After.Shorthand {
return fmt.Errorf("legacy flag shorthand must remain unchanged")
}
if m.Legacy.Before.NoOpt != m.Legacy.After.NoOpt {
return fmt.Errorf("legacy flag no_opt must remain unchanged")
}
if m.Legacy.Before.Scope != m.Legacy.After.Scope {
return fmt.Errorf("legacy flag scope must remain unchanged")
}
return nil
}
func (s FlagMigrationState) validate(label string) error {
if !s.Present {
if s.Type != "" || s.Required || s.Hidden || s.Shorthand != "" || s.NoOpt != "" || s.Scope != "" || s.AliasOf != "" {
return fmt.Errorf("%s absent state must not declare flag attributes", label)
}
return nil
}
if strings.TrimSpace(s.Type) == "" {
return fmt.Errorf("%s present state requires type", label)
}
if s.Scope != "local" && s.Scope != "inherited" {
return fmt.Errorf("%s present state has invalid scope %q", label, s.Scope)
}
return nil
}
func (m FlagMigration) key() string {
return m.Command + "\x00" + m.Legacy.Name + "\x00" + m.Canonical.Name
}
func isExactCommandPath(path string) bool {
return path != "" &&
path == strings.TrimSpace(path) &&
strings.Join(strings.Fields(path), " ") == path &&
(path == "dws" || strings.HasPrefix(path, "dws ")) &&
!strings.ContainsAny(path, "*?[]{}")
}
func isExactFlagName(name string) bool {
return name != "" &&
name == strings.TrimSpace(name) &&
!strings.HasPrefix(name, "-") &&
!strings.ContainsAny(name, "*?[]{} /\t\r\n")
}
// CompareAllWithFlagMigrations applies the ordinary compatibility policy and
// then consumes only exact, merge-base-owned flag migrations. Candidate-owned
// records participate in the lifecycle check, but never authorize their own
// interface change.
func CompareAllWithFlagMigrations(
current Snapshot,
references map[string]Snapshot,
authority FlagMigrationManifest,
candidate FlagMigrationManifest,
) (Report, error) {
authorizations, err := AuthorizeFlagMigrations(current, references, authority, candidate)
if err != nil {
return Report{}, err
}
report := CompareAll(current, references)
if len(authorizations) == 0 {
return report, nil
}
for index := range report.Comparisons {
comparison := &report.Comparisons[index]
reference := references[comparison.Reference]
filtered := comparison.Blocking[:0]
for _, change := range comparison.Blocking {
if flagMigrationAuthorizesChange(current, reference, change, authorizations) {
continue
}
filtered = append(filtered, change)
}
comparison.Blocking = filtered
comparison.Compatible = len(filtered) == 0
}
report.Compatible = true
for _, comparison := range report.Comparisons {
if !comparison.Compatible {
report.Compatible = false
break
}
}
return report, nil
}
// AuthorizeFlagMigrations validates both snapshots and manifests, enforces the
// base-owned migration lifecycle, and returns only approvals that may authorize
// the current exact interface transition. A non-empty lifecycle requires both a
// main/merge-base authority reference and a stable reference. Candidate-added
// records never appear in the returned authorization set.
func AuthorizeFlagMigrations(
current Snapshot,
references map[string]Snapshot,
authority FlagMigrationManifest,
candidate FlagMigrationManifest,
) ([]FlagMigration, error) {
if err := current.Validate(); err != nil {
return nil, fmt.Errorf("validate current interface snapshot: %w", err)
}
for label, snapshot := range references {
if err := snapshot.Validate(); err != nil {
return nil, fmt.Errorf("validate %s interface snapshot: %w", label, err)
}
}
if err := authority.Validate(); err != nil {
return nil, fmt.Errorf("validate approved flag migrations: %w", err)
}
if err := candidate.Validate(); err != nil {
return nil, fmt.Errorf("validate candidate flag migrations: %w", err)
}
return evaluateFlagMigrationLifecycle(current, references, authority, candidate)
}
type flagMigrationPhase string
const (
flagMigrationBefore flagMigrationPhase = "before"
flagMigrationAfter flagMigrationPhase = "after"
flagMigrationPartial flagMigrationPhase = "partial"
)
func evaluateFlagMigrationLifecycle(
current Snapshot,
references map[string]Snapshot,
authority FlagMigrationManifest,
candidate FlagMigrationManifest,
) ([]FlagMigration, error) {
if len(authority.Migrations) == 0 && len(candidate.Migrations) == 0 {
return nil, nil
}
if _, ok := references["stable"]; !ok {
return nil, fmt.Errorf("flag migration lifecycle requires a stable reference")
}
mergeBase, label, ok := flagMigrationAuthoritySnapshot(references)
if !ok {
return nil, fmt.Errorf("flag migration lifecycle requires a main or merge-base reference")
}
authorityByKey := flagMigrationIndex(authority)
candidateByKey := flagMigrationIndex(candidate)
authorizations := make([]FlagMigration, 0, len(authority.Migrations))
for _, approved := range authority.Migrations {
basePhase := matchFlagMigrationPhase(mergeBase, approved)
wantBasePhase := flagMigrationBefore
if approved.State == FlagMigrationConsumed {
wantBasePhase = flagMigrationAfter
}
if basePhase != wantBasePhase {
return nil, fmt.Errorf(
"approved flag migration %s is %s in %s, want exact %s state for %s",
approved.displayKey(),
basePhase,
label,
wantBasePhase,
approved.State,
)
}
proposed, exists := candidateByKey[approved.key()]
if exists && !sameFlagMigrationApproval(approved, proposed) {
return nil, fmt.Errorf("candidate modified base-owned flag migration %s", approved.displayKey())
}
currentPhase := matchFlagMigrationPhase(current, approved)
switch approved.State {
case FlagMigrationPending:
if !exists {
return nil, fmt.Errorf("candidate removed pending flag migration %s", approved.displayKey())
}
switch currentPhase {
case flagMigrationBefore:
if proposed.State != FlagMigrationPending {
return nil, fmt.Errorf("candidate falsely consumed unchanged flag migration %s", approved.displayKey())
}
case flagMigrationAfter:
if proposed.State != FlagMigrationConsumed {
return nil, fmt.Errorf("candidate completed flag migration %s without marking it consumed", approved.displayKey())
}
authorizations = append(authorizations, approved)
default:
return nil, fmt.Errorf("candidate partially applied flag migration %s", approved.displayKey())
}
case FlagMigrationConsumed:
if currentPhase != flagMigrationAfter {
return nil, fmt.Errorf("candidate drifted from consumed flag migration %s", approved.displayKey())
}
allReferencesAfter := true
for _, reference := range references {
if matchFlagMigrationPhase(reference, approved) != flagMigrationAfter {
allReferencesAfter = false
break
}
}
if allReferencesAfter {
if exists {
return nil, fmt.Errorf("consumed flag migration %s is stale after all references reached the after state", approved.displayKey())
}
continue
}
if !exists {
return nil, fmt.Errorf("candidate removed consumed flag migration %s before every reference reached the after state", approved.displayKey())
}
if proposed.State != FlagMigrationConsumed {
return nil, fmt.Errorf("candidate changed consumed flag migration %s back to pending", approved.displayKey())
}
authorizations = append(authorizations, approved)
}
}
for _, proposed := range candidate.Migrations {
if _, exists := authorityByKey[proposed.key()]; exists {
continue
}
if proposed.State != FlagMigrationPending {
return nil, fmt.Errorf("candidate-added flag migration %s must start pending", proposed.displayKey())
}
if matchFlagMigrationPhase(mergeBase, proposed) != flagMigrationBefore {
return nil, fmt.Errorf("candidate-added flag migration %s does not match the merge-base before state", proposed.displayKey())
}
if matchFlagMigrationPhase(current, proposed) != flagMigrationBefore {
return nil, fmt.Errorf("candidate-added flag migration %s cannot authorize its own interface change", proposed.displayKey())
}
}
return authorizations, nil
}
func flagMigrationAuthoritySnapshot(references map[string]Snapshot) (Snapshot, string, bool) {
for _, label := range []string{"merge-base", "main"} {
if snapshot, ok := references[label]; ok {
return snapshot, label, true
}
}
return Snapshot{}, "", false
}
func flagMigrationIndex(manifest FlagMigrationManifest) map[string]FlagMigration {
index := make(map[string]FlagMigration, len(manifest.Migrations))
for _, migration := range manifest.Migrations {
index[migration.key()] = migration
}
return index
}
func sameFlagMigrationApproval(left, right FlagMigration) bool {
left.State = ""
right.State = ""
return reflect.DeepEqual(left, right)
}
func matchFlagMigrationPhase(snapshot Snapshot, migration FlagMigration) flagMigrationPhase {
command, exists := commandIndex(snapshot)[migration.Command]
if !exists {
return flagMigrationPartial
}
legacy := flagMigrationStateForCommand(command, migration.Legacy.Name)
canonical := flagMigrationStateForCommand(command, migration.Canonical.Name)
if legacy == migration.Legacy.Before && canonical == migration.Canonical.Before {
return flagMigrationBefore
}
if legacy == migration.Legacy.After && canonical == migration.Canonical.After {
return flagMigrationAfter
}
return flagMigrationPartial
}
func flagMigrationStateForCommand(command Command, name string) FlagMigrationState {
for _, flag := range command.LocalFlags {
if flag.Name == name {
return flagMigrationState(flag, "local")
}
}
for _, flag := range command.InheritedFlags {
if flag.Name == name {
return flagMigrationState(flag, "inherited")
}
}
return FlagMigrationState{}
}
func flagMigrationState(flag Flag, scope string) FlagMigrationState {
return FlagMigrationState{
Present: true,
Type: flag.Type,
Required: flag.Required,
Hidden: flag.Hidden,
Shorthand: flag.Shorthand,
NoOpt: flag.NoOpt,
Scope: scope,
AliasOf: flag.AliasOf,
}
}
func flagMigrationAuthorizesChange(
current Snapshot,
reference Snapshot,
change Change,
authorizations []FlagMigration,
) bool {
canonicalPath := acceptedPathIndex(reference)[change.Path]
if canonicalPath == "" {
canonicalPath = change.Path
}
for _, migration := range authorizations {
if canonicalPath != migration.Command ||
matchFlagMigrationPhase(reference, migration) != flagMigrationBefore ||
matchFlagMigrationPhase(current, migration) != flagMigrationAfter {
continue
}
if change.Flag == migration.Legacy.Name && change.Kind == "flag_became_hidden" {
return true
}
if change.Flag != migration.Canonical.Name {
continue
}
if !migration.Canonical.Before.Present &&
migration.Canonical.After.Required &&
change.Kind == "required_flag_added" {
return true
}
if migration.Canonical.Before.Present &&
!migration.Canonical.Before.Required &&
migration.Canonical.After.Required &&
change.Kind == "flag_became_required" {
return true
}
}
return false
}
func (m FlagMigration) displayKey() string {
return fmt.Sprintf("%q --%s -> --%s", m.Command, m.Legacy.Name, m.Canonical.Name)
}
@@ -0,0 +1,966 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package interfacesnapshot
import (
"encoding/json"
"reflect"
"strings"
"testing"
)
func TestCrossPlatformCoverageFlagMigrationStrictJSONSchemaErrors(t *testing.T) {
tests := []struct {
name string
input string
wantErr string
}{
{
name: "schema validator rejects a trailing value",
input: coverageManifestJSON() + ` {}`,
wantErr: "trailing JSON value",
},
{
name: "schema validator reports malformed trailing data",
input: coverageManifestJSON() + ` ?`,
wantErr: "read trailing flag migration manifest data",
},
{
name: "schema validator reports an empty document",
input: "",
wantErr: "read flag migration manifest value at $",
},
{
name: "schema validator rejects an unknown field",
input: `{"version":1,"migrations":[],"unexpected":true}`,
wantErr: `unknown field "unexpected"`,
},
{
name: "schema validator reports a malformed field token",
input: `{"`,
wantErr: "flag migration manifest",
},
{
name: "schema validator reports an unclosed object",
input: `{"version":1`,
wantErr: "close flag migration manifest object",
},
{
name: "schema validator reports an unclosed array",
input: `{"version":1,"migrations":[`,
wantErr: "close flag migration manifest array",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := validateFlagMigrationJSONSchema([]byte(test.input))
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("validateFlagMigrationJSONSchema() error = %v, want %q", err, test.wantErr)
}
})
}
}
func TestCrossPlatformCoverageFlagMigrationStrictJSONTypeDiagnostics(t *testing.T) {
tests := []struct {
name string
input string
schema reflect.Type
wantErr string
}{
{
name: "manifest must be an object",
input: `[]`,
schema: reflect.TypeOf(FlagMigrationManifest{}),
wantErr: "must be an object, got array",
},
{
name: "migrations must be an array",
input: `{}`,
schema: reflect.TypeOf([]FlagMigration{}),
wantErr: "must be an array, got object",
},
{
name: "integer rejects string",
input: `"one"`,
schema: reflect.TypeOf(int(0)),
wantErr: "must be a number, got string",
},
{
name: "string rejects number",
input: `1`,
schema: reflect.TypeOf(""),
wantErr: "must be a string, got number",
},
{
name: "string rejects boolean",
input: `false`,
schema: reflect.TypeOf(""),
wantErr: "must be a string, got boolean",
},
{
name: "unsupported schema fails closed",
input: `1`,
schema: reflect.TypeOf(float64(0)),
wantErr: "unsupported Go schema type float64",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
decoder := json.NewDecoder(strings.NewReader(test.input))
decoder.UseNumber()
err := validateMigrationJSONValue(decoder, "$", test.schema)
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("validateMigrationJSONValue() error = %v, want %q", err, test.wantErr)
}
})
}
}
func TestCrossPlatformCoverageFlagMigrationStrictJSONSchemaHelpers(t *testing.T) {
type fieldFixture struct {
DefaultName string
Ignored string `json:"-"`
Tagged string `json:"tagged,omitempty"`
}
fields := migrationJSONFields(reflect.TypeOf(fieldFixture{}))
if _, exists := fields["Ignored"]; exists {
t.Fatal("migrationJSONFields() retained a json:- field")
}
if fields["DefaultName"] != reflect.TypeOf("") || fields["tagged"] != reflect.TypeOf("") {
t.Fatalf("migrationJSONFields() = %#v, want default and tagged string fields", fields)
}
if got := migrationJSONKindDescription(reflect.TypeOf(float64(0))); got != "the declared JSON type" {
t.Fatalf("migrationJSONKindDescription(float64) = %q", got)
}
for _, test := range []struct {
name string
token json.Token
want string
}{
{name: "closing delimiter", token: json.Delim('}'), want: `delimiter "}"`},
{name: "unsupported token", token: struct{}{}, want: "struct {}"},
} {
t.Run(test.name, func(t *testing.T) {
if got := migrationJSONTokenDescription(test.token); got != test.want {
t.Fatalf("migrationJSONTokenDescription() = %q, want %q", got, test.want)
}
})
}
}
func TestCrossPlatformCoverageFlagMigrationManifestParserEdges(t *testing.T) {
tests := []struct {
name string
input string
wantErr string
}{
{
name: "initial malformed JSON",
input: `{`,
wantErr: "unexpected EOF",
},
{
name: "multiple JSON values",
input: coverageManifestJSON() + ` {}`,
wantErr: "trailing multiple JSON values",
},
{
name: "malformed trailing JSON",
input: coverageManifestJSON() + ` {`,
wantErr: "read trailing flag migration manifest data",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := ReadFlagMigrationManifest(strings.NewReader(test.input))
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("ReadFlagMigrationManifest() error = %v, want %q", err, test.wantErr)
}
})
}
}
func TestCrossPlatformCoverageFlagMigrationManifestRejectsEveryContractDrift(t *testing.T) {
optionalCanonical := func() FlagMigrationManifest {
manifest := coverageManifest(FlagMigrationPending)
manifest.Migrations[0].Canonical.Before = FlagMigrationState{
Present: true,
Type: "string",
Scope: "local",
}
return manifest
}
tests := []struct {
name string
make func() FlagMigrationManifest
mutate func(*FlagMigrationManifest)
wantErr string
}{
{
name: "canonical flag name is exact",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Name = "--message-id" },
wantErr: "canonical name must be an exact canonical flag",
},
{
name: "legacy and canonical names differ",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Name = m.Migrations[0].Legacy.Name },
wantErr: "legacy and canonical flags must differ",
},
{
name: "legacy before state validates",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before.Scope = "global" },
wantErr: "legacy before present state has invalid scope",
},
{
name: "legacy after state validates",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Type = "" },
wantErr: "legacy after present state requires type",
},
{
name: "canonical before state validates",
make: optionalCanonical,
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.Type = "" },
wantErr: "canonical before present state requires type",
},
{
name: "canonical after state validates",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After.Scope = "" },
wantErr: "canonical after present state has invalid scope",
},
{
name: "legacy before remains present",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before = FlagMigrationState{} },
wantErr: "legacy flag must remain present before and after migration",
},
{
name: "legacy after remains present",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After = FlagMigrationState{} },
wantErr: "legacy flag must remain present before and after migration",
},
{
name: "legacy starts visible",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before.Hidden = true },
wantErr: "legacy flag must migrate exactly from visible to hidden",
},
{
name: "legacy ends hidden",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Hidden = false },
wantErr: "legacy flag must migrate exactly from visible to hidden",
},
{
name: "canonical after remains present",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After = FlagMigrationState{} },
wantErr: "canonical flag must be present after migration",
},
{
name: "legacy after declares alias target",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.AliasOf = "other-id" },
wantErr: `legacy flag after state must declare alias_of "message-id"`,
},
{
name: "legacy before cannot declare alias",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before.AliasOf = "message-id" },
wantErr: "alias_of is only valid on the legacy after state",
},
{
name: "canonical before cannot declare alias",
make: optionalCanonical,
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.AliasOf = "message-id" },
wantErr: "alias_of is only valid on the legacy after state",
},
{
name: "canonical after cannot declare alias",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After.AliasOf = "message-id" },
wantErr: "alias_of is only valid on the legacy after state",
},
{
name: "canonical type remains stable",
make: optionalCanonical,
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.Type = "stringSlice" },
wantErr: "canonical flag type must remain unchanged",
},
{
name: "canonical shorthand remains stable",
make: optionalCanonical,
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.Shorthand = "m" },
wantErr: "canonical flag shorthand must remain unchanged",
},
{
name: "canonical no-opt remains stable",
make: optionalCanonical,
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.NoOpt = "auto" },
wantErr: "canonical flag no_opt must remain unchanged",
},
{
name: "canonical scope remains stable",
make: optionalCanonical,
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.Scope = "inherited" },
wantErr: "canonical flag scope must remain unchanged",
},
{
name: "legacy before type matches legacy after",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before.Type = "stringSlice" },
wantErr: "legacy and canonical flag types must match exactly",
},
{
name: "legacy after type matches canonical after",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After.Type = "stringSlice" },
wantErr: "legacy and canonical flag types must match exactly",
},
{
name: "legacy shorthand remains stable",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Shorthand = "x" },
wantErr: "legacy flag shorthand must remain unchanged",
},
{
name: "legacy no-opt remains stable",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.NoOpt = "" },
wantErr: "legacy flag no_opt must remain unchanged",
},
{
name: "legacy scope remains stable",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Scope = "inherited" },
wantErr: "legacy flag scope must remain unchanged",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
manifest := test.make()
test.mutate(&manifest)
if err := manifest.Validate(); err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("Validate() error = %v, want %q", err, test.wantErr)
}
})
}
}
func TestCrossPlatformCoverageFlagMigrationManifestRejectsAbsentAttributesAndTargetConflicts(t *testing.T) {
stateMutations := []struct {
name string
mutate func(*FlagMigrationState)
}{
{name: "type", mutate: func(s *FlagMigrationState) { s.Type = "string" }},
{name: "required", mutate: func(s *FlagMigrationState) { s.Required = true }},
{name: "hidden", mutate: func(s *FlagMigrationState) { s.Hidden = true }},
{name: "shorthand", mutate: func(s *FlagMigrationState) { s.Shorthand = "m" }},
{name: "no-opt", mutate: func(s *FlagMigrationState) { s.NoOpt = "auto" }},
{name: "scope", mutate: func(s *FlagMigrationState) { s.Scope = "local" }},
{name: "alias", mutate: func(s *FlagMigrationState) { s.AliasOf = "message-id" }},
}
for _, test := range stateMutations {
t.Run("absent state rejects "+test.name, func(t *testing.T) {
manifest := coverageManifest(FlagMigrationPending)
test.mutate(&manifest.Migrations[0].Canonical.Before)
if err := manifest.Validate(); err == nil || !strings.Contains(err.Error(), "absent state must not declare flag attributes") {
t.Fatalf("Validate() error = %v, want absent-state rejection", err)
}
})
}
manifest := coverageManifest(FlagMigrationPending)
second := coverageManifest(FlagMigrationPending).Migrations[0]
second.Canonical.Name = "canonical-two"
second.Legacy.After.AliasOf = second.Canonical.Name
manifest.Migrations = append(manifest.Migrations, second)
if err := manifest.Validate(); err == nil || !strings.Contains(err.Error(), "to both --message-id and --canonical-two") {
t.Fatalf("Validate() error = %v, want one legacy to two canonical targets rejected", err)
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsValidatesEveryInput(t *testing.T) {
before := coverageMigrationSnapshot(coverageManifest(FlagMigrationPending).Migrations[0], false, false)
empty := coverageEmptyManifest()
tests := []struct {
name string
current Snapshot
references map[string]Snapshot
authority FlagMigrationManifest
candidate FlagMigrationManifest
wantErr string
}{
{
name: "current snapshot",
current: Snapshot{},
references: map[string]Snapshot{"merge-base": before},
authority: empty,
candidate: empty,
wantErr: "validate current interface snapshot",
},
{
name: "reference snapshot",
current: before,
references: map[string]Snapshot{"merge-base": {}},
authority: empty,
candidate: empty,
wantErr: "validate merge-base interface snapshot",
},
{
name: "authority manifest",
current: before,
references: map[string]Snapshot{"merge-base": before},
authority: FlagMigrationManifest{},
candidate: empty,
wantErr: "validate approved flag migrations",
},
{
name: "candidate manifest",
current: before,
references: map[string]Snapshot{"merge-base": before},
authority: empty,
candidate: FlagMigrationManifest{},
wantErr: "validate candidate flag migrations",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := CompareAllWithFlagMigrations(test.current, test.references, test.authority, test.candidate)
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("CompareAllWithFlagMigrations() error = %v, want %q", err, test.wantErr)
}
})
}
report, err := CompareAllWithFlagMigrations(before, map[string]Snapshot{"merge-base": before}, empty, empty)
if err != nil || !report.Compatible {
t.Fatalf("empty migration lifecycle = (%#v, %v), want compatible", report, err)
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsLifecycleErrors(t *testing.T) {
pending := coverageManifest(FlagMigrationPending)
consumed := coverageManifest(FlagMigrationConsumed)
empty := coverageEmptyManifest()
migration := pending.Migrations[0]
before := coverageMigrationSnapshot(migration, false, false)
after := coverageMigrationSnapshot(migration, true, false)
partial := coverageMigrationSnapshot(migration, false, false)
partial.Commands[len(partial.Commands)-1].LocalFlags[0].Hidden = true
modified := coverageManifest(FlagMigrationPending)
modified.Migrations[0].Reason = "A different reviewed reason."
tests := []struct {
name string
current Snapshot
references map[string]Snapshot
authority FlagMigrationManifest
candidate FlagMigrationManifest
wantErr string
}{
{
name: "requires merge-base or main",
current: before,
references: map[string]Snapshot{"stable": before},
authority: pending,
candidate: pending,
wantErr: "requires a main or merge-base reference",
},
{
name: "pending authority requires before base",
current: after,
references: map[string]Snapshot{"merge-base": after, "stable": after},
authority: pending,
candidate: pending,
wantErr: "want exact before state for pending",
},
{
name: "consumed authority requires after base",
current: before,
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: consumed,
candidate: consumed,
wantErr: "want exact after state for consumed",
},
{
name: "missing base command is partial",
current: before,
references: map[string]Snapshot{"merge-base": coverageRootOnlySnapshot(), "stable": before},
authority: pending,
candidate: pending,
wantErr: "is partial in merge-base",
},
{
name: "candidate cannot modify base receipt",
current: before,
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: pending,
candidate: modified,
wantErr: "candidate modified base-owned flag migration",
},
{
name: "candidate cannot remove pending receipt",
current: before,
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: pending,
candidate: empty,
wantErr: "candidate removed pending flag migration",
},
{
name: "candidate cannot falsely consume pending receipt",
current: before,
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: pending,
candidate: consumed,
wantErr: "candidate falsely consumed unchanged flag migration",
},
{
name: "candidate must consume completed receipt",
current: after,
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: pending,
candidate: pending,
wantErr: "without marking it consumed",
},
{
name: "candidate rejects partial application",
current: partial,
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: pending,
candidate: consumed,
wantErr: "candidate partially applied flag migration",
},
{
name: "consumed receipt rejects current revert",
current: before,
references: map[string]Snapshot{"merge-base": after, "stable": before},
authority: consumed,
candidate: consumed,
wantErr: "candidate drifted from consumed flag migration",
},
{
name: "consumed receipt cannot be removed early",
current: after,
references: map[string]Snapshot{"merge-base": after, "stable": before},
authority: consumed,
candidate: empty,
wantErr: "before every reference reached the after state",
},
{
name: "consumed receipt cannot revert to pending",
current: after,
references: map[string]Snapshot{"merge-base": after, "stable": before},
authority: consumed,
candidate: pending,
wantErr: "back to pending",
},
{
name: "consumed receipt becomes stale",
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",
},
{
name: "candidate-added receipt starts pending",
current: before,
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: empty,
candidate: consumed,
wantErr: "must start pending",
},
{
name: "candidate-added receipt matches base before",
current: before,
references: map[string]Snapshot{"merge-base": after, "stable": after},
authority: empty,
candidate: pending,
wantErr: "does not match the merge-base before state",
},
{
name: "candidate-added receipt cannot self-authorize",
current: after,
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: empty,
candidate: pending,
wantErr: "cannot authorize its own interface change",
},
{
name: "missing current command is partial",
current: coverageRootOnlySnapshot(),
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: pending,
candidate: pending,
wantErr: "candidate partially applied flag migration",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := CompareAllWithFlagMigrations(test.current, test.references, test.authority, test.candidate)
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("CompareAllWithFlagMigrations() error = %v, want %q", err, test.wantErr)
}
})
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsMainAuthorityAndCleanup(t *testing.T) {
pending := coverageManifest(FlagMigrationPending)
consumed := coverageManifest(FlagMigrationConsumed)
empty := coverageEmptyManifest()
migration := pending.Migrations[0]
before := coverageMigrationSnapshot(migration, false, false)
after := coverageMigrationSnapshot(migration, true, false)
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"main": before, "stable": before},
pending,
consumed,
)
if err != nil || !report.Compatible {
t.Fatalf("main-owned migration = (%#v, %v), want compatible", report, err)
}
report, err = CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "main": after, "stable": before},
pending,
consumed,
)
if err != nil || !report.Compatible {
t.Fatalf("merge-base precedence = (%#v, %v), want compatible", report, err)
}
report, err = CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": after, "stable": after},
consumed,
empty,
)
if err != nil || !report.Compatible {
t.Fatalf("consumed receipt cleanup = (%#v, %v), want compatible", report, err)
}
report, err = CompareAllWithFlagMigrations(
before,
map[string]Snapshot{"merge-base": before, "stable": before},
empty,
pending,
)
if err != nil || !report.Compatible {
t.Fatalf("pending receipt creation = (%#v, %v), want compatible", report, err)
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresStableForReceiptCleanup(t *testing.T) {
consumed := coverageManifest(FlagMigrationConsumed)
empty := coverageEmptyManifest()
after := coverageMigrationSnapshot(consumed.Migrations[0], true, false)
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": after},
consumed,
empty,
)
if err == nil || !strings.Contains(err.Error(), "requires a stable reference") {
t.Fatalf("receipt cleanup without stable = (%#v, %v), want stable-reference error", report, err)
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsInheritedAndOptionalCanonical(t *testing.T) {
t.Run("inherited exact state", func(t *testing.T) {
pending := coverageManifest(FlagMigrationPending)
pending.Migrations[0].Legacy.Before.Scope = "inherited"
pending.Migrations[0].Legacy.After.Scope = "inherited"
pending.Migrations[0].Canonical.After.Scope = "inherited"
consumed := pending
consumed.Migrations = append([]FlagMigration(nil), pending.Migrations...)
consumed.Migrations[0].State = FlagMigrationConsumed
before := coverageMigrationSnapshot(pending.Migrations[0], false, false)
after := coverageMigrationSnapshot(pending.Migrations[0], true, false)
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
)
if err != nil || !report.Compatible {
t.Fatalf("inherited migration = (%#v, %v), want compatible", report, err)
}
})
t.Run("existing optional canonical becomes required", func(t *testing.T) {
pending := coverageManifest(FlagMigrationPending)
pending.Migrations[0].Canonical.Before = FlagMigrationState{
Present: true,
Type: "string",
Scope: "local",
}
consumed := pending
consumed.Migrations = append([]FlagMigration(nil), pending.Migrations...)
consumed.Migrations[0].State = FlagMigrationConsumed
before := coverageMigrationSnapshot(pending.Migrations[0], false, false)
after := coverageMigrationSnapshot(pending.Migrations[0], true, false)
ordinary := Compare(after, before, "merge-base")
if !hasFlagChange(ordinary.Blocking, "flag_became_required", pending.Migrations[0].Command, pending.Migrations[0].Canonical.Name) {
t.Fatalf("fixture did not create flag_became_required: %#v", ordinary.Blocking)
}
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
)
if err != nil || !report.Compatible {
t.Fatalf("optional-to-required migration = (%#v, %v), want compatible", report, err)
}
})
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsNormalizesAliasesAndKeepsOtherBreakage(t *testing.T) {
pending := coverageManifest(FlagMigrationPending)
consumed := coverageManifest(FlagMigrationConsumed)
migration := pending.Migrations[0]
before := coverageMigrationSnapshot(migration, false, true)
after := coverageMigrationSnapshot(migration, true, true)
ordinary := Compare(after, before, "merge-base")
if !hasFlagChange(ordinary.Blocking, "flag_became_hidden", "dws chat deliver", migration.Legacy.Name) {
t.Fatalf("alias fixture did not create alias-path finding: %#v", ordinary.Blocking)
}
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
)
if err != nil || !report.Compatible {
t.Fatalf("alias-path migration = (%#v, %v), want compatible", report, err)
}
beforeWithOther := before
afterWithOther := after
beforeWithOther.Commands = append(beforeWithOther.Commands, coverageCommand("dws files", nil, []Flag{{Name: "format", Type: "string"}}, nil))
afterWithOther.Commands = append(afterWithOther.Commands, coverageCommand("dws files", nil, nil, nil))
report, err = CompareAllWithFlagMigrations(
afterWithOther,
map[string]Snapshot{"merge-base": beforeWithOther, "stable": beforeWithOther},
pending,
consumed,
)
if err != nil {
t.Fatalf("unrelated breakage returned lifecycle error: %v", err)
}
if report.Compatible || !hasFlagChange(report.Comparisons[0].Blocking, "flag_removed", "dws files", "format") {
t.Fatalf("unrelated breakage was hidden: %#v", report)
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsDoesNotAuthorizePartialReferenceOrRuleDrift(t *testing.T) {
pending := coverageManifest(FlagMigrationPending)
consumed := coverageManifest(FlagMigrationConsumed)
migration := pending.Migrations[0]
before := coverageMigrationSnapshot(migration, false, false)
after := coverageMigrationSnapshot(migration, true, false)
partialReference := coverageMigrationSnapshot(migration, false, false)
partialReference.Commands[len(partialReference.Commands)-1].LocalFlags[0].Hidden = true
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": partialReference},
pending,
consumed,
)
if err != nil {
t.Fatalf("partial non-authority reference returned lifecycle error: %v", err)
}
if report.Compatible {
t.Fatalf("partial reference was incorrectly authorized: %#v", report)
}
beforeWithoutRoot := coverageMigrationSnapshot(migration, false, false)
afterWithoutRoot := coverageMigrationSnapshot(migration, true, false)
beforeWithoutRoot.Commands = beforeWithoutRoot.Commands[len(beforeWithoutRoot.Commands)-1:]
afterWithoutRoot.Commands = afterWithoutRoot.Commands[len(afterWithoutRoot.Commands)-1:]
afterWithoutRoot.Rules.ExcludedFlags = []string{"help", "version"}
report, err = CompareAllWithFlagMigrations(
afterWithoutRoot,
map[string]Snapshot{"merge-base": beforeWithoutRoot, "stable": beforeWithoutRoot},
pending,
consumed,
)
if err != nil {
t.Fatalf("rule drift returned lifecycle error: %v", err)
}
if report.Compatible || !hasChangeKind(report.Comparisons[0].Blocking, "snapshot_rules_changed") {
t.Fatalf("snapshot rule drift was incorrectly authorized: %#v", report)
}
}
func coverageManifestJSON() string {
return `{
"version": 1,
"migrations": [
{
"command": "dws chat send",
"legacy": {
"name": "legacy-id",
"before": {"present": true, "type": "string", "required": true, "shorthand": "l", "no_opt": "auto", "scope": "local"},
"after": {"present": true, "type": "string", "hidden": true, "shorthand": "l", "no_opt": "auto", "scope": "local", "alias_of": "message-id"}
},
"canonical": {
"name": "message-id",
"before": {"present": false},
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
},
"state": "pending",
"reason": "Reviewed exact flag migration."
}
]
}`
}
func coverageManifest(state string) FlagMigrationManifest {
return FlagMigrationManifest{
Version: FlagMigrationManifestVersion,
Migrations: []FlagMigration{
{
Command: "dws chat send",
Legacy: FlagMigrationSide{
Name: "legacy-id",
Before: FlagMigrationState{
Present: true,
Type: "string",
Required: true,
Shorthand: "l",
NoOpt: "auto",
Scope: "local",
},
After: FlagMigrationState{
Present: true,
Type: "string",
Hidden: true,
Shorthand: "l",
NoOpt: "auto",
Scope: "local",
AliasOf: "message-id",
},
},
Canonical: FlagMigrationSide{
Name: "message-id",
Before: FlagMigrationState{},
After: FlagMigrationState{
Present: true,
Type: "string",
Required: true,
Scope: "local",
},
},
State: state,
Reason: "Reviewed exact flag migration.",
},
},
}
}
func coverageEmptyManifest() FlagMigrationManifest {
return FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}
}
func coverageMigrationSnapshot(migration FlagMigration, after, commandAlias bool) Snapshot {
legacyState := migration.Legacy.Before
canonicalState := migration.Canonical.Before
if after {
legacyState = migration.Legacy.After
canonicalState = migration.Canonical.After
}
local := []Flag{}
inherited := []Flag{}
add := func(name string, state FlagMigrationState) {
if !state.Present {
return
}
flag := Flag{
Name: name,
Shorthand: state.Shorthand,
Type: state.Type,
NoOpt: state.NoOpt,
Required: state.Required,
Hidden: state.Hidden,
AliasOf: state.AliasOf,
}
if state.Scope == "inherited" {
inherited = append(inherited, flag)
return
}
local = append(local, flag)
}
add(migration.Legacy.Name, legacyState)
add(migration.Canonical.Name, canonicalState)
aliases := []string{}
if commandAlias {
aliases = []string{"deliver"}
}
return coverageSnapshot(
coverageCommand("dws", nil, nil, nil),
coverageCommand("dws chat", nil, nil, nil),
coverageCommand(migration.Command, aliases, local, inherited),
)
}
func coverageRootOnlySnapshot() Snapshot {
return coverageSnapshot(coverageCommand("dws", nil, nil, nil))
}
func coverageSnapshot(commands ...Command) Snapshot {
return Snapshot{
SchemaVersion: SchemaVersion,
Rules: Rules{
ExcludedCommandSubtrees: append([]string(nil), excludedCommandSubtrees...),
ExcludedFlags: []string{"help"},
},
Commands: commands,
}
}
func coverageCommand(path string, aliases []string, local, inherited []Flag) Command {
if aliases == nil {
aliases = []string{}
}
if local == nil {
local = []Flag{}
}
if inherited == nil {
inherited = []Flag{}
}
return Command{
Path: path,
Runnable: true,
Aliases: aliases,
LocalFlags: local,
InheritedFlags: inherited,
}
}
@@ -0,0 +1,252 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package interfacesnapshot
import (
"errors"
"reflect"
"strings"
"testing"
)
type flagMigrationErrorReader struct{}
func (flagMigrationErrorReader) Read([]byte) (int, error) {
return 0, errors.New("injected read failure")
}
const validFlagMigrationManifestJSON = `{
"version": 1,
"migrations": [
{
"command": "dws chat message recall",
"legacy": {
"name": "msg-id",
"before": {"present": true, "type": "string", "required": true, "scope": "local"},
"after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "message-id"}
},
"canonical": {
"name": "message-id",
"before": {"present": false},
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
},
"state": "pending",
"reason": "Reviewed message identifier flag migration."
}
]
}`
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsUnknownFields(t *testing.T) {
_, err := ReadFlagMigrationManifest(strings.NewReader(`{
"version": 1,
"migrations": [],
"allow_all_chat_flags": true
}`))
if err == nil || !strings.Contains(err.Error(), "unknown field") {
t.Fatalf("ReadFlagMigrationManifest() error = %v, want unknown field rejection", err)
}
}
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsDuplicateFields(t *testing.T) {
for _, input := range []string{
`{"version": 999, "version": 1, "migrations": []}`,
strings.Replace(validFlagMigrationManifestJSON, `"state": "pending"`, `"state": "consumed", "state": "pending"`, 1),
} {
_, err := ReadFlagMigrationManifest(strings.NewReader(input))
if err == nil || !strings.Contains(err.Error(), "duplicate field") {
t.Fatalf("ReadFlagMigrationManifest() error = %v, want duplicate field rejection", err)
}
}
}
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsNonCanonicalFields(t *testing.T) {
for _, input := range []string{
`{"Version": 1, "migrations": []}`,
`{"version": 1, "Version": 1, "migrations": []}`,
strings.Replace(validFlagMigrationManifestJSON, `"state": "pending"`, `"State": "pending"`, 1),
} {
_, err := ReadFlagMigrationManifest(strings.NewReader(input))
if err == nil || !strings.Contains(err.Error(), "non-canonical field") {
t.Fatalf("ReadFlagMigrationManifest() error = %v, want non-canonical field rejection", err)
}
}
}
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsNullScalars(t *testing.T) {
for _, input := range []string{
strings.Replace(validFlagMigrationManifestJSON, `"required": true`, `"required": null`, 1),
strings.Replace(validFlagMigrationManifestJSON, `"scope": "local"`, `"scope": "local", "shorthand": null`, 1),
`{"version": null, "migrations": []}`,
} {
_, err := ReadFlagMigrationManifest(strings.NewReader(input))
if err == nil || !strings.Contains(err.Error(), "must be") || !strings.Contains(err.Error(), "null") {
t.Fatalf("ReadFlagMigrationManifest() error = %v, want scalar null rejection", err)
}
}
}
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsWrongJSONTypes(t *testing.T) {
for _, input := range []string{
strings.Replace(validFlagMigrationManifestJSON, `"required": true`, `"required": "true"`, 1),
strings.Replace(validFlagMigrationManifestJSON, `"scope": "local"`, `"scope": false`, 1),
`{"version": "1", "migrations": []}`,
`{"version": 1, "migrations": {}}`,
} {
if _, err := ReadFlagMigrationManifest(strings.NewReader(input)); err == nil {
t.Fatal("ReadFlagMigrationManifest() accepted a field with the wrong JSON type")
}
}
}
func TestCrossPlatformCoverageReadFlagMigrationManifestReportsReaderFailure(t *testing.T) {
_, err := ReadFlagMigrationManifest(flagMigrationErrorReader{})
if err == nil || !strings.Contains(err.Error(), "injected read failure") {
t.Fatalf("ReadFlagMigrationManifest() error = %v, want reader failure", err)
}
}
func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *testing.T) {
if _, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON)); err != nil {
t.Fatalf("ReadFlagMigrationManifest(valid) error = %v", err)
}
tests := []struct {
name string
input string
wantErr string
}{
{
name: "unsupported version",
input: strings.Replace(validFlagMigrationManifestJSON, `"version": 1`, `"version": 2`, 1),
wantErr: "unsupported flag migration manifest version",
},
{
name: "missing migrations array",
input: `{"version": 1}`,
wantErr: "migrations must be an array",
},
{
name: "null migrations array",
input: `{"version": 1, "migrations": null}`,
wantErr: "migrations must be an array",
},
{
name: "wildcard command",
input: strings.Replace(validFlagMigrationManifestJSON, "dws chat message recall", "dws chat *", 1),
wantErr: "exact command path",
},
{
name: "wildcard flag",
input: strings.Replace(validFlagMigrationManifestJSON, `"name": "msg-id"`, `"name": "msg-*"`, 1),
wantErr: "exact legacy flag",
},
{
name: "empty reason",
input: strings.Replace(validFlagMigrationManifestJSON, "Reviewed message identifier flag migration.", "", 1),
wantErr: "non-empty reason",
},
{
name: "invalid state",
input: strings.Replace(validFlagMigrationManifestJSON, `"state": "pending"`, `"state": "approved"`, 1),
wantErr: "invalid state",
},
{
name: "legacy removed",
input: strings.Replace(validFlagMigrationManifestJSON, `"after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "message-id"}`, `"after": {"present": false}`, 1),
wantErr: "legacy flag must remain present",
},
{
name: "canonical hidden",
input: strings.Replace(validFlagMigrationManifestJSON, `"after": {"present": true, "type": "string", "required": true, "scope": "local"}`, `"after": {"present": true, "type": "string", "required": true, "hidden": true, "scope": "local"}`, 1),
wantErr: "canonical flag must remain visible",
},
{
name: "canonical remains optional",
input: strings.Replace(
validFlagMigrationManifestJSON,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"after": {"present": true, "type": "string", "scope": "local"}`,
1,
),
wantErr: "canonical flag must be required after migration",
},
{
name: "canonical was already required",
input: strings.Replace(
validFlagMigrationManifestJSON,
`"before": {"present": false}`,
`"before": {"present": true, "type": "string", "required": true, "scope": "local"}`,
1,
),
wantErr: "canonical flag must be absent or optional before migration",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := ReadFlagMigrationManifest(strings.NewReader(test.input))
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("ReadFlagMigrationManifest() error = %v, want %q", err, test.wantErr)
}
})
}
}
func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactContracts(t *testing.T) {
manifest, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON))
if err != nil {
t.Fatal(err)
}
duplicate := manifest
duplicate.Migrations = append(duplicate.Migrations, duplicate.Migrations[0])
if err := duplicate.Validate(); err == nil || !strings.Contains(err.Error(), "duplicates") {
t.Fatalf("duplicate migration error = %v", err)
}
whitespaceReason := manifest
whitespaceReason.Migrations = append([]FlagMigration(nil), manifest.Migrations...)
whitespaceReason.Migrations[0].Reason = " reviewed "
if err := whitespaceReason.Validate(); err == nil || !strings.Contains(err.Error(), "trimmed") {
t.Fatalf("whitespace reason error = %v", err)
}
canonicalDrift := manifest
canonicalDrift.Migrations = append([]FlagMigration(nil), manifest.Migrations...)
canonicalDrift.Migrations[0].Canonical.Before = FlagMigrationState{
Present: true,
Type: "string",
Scope: "local",
}
canonicalDrift.Migrations[0].Canonical.After.Type = "stringSlice"
if err := canonicalDrift.Validate(); err == nil || !strings.Contains(err.Error(), "canonical flag type") {
t.Fatalf("canonical type drift error = %v", err)
}
_, err = ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON + ` {}`))
if err == nil || !strings.Contains(err.Error(), "trailing") {
t.Fatalf("trailing JSON error = %v", err)
}
}
func TestCrossPlatformCoverageAuthorizeFlagMigrationsReturnsExactBaseOwnedApproval(t *testing.T) {
pending := coverageManifest(FlagMigrationPending)
consumed := coverageManifest(FlagMigrationConsumed)
migration := pending.Migrations[0]
before := coverageMigrationSnapshot(migration, false, false)
after := coverageMigrationSnapshot(migration, true, false)
got, err := AuthorizeFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
)
if err != nil {
t.Fatalf("AuthorizeFlagMigrations() error = %v", err)
}
if !reflect.DeepEqual(got, pending.Migrations) {
t.Fatalf("AuthorizeFlagMigrations() = %#v, want exact base-owned approval %#v", got, pending.Migrations)
}
}
+3
View File
@@ -117,5 +117,8 @@ func flagContractOtherwiseChanged(oldFlag, newFlag Flag) bool {
if !oldFlag.Hidden && newFlag.Hidden {
return true
}
if oldFlag.AliasOf != "" && newFlag.AliasOf != oldFlag.AliasOf {
return true
}
return false
}
+118 -9
View File
@@ -22,11 +22,16 @@ import (
"sort"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
const SchemaVersion = 1
const SchemaVersion = 2
// FlagAliasOfAnnotation records a framework-originated reviewed relationship
// between a retained compatibility flag and an exact canonical Cobra flag.
const FlagAliasOfAnnotation = runtimeannotate.AnnotationFlagAliasOf
var (
// Framework-owned command subtrees are deliberately excluded. They are
@@ -80,6 +85,12 @@ type Flag struct {
Required bool `json:"required"`
Hidden bool `json:"hidden"`
Deprecated string `json:"deprecated,omitempty"`
AliasOf string `json:"alias_of,omitempty"`
// Capture cannot return an error, so it preserves malformed or non-framework
// relation evidence here for Validate/Write to reject. Valid snapshots never
// serialize this implementation detail.
malformedAliasEvidence bool
}
// Capture walks root without rendering help or executing any command. Both
@@ -193,6 +204,9 @@ func (s Snapshot) Validate() error {
if err := validateFlags(command.Path, "inherited", command.InheritedFlags); err != nil {
return err
}
if err := validateFlagAliases(command); err != nil {
return err
}
}
return nil
}
@@ -206,21 +220,46 @@ func captureFlags(set *pflag.FlagSet) []Flag {
if flag == nil || excludedFlags[flag.Name] {
return
}
aliasOf, malformedAliasEvidence := captureFlagAliasOf(flag)
flags = append(flags, Flag{
Name: flag.Name,
Shorthand: flag.Shorthand,
Type: flag.Value.Type(),
Default: flag.DefValue,
NoOpt: flag.NoOptDefVal,
Required: isRequired(flag),
Hidden: flag.Hidden,
Deprecated: strings.TrimSpace(flag.Deprecated),
Name: flag.Name,
Shorthand: flag.Shorthand,
Type: flag.Value.Type(),
Default: flag.DefValue,
NoOpt: flag.NoOptDefVal,
Required: isRequired(flag),
Hidden: flag.Hidden,
Deprecated: strings.TrimSpace(flag.Deprecated),
AliasOf: aliasOf,
malformedAliasEvidence: malformedAliasEvidence,
})
})
sort.Slice(flags, func(i, j int) bool { return flags[i].Name < flags[j].Name })
return flags
}
func captureFlagAliasOf(flag *pflag.Flag) (string, bool) {
aliasOf, aliasMalformed := exactFlagAnnotation(flag, FlagAliasOfAnnotation)
origin, originMalformed := exactFlagAnnotation(flag, runtimeannotate.AnnotationFlagAliasOrigin)
if aliasOf == "" && origin == "" && !aliasMalformed && !originMalformed {
return "", false
}
malformed := aliasMalformed || originMalformed || aliasOf == "" ||
origin != runtimeannotate.FlagAliasOriginCorecmdV1
return aliasOf, malformed
}
func exactFlagAnnotation(flag *pflag.Flag, key string) (string, bool) {
values, exists := flag.Annotations[key]
if !exists {
return "", false
}
if len(values) != 1 || values[0] == "" || values[0] != strings.TrimSpace(values[0]) {
return "", true
}
return values[0], false
}
func isRequired(flag *pflag.Flag) bool {
for _, value := range flag.Annotations[cobra.BashCompOneRequiredFlag] {
if value == "true" {
@@ -268,3 +307,73 @@ func validateFlags(path, scope string, flags []Flag) error {
}
return nil
}
func validateFlagAliases(command Command) error {
flags := make(map[string]Flag, len(command.LocalFlags)+len(command.InheritedFlags))
ordered := make([]Flag, 0, len(command.LocalFlags)+len(command.InheritedFlags))
for _, flag := range command.LocalFlags {
flags[flag.Name] = flag
ordered = append(ordered, flag)
}
for _, flag := range command.InheritedFlags {
if _, exists := flags[flag.Name]; exists {
return fmt.Errorf("command %q contains flag %q in both local and inherited scopes", command.Path, flag.Name)
}
flags[flag.Name] = flag
ordered = append(ordered, flag)
}
for _, flag := range ordered {
if flag.malformedAliasEvidence {
return fmt.Errorf(
"command %q flag --%s must declare one exact %q value and framework origin %q",
command.Path,
flag.Name,
FlagAliasOfAnnotation,
runtimeannotate.FlagAliasOriginCorecmdV1,
)
}
if flag.AliasOf == "" {
continue
}
if !isExactFlagName(flag.AliasOf) {
return fmt.Errorf(
"command %q flag --%s has non-exact alias_of target %q",
command.Path,
flag.Name,
flag.AliasOf,
)
}
if flag.AliasOf == flag.Name {
return fmt.Errorf("command %q flag --%s cannot alias itself", command.Path, flag.Name)
}
target, exists := flags[flag.AliasOf]
if !exists {
return fmt.Errorf(
"command %q flag --%s aliases missing flag --%s",
command.Path,
flag.Name,
flag.AliasOf,
)
}
if target.AliasOf != "" {
return fmt.Errorf(
"command %q flag --%s aliases --%s, which is itself an alias",
command.Path,
flag.Name,
flag.AliasOf,
)
}
if flag.Type != target.Type {
return fmt.Errorf(
"command %q alias flags --%s and --%s have different types %q and %q",
command.Path,
flag.Name,
flag.AliasOf,
flag.Type,
target.Type,
)
}
}
return nil
}
+164 -1
View File
@@ -15,9 +15,12 @@ package interfacesnapshot
import (
"bytes"
"encoding/json"
"reflect"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
"github.com/spf13/cobra"
)
@@ -95,7 +98,7 @@ func TestCrossPlatformCoverageCaptureUsesStableNoiseRulesAndFlagScopes(t *testin
func TestCrossPlatformCoverageReadRejectsUnknownSnapshotFields(t *testing.T) {
input := bytes.NewBufferString(`{
"schema_version": 1,
"schema_version": 2,
"rules": {"excluded_command_subtrees": [], "excluded_flags": []},
"commands": [],
"future_field": true
@@ -105,6 +108,166 @@ func TestCrossPlatformCoverageReadRejectsUnknownSnapshotFields(t *testing.T) {
}
}
func TestCrossPlatformCoverageCaptureRoundTripsFrameworkFlagAlias(t *testing.T) {
if FlagAliasOfAnnotation != runtimeannotate.AnnotationFlagAliasOf {
t.Fatalf(
"flag alias annotation key drifted: snapshot=%q runtime=%q",
FlagAliasOfAnnotation,
runtimeannotate.AnnotationFlagAliasOf,
)
}
root := &cobra.Command{Use: "dws"}
leaf := &cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}}
corecmd.RegisterFlags(leaf, []corecmd.FlagSpec{{
Name: "message-id",
Usage: "message ID",
Aliases: []string{"open-conversation-id"},
}})
root.AddCommand(leaf)
snapshot := Capture(root)
flag := flagIndex(commandIndex(snapshot)["dws send"].LocalFlags)["open-conversation-id"]
if flag.AliasOf != "message-id" {
t.Fatalf("captured alias_of = %q, want message-id", flag.AliasOf)
}
var encoded bytes.Buffer
if err := Write(&encoded, snapshot); err != nil {
t.Fatalf("Write: %v", err)
}
decoded, err := Read(bytes.NewReader(encoded.Bytes()))
if err != nil {
t.Fatalf("Read: %v", err)
}
decodedFlag := flagIndex(commandIndex(decoded)["dws send"].LocalFlags)["open-conversation-id"]
if decodedFlag.AliasOf != "message-id" {
t.Fatalf("round-tripped alias_of = %q, want message-id", decodedFlag.AliasOf)
}
}
func TestCrossPlatformCoverageCaptureRejectsUntrustedFlagAliasEvidence(t *testing.T) {
for _, origin := range []string{"", "handwritten.alias.v1"} {
t.Run(origin, func(t *testing.T) {
root := &cobra.Command{Use: "dws"}
leaf := &cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}}
leaf.Flags().String("message-id", "", "canonical message ID")
leaf.Flags().String("legacy-id", "", "legacy message ID")
legacy := leaf.Flags().Lookup("legacy-id")
legacy.Annotations = map[string][]string{
FlagAliasOfAnnotation: {"message-id"},
}
if origin != "" {
legacy.Annotations[runtimeannotate.AnnotationFlagAliasOrigin] = []string{origin}
}
root.AddCommand(leaf)
if err := Capture(root).Validate(); err == nil {
t.Fatal("Validate accepted alias evidence outside corecmd FlagSpec.Aliases")
}
})
}
}
func TestCrossPlatformCoverageCaptureRejectsMultipleNonEmptyFlagAliases(t *testing.T) {
root := &cobra.Command{Use: "dws"}
leaf := &cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}}
leaf.Flags().String("message-id", "", "canonical message ID")
leaf.Flags().String("other-id", "", "other ID")
leaf.Flags().String("legacy-id", "", "legacy ID")
legacy := leaf.Flags().Lookup("legacy-id")
legacy.Annotations = map[string][]string{
FlagAliasOfAnnotation: {"message-id", "other-id"},
}
root.AddCommand(leaf)
snapshot := Capture(root)
if err := snapshot.Validate(); err == nil {
t.Fatal("Validate accepted multiple non-empty alias annotation values")
}
var encoded bytes.Buffer
if err := Write(&encoded, snapshot); err == nil {
t.Fatal("Write accepted multiple non-empty alias annotation values")
}
}
func TestCrossPlatformCoverageValidateRejectsMalformedFlagAliasState(t *testing.T) {
tests := []struct {
name string
aliasOf string
}{
{name: "self", aliasOf: "legacy-id"},
{name: "leading dashes", aliasOf: "--message-id"},
{name: "non exact", aliasOf: "message id"},
{name: "missing target", aliasOf: "missing-id"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
snapshot := testSnapshot(
testCommand("dws"),
testCommandWithFlagScopes(
"dws send",
[]Flag{
{Name: "legacy-id", Type: "string", AliasOf: test.aliasOf},
{Name: "message-id", Type: "string"},
},
nil,
),
)
if err := snapshot.Validate(); err == nil {
t.Fatalf("Validate accepted alias_of %q", test.aliasOf)
}
encoded, err := json.Marshal(snapshot)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
if _, err := Read(bytes.NewReader(encoded)); err == nil {
t.Fatalf("Read accepted alias_of %q", test.aliasOf)
}
})
}
}
func TestCrossPlatformCoverageValidateRejectsFlagAliasChainTypeAndScopeConflicts(t *testing.T) {
tests := []struct {
name string
local []Flag
inherited []Flag
}{
{
name: "alias chain",
local: []Flag{
{Name: "legacy-id", Type: "string", AliasOf: "middle-id"},
{Name: "middle-id", Type: "string", AliasOf: "message-id"},
{Name: "message-id", Type: "string"},
},
},
{
name: "different types",
local: []Flag{
{Name: "legacy-id", Type: "string", AliasOf: "message-id"},
{Name: "message-id", Type: "int"},
},
},
{
name: "same flag in local and inherited scopes",
local: []Flag{{Name: "legacy-id", Type: "string"}},
inherited: []Flag{{Name: "legacy-id", Type: "string"}},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
snapshot := testSnapshot(
testCommand("dws"),
testCommandWithFlagScopes("dws send", test.local, test.inherited),
)
if err := snapshot.Validate(); err == nil {
t.Fatalf("Validate accepted invalid alias contract: %#v", snapshot.Commands[1])
}
})
}
}
func TestCrossPlatformCoverageCompareBlocksCandidateSiblingAliasCollision(t *testing.T) {
base := testSnapshot(testCommand("dws"))
current := testSnapshot(
@@ -71,8 +71,8 @@ func TestCrossPlatformCoverageDocSemanticCatalogExactlyCoversRegisteredSurface(t
}
wantPrimaries := map[string]string{
"+find-doc": "+search", "+doc-append": "+update", "+version-save": "+history-save",
"+version-list": "+history-list", "+version-revert": "+history-revert", "+share-doc": "+share",
"+find-doc": "+search", "+doc-append": "+update", "+history-save": "+version-save",
"+history-list": "+version-list", "+history-revert": "+version-revert", "+share-doc": "+share",
}
for command, primary := range wantPrimaries {
item := registered[command]
+177 -38
View File
@@ -15,6 +15,7 @@ package chat
import (
"encoding/json"
"errors"
"fmt"
"math"
"path/filepath"
@@ -1081,6 +1082,10 @@ func DownloadMessageResources(
continue
}
preferredName := resourceDownloadPreferredName(data)
if preferredName == "" {
preferredName, _ = resource["name"].(string)
preferredName = strings.TrimSpace(preferredName)
}
filename := resourceDownloadFilename(resourceURL, preferredName)
filename = disambiguateResourceDownloadFilename(filename, downloadedNames)
output := filepath.Join(outputDir, filename)
@@ -1203,9 +1208,10 @@ func uniqueShortcutStrings(values []string) []string {
var MessagesQuerySendStatus = shortcut.Shortcut{
Service: "chat",
Command: "+messages-query-send-status",
Aliases: []string{"+messages-send-status"},
Product: "im",
Description: "查询消息发送状态",
Intent: "当你发消息后拿到 openTaskId、想确认这条消息是否发送成功时使用;只读返回发送状态,需传 --open-task-id。",
Description: "查询消息投递状态并衔接后续消息操作",
Intent: "当你发消息后拿到 openTaskId、想确认投递结果,或后续 edit/recall/read-status 需要取得 openMessageId 和 openConversationId 时使用;openTaskId 不是消息 ID。结果会保留下层响应,并追加版本化 messageRef 与结构化 nextActions。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -1218,17 +1224,18 @@ var MessagesQuerySendStatus = shortcut.Shortcut{
CanonicalPath: "chat.shortcut_messages_query_send_status",
CLIPath: "chat +messages-query-send-status",
PrimaryCLIPath: "chat +messages-query-send-status",
Aliases: []string{"chat +messages-send-status"},
},
Description: "查询消息发送状态",
Description: "查询消息投递状态并衔接后续消息操作",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "查询消息发送状态",
UseWhen: []string{"当你发消息后拿到 openTaskId、想确认这条消息是否发送成功时使用;只读返回发送状态,需传 --open-task-id。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
AgentSummary: "查询消息投递状态并衔接后续消息操作",
UseWhen: []string{"当你发消息后拿到 openTaskId、想确认投递结果,或后续 edit/recall/read-status 需要取得 openMessageId 和 openConversationId 时使用;openTaskId 不是消息 ID。结果会保留下层响应,并追加版本化 messageRef 与结构化 nextActions。"},
AvoidWhen: []string{"没有 openTaskId、已经有消息 ID,或只需查历史消息内容时不要使用"},
Examples: []string{"dws chat +messages-query-send-status --open-task-id <openTaskId>"},
},
},
@@ -1237,7 +1244,12 @@ var MessagesQuerySendStatus = shortcut.Shortcut{
},
Tips: []string{`dws chat +messages-query-send-status --open-task-id <openTaskId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("query_message_send_status", map[string]any{"openTaskId": rt.Str("open-task-id")})
taskID := rt.Str("open-task-id")
data, err := rt.CallMCPData("im", "query_message_send_status", map[string]any{"openTaskId": taskID})
if err != nil {
return err
}
return rt.Output(chatmsg.ProjectMessageSendStatus(data, taskID))
},
}
@@ -1439,8 +1451,8 @@ var MessagesSendCard = shortcut.Shortcut{
Service: "chat",
Command: "+messages-send-card",
Product: "im",
Description: "创建流式卡片,可在同一次调用中写入内容并结束",
Intent: "当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。",
Description: "创建流式卡片,可在同一次调用中写入内容并结束;群聊创建时可 @成员或 @所有人",
Intent: "当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。群聊可用 --at-open-dingtalk-ids 或 --at-all 在创建卡片时设置 @对象;同时传 --content 时,Runtime 会把创建响应中的 atTag 自动加在正文前,后续更新不重复传递 @参数。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
@@ -1454,30 +1466,34 @@ var MessagesSendCard = shortcut.Shortcut{
CLIPath: "chat +messages-send-card",
PrimaryCLIPath: "chat +messages-send-card",
},
Description: "创建流式卡片,可在同一次调用中写入内容并结束",
Description: "创建流式卡片,可在同一次调用中写入内容并结束;群聊创建时可 @成员或 @所有人",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed card lifecycle adapter: it can resolve a userId through contact search with exact matching, call create_and_send_card alone, or compose creation with update_streaming_card after extracting the returned bizId.",
Reason: "Reviewed card lifecycle adapter: it can resolve a userId through contact search with exact matching, call create_and_send_card alone, or compose creation with update_streaming_card after extracting the returned bizId and atTag.",
},
Selection: contract.SelectionSpec{
AgentSummary: "创建流式卡片,可在同一次调用中写入内容并结束",
UseWhen: []string{"当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。"},
AgentSummary: "创建流式卡片,可在同一次调用中写入内容并结束;群聊创建时可 @成员或 @所有人",
UseWhen: []string{"当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。群聊可用 --at-open-dingtalk-ids 或 --at-all 在创建卡片时设置 @对象;同时传 --content 时,Runtime 会把创建响应中的 atTag 自动加在正文前,后续更新不重复传递 @参数。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。"},
AvoidWhen: []string{"已有 bizId、只需要追加或更新现有卡片内容时使用 +messages-update-card"},
Examples: []string{
"dws chat +messages-send-card --group <openConversationId> --content \"任务已完成\"",
"dws chat +messages-send-card --group <openConversationId> --at-open-dingtalk-ids <openDingTalkId> --content \"任务已完成\"",
"dws chat +messages-send-card --receiver <userId>",
},
},
Parameters: []contract.ParamDecl{
{Name: "receiver-open-dingtalk-id", Property: "receiverOpenDingTalkId"},
{Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds"},
{Name: "at-all", Property: "atAll"},
},
},
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(与两个单聊接收者参数互斥)"},
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(与两个单聊接收者参数互斥);艾特参数仅支持群聊 --group"},
{Name: "receiver", Type: shortcut.FlagString, Desc: "单聊接收者 userId(与 --group/--receiver-open-dingtalk-id 互斥);始终通过通讯录搜索精确匹配 openDingTalkId,包括 --dry-run 和 D/d 开头的 userId"},
{Name: "receiver-open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊接收者 openDingTalkId(与 --group/--receiver 互斥);显式直传且不做通讯录解析"},
{Name: "content", Type: shortcut.FlagString, Desc: "创建后立即写入的卡片内容;省略时仅创建并返回 bizId"},
{Name: "at-open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "群聊创建卡片时 @ 的 openDingTalkId 列表;仅随 create_and_send_card 发送;艾特参数仅支持群聊 --group"},
{Name: "at-all", Type: shortcut.FlagBool, Desc: "群聊创建卡片时 @ 所有人;仅随 create_and_send_card 发送;艾特参数仅支持群聊 --group"},
{Name: "content", Type: shortcut.FlagString, Desc: "创建后立即写入的卡片正文;群聊 @ 时 Runtime 自动前置 create 返回的 atTag;省略时仅创建并返回 bizId"},
{Name: "flow-status", Type: shortcut.FlagInt, Default: "3", Desc: "自动更新状态:1处理中/2输入中/3完成/4执行中/5错误;--flow-status 必须在 1-5 之间,且显式指定时必须同时提供 --content"},
},
Constraints: []shortcut.Constraint{
@@ -1487,10 +1503,15 @@ var MessagesSendCard = shortcut.Shortcut{
Flags: []string{"flow-status"},
Description: "--flow-status 必须在 1-5 之间,且显式指定时必须同时提供 --content",
},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"group", "at-open-dingtalk-ids", "at-all"},
Description: "艾特参数仅支持群聊 --group",
},
},
Tips: []string{
`dws chat +messages-send-card --group <openConversationId>`,
`dws chat +messages-send-card --group <openConversationId> --content "任务已完成"`,
`dws chat +messages-send-card --group <openConversationId> --at-open-dingtalk-ids <openDingTalkId> --content "任务已完成"`,
},
Validate: func(rt *shortcut.RuntimeContext) error {
if status := rt.Int("flow-status"); !validCardFlowStatus(status) {
@@ -1499,6 +1520,9 @@ var MessagesSendCard = shortcut.Shortcut{
if rt.Changed("flow-status") && rt.Str("content") == "" {
return fmt.Errorf("--flow-status 只有与 --content 一起使用才有意义")
}
if rt.Str("group") == "" && (len(uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids"))) > 0 || rt.Bool("at-all")) {
return fmt.Errorf("--at-open-dingtalk-ids 和 --at-all 仅支持群聊 --group")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -1506,9 +1530,18 @@ var MessagesSendCard = shortcut.Shortcut{
receiver := rt.Str("receiver")
receiverOpenID := rt.Str("receiver-open-dingtalk-id")
params := map[string]any{}
mentionsRequested := false
switch {
case group != "":
params["openConversationId"] = group
if atOpenIDs := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")); len(atOpenIDs) > 0 {
params["atOpenDingTalkIds"] = atOpenIDs
mentionsRequested = true
}
if rt.Bool("at-all") {
params["atAll"] = true
mentionsRequested = true
}
case receiver != "":
openID, err := resolveUserOpenDingTalkID(rt, receiver)
if err != nil {
@@ -1520,10 +1553,35 @@ var MessagesSendCard = shortcut.Shortcut{
}
content := rt.Str("content")
if content == "" {
return rt.CallMCP("create_and_send_card", params)
if rt.DryRun() {
return rt.Output(map[string]any{
"contractVersion": chatmsg.StreamingCardContractVersion,
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"actionCount": 1,
"actions": []map[string]any{{
"tool": "create_and_send_card",
"arguments": params,
}},
})
}
created, err := rt.CallMCPWriteData("im", "create_and_send_card", params)
if err != nil {
return err
}
bizID := findCardBizID(created)
if bizID == "" {
return cardCreateMissingBizIDError(created)
}
return rt.Output(chatmsg.ProjectStreamingCardReceipt(created, bizID))
}
status := rt.Int("flow-status")
if rt.DryRun() {
plannedContent := content
if mentionsRequested {
plannedContent = "<atTag from create_and_send_card>" + content
}
return rt.Output(map[string]any{
"contractVersion": currentCardWorkflowContract.Version,
"dry_run": true,
@@ -1540,7 +1598,7 @@ var MessagesSendCard = shortcut.Shortcut{
"tool": "update_streaming_card",
"arguments": map[string]any{
"bizId": "<from create_and_send_card>",
"msgContent": content,
"msgContent": plannedContent,
"flowStatus": status,
},
},
@@ -1555,32 +1613,42 @@ var MessagesSendCard = shortcut.Shortcut{
if bizID == "" {
return fmt.Errorf("卡片已创建但下层未返回 bizId,无法自动更新;请检查 create_and_send_card 响应")
}
atTag := findCardAtTag(created)
if mentionsRequested && atTag == "" {
return fmt.Errorf("卡片已创建(bizId=%s),但下层未返回 atTag,无法保证请求的 @ 生效;未执行自动更新", bizID)
}
updated, err := rt.CallMCPWriteData("im", "update_streaming_card", map[string]any{
"bizId": bizID,
"msgContent": content,
"msgContent": atTag + content,
"flowStatus": status,
})
if err != nil {
return fmt.Errorf("卡片已创建(bizId=%s),但自动更新失败: %w", bizID, err)
}
return rt.Output(map[string]any{
"contractVersion": currentCardWorkflowContract.Version,
"ok": true,
"bizId": bizID,
"flowStatus": status,
"created": created,
"updated": updated,
})
if _, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated); err != nil {
return fmt.Errorf("卡片已创建(bizId=%s),但自动更新结果不可信: %w", bizID, cardUpdateVerificationError(bizID, err))
}
payload := chatmsg.ProjectStreamingCardReceipt(created, bizID)
payload["bizId"] = bizID
payload["flowStatus"] = status
payload["updated"] = updated
return rt.Output(payload)
},
}
func findCardBizID(value any) string {
return strings.TrimSpace(findCardResponseString(value, []string{"bizId", "bizID", "biz_id"}))
}
func findCardAtTag(value any) string {
return findCardResponseString(value, []string{"atTag"})
}
func findCardResponseString(value any, directKeys []string) string {
switch typed := value.(type) {
case map[string]any:
directKeys := []string{"bizId", "bizID", "biz_id"}
for _, key := range directKeys {
if candidate, ok := typed[key].(string); ok && strings.TrimSpace(candidate) != "" {
candidate = strings.TrimSpace(candidate)
return candidate
}
}
@@ -1595,7 +1663,7 @@ func findCardBizID(value any) string {
}
for _, key := range envelopeKeys {
visited[key] = struct{}{}
if candidate := findCardBizID(typed[key]); candidate != "" {
if candidate := findCardResponseString(typed[key], directKeys); candidate != "" {
return candidate
}
}
@@ -1608,13 +1676,13 @@ func findCardBizID(value any) string {
}
sort.Strings(remainingKeys)
for _, key := range remainingKeys {
if candidate := findCardBizID(typed[key]); candidate != "" {
if candidate := findCardResponseString(typed[key], directKeys); candidate != "" {
return candidate
}
}
case []any:
for _, child := range typed {
if candidate := findCardBizID(child); candidate != "" {
if candidate := findCardResponseString(child, directKeys); candidate != "" {
return candidate
}
}
@@ -1623,7 +1691,7 @@ func findCardBizID(value any) string {
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
var nested any
if json.Unmarshal([]byte(trimmed), &nested) == nil {
return findCardBizID(nested)
return findCardResponseString(nested, directKeys)
}
}
}
@@ -1659,7 +1727,7 @@ var MessagesUpdateCard = shortcut.Shortcut{
Selection: contract.SelectionSpec{
AgentSummary: "流式更新卡片内容(最后一次 --flow-status 应为 3)",
UseWhen: []string{"当你要向已发送的流式文本卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus 1-5(最后一次应为 3 表示完成)。当前不支持 Card JSON 组件或 action callback。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
AvoidWhen: []string{"需要底层原始响应、未公开参数,或由调用方自行管理确认与更新节奏时,改用 chat message update-card"},
Examples: []string{"dws chat +messages-update-card --biz-id <bizId> --content \"内容\" --flow-status 3"},
},
},
@@ -1673,20 +1741,91 @@ var MessagesUpdateCard = shortcut.Shortcut{
},
Tips: []string{`dws chat +messages-update-card --biz-id <bizId> --content "内容" --flow-status 3`},
Validate: func(rt *shortcut.RuntimeContext) error {
if _, err := chatmsg.NormalizeCardBizID(rt.Str("biz-id")); err != nil {
return err
}
if !validCardFlowStatus(rt.Int("flow-status")) {
return fmt.Errorf("--flow-status 必须在 1-5 之间")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("update_streaming_card", map[string]any{
"bizId": rt.Str("biz-id"),
// Validate has already normalized and rejected empty, placeholder, and
// whitespace-containing values before Execute is entered.
bizID, _ := chatmsg.NormalizeCardBizID(rt.Str("biz-id"))
params := map[string]any{
"bizId": bizID,
"msgContent": rt.Str("content"),
"flowStatus": rt.Int("flow-status"),
})
}
if rt.DryRun() {
return rt.Output(map[string]any{
"dry_run": true,
"executed": false,
"verified": false,
"action": map[string]any{
"product": "im",
"tool": "update_streaming_card",
"arguments": params,
},
})
}
updated, err := rt.CallMCPWriteData("im", "update_streaming_card", params)
if err != nil {
return err
}
proof, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated)
if err != nil {
return cardUpdateVerificationError(bizID, err)
}
return rt.Output(chatmsg.ProjectStreamingCardUpdate(updated, bizID, proof))
},
}
func cardCreateMissingBizIDError(created map[string]any) error {
return apperrors.NewAPI(
"卡片可能已经创建,但服务端未返回后续更新所需的 bizId;CLI 无法确认卡片工作流可继续",
apperrors.WithOperation("create_and_send_card"),
apperrors.WithServerKey("im"),
apperrors.WithOrigin("client_postcondition"),
apperrors.WithFailureStage("verify_card_reference"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithReason("streaming_card_reference_missing"),
apperrors.WithHint("不要盲目重试创建;请保留 trace_id 并推动服务端返回 bizId、openMessageId 和 openConversationId"),
apperrors.WithDetails(map[string]any{"created": created}),
)
}
func cardUpdateVerificationError(bizID string, verifyErr error) error {
reason := "streaming_card_update_unverified"
message := "服务端未返回卡片实际更新的证据;为避免假成功,CLI 已将本次操作判为失败"
hint := "请检查服务端是否返回 updated=true、affectedCount>0 或等价的明确更新结果"
switch {
case errors.Is(verifyErr, chatmsg.ErrCardUpdateNotApplied):
reason = "streaming_card_update_not_applied"
message = "服务端明确表示流式卡片没有被更新"
hint = "请确认 bizId 来自 send-card、当前账号有权限且卡片仍允许该状态转换"
case errors.Is(verifyErr, chatmsg.ErrCardUpdateBizIDDrift):
reason = "streaming_card_update_biz_id_mismatch"
message = "服务端返回的 bizId 与本次请求不一致;无法确认目标卡片已更新"
hint = "请保留 trace_id 并检查 update_streaming_card 的响应映射"
}
return apperrors.NewAPI(
message,
apperrors.WithOperation("update_streaming_card"),
apperrors.WithServerKey("im"),
apperrors.WithOrigin("client_postcondition"),
apperrors.WithFailureStage("verify_update_result"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithReason(reason),
apperrors.WithHint(hint),
apperrors.WithDetails(map[string]any{"bizId": bizID}),
apperrors.WithCause(verifyErr),
)
}
// MessagesResourceURL gets a message resource download url (get_resource_download_url, im).
var MessagesResourceURL = shortcut.Shortcut{
Service: "chat",
@@ -360,7 +360,9 @@ func TestCrossPlatformCoverageRecallCardAndLedgerBoundaries(t *testing.T) {
})
}
helpers.InitDeps(&larkAlignmentCaller{})
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"im/update_streaming_card": `{"result":{"updated":true}}`,
}})
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+messages-update-card", "--biz-id", "b", "--content", "x", "--flow-status", "3", "--yes"})
if err := root.Execute(); err != nil {
+403 -2
View File
@@ -20,6 +20,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
type chatOutputErrorWriter struct {
@@ -124,6 +125,159 @@ func TestCrossPlatformCoverageIMWorkflowContractsPublishRealPositiveAndNegativeB
}
}
func TestCrossPlatformCoverageMessagesSendStatusAliasPublishesWorkflowReceipt(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/query_message_send_status": `{"result":{"status":"SUCCESS","openTaskId":"task-1","openMessageId":"msg-1","openConversationId":"cid-1"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+messages-send-status", "--open-task-id", "task-1"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "query_message_send_status" || fake.calls[0].args["openTaskId"] != "task-1" {
t.Fatalf("calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["contractVersion"] != chatmsg.MessageSendStatusContractVersion || payload["readyForMessageActions"] != true {
t.Fatalf("payload = %#v", payload)
}
ref, _ := payload["messageRef"].(map[string]any)
if ref["openMessageId"] != "msg-1" || ref["openConversationId"] != "cid-1" {
t.Fatalf("messageRef = %#v", ref)
}
}
func TestCrossPlatformCoverageMessageWorkflowFailureAndPreviewBranches(t *testing.T) {
t.Run("send status lower error", func(t *testing.T) {
fake := &larkAlignmentCaller{failProductTool: "im/query_message_send_status"}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+messages-query-send-status", "--open-task-id", "task-1"})
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "fixture lower call failed") {
t.Fatalf("error = %v", err)
}
})
t.Run("create only dry run", func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send-card", "--group", "cid", "--dry-run", "--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 0 {
t.Fatalf("dry-run made calls: %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["actionCount"] != float64(1) || payload["executed"] != false {
t.Fatalf("payload = %#v", payload)
}
})
for _, test := range []struct {
name string
fake *larkAlignmentCaller
wantError string
}{
{
name: "create only lower error",
fake: &larkAlignmentCaller{failProductTool: "im/create_and_send_card"},
wantError: "fixture lower call failed",
},
{
name: "create only missing biz id",
fake: &larkAlignmentCaller{responses: map[string]string{
"im/create_and_send_card": `{"result":{"created":true}}`,
}},
wantError: "未返回后续更新所需的 bizId",
},
} {
t.Run(test.name, func(t *testing.T) {
helpers.InitDeps(test.fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+messages-send-card", "--group", "cid", "--yes"})
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("error = %v, want substring %q", err, test.wantError)
}
})
}
t.Run("update card lower error", func(t *testing.T) {
fake := &larkAlignmentCaller{failProductTool: "im/update_streaming_card"}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-update-card",
"--biz-id", "biz-1", "--content", "完成", "--flow-status", "3", "--yes",
})
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "fixture lower call failed") {
t.Fatalf("error = %v", err)
}
})
createErr := cardCreateMissingBizIDError(map[string]any{"created": true})
var typed *apperrors.Error
if !errors.As(createErr, &typed) || typed.Reason != "streaming_card_reference_missing" {
t.Fatalf("create error = %#v", createErr)
}
for _, test := range []struct {
cause error
wantReason string
}{
{cause: chatmsg.ErrCardUpdateNotApplied, wantReason: "streaming_card_update_not_applied"},
{cause: chatmsg.ErrCardUpdateBizIDDrift, wantReason: "streaming_card_update_biz_id_mismatch"},
} {
typed = nil
err := cardUpdateVerificationError("biz-1", test.cause)
if !errors.As(err, &typed) || typed.Reason != test.wantReason {
t.Errorf("cardUpdateVerificationError(%v) = %#v", test.cause, err)
}
}
}
func TestCrossPlatformCoverageMessagesSendPublishesStatusQueryReceipt(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"chat/send_personal_message": `{"result":{"openTaskId":"task-send-1"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send", "--as", "user", "--chat-id", "cid-1",
"--text", "hello", "--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
receipt, _ := payload["sendReceipt"].(map[string]any)
if receipt["contractVersion"] != chatmsg.MessageSendReceiptContractVersion || receipt["openTaskId"] != "task-send-1" {
t.Fatalf("sendReceipt = %#v", receipt)
}
actions, _ := receipt["nextActions"].([]any)
if len(actions) != 1 {
t.Fatalf("nextActions = %#v", actions)
}
}
func TestCrossPlatformCoverageMessagesSendBotMultiGroupPublishesPerTargetLedger(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
@@ -609,7 +763,7 @@ func TestCrossPlatformCoverageMessagesSendTextModesAndExecuteGuard(t *testing.T)
func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/create_and_send_card": `{"result":{"card":{"biz_id":"biz-1"}}}`,
"im/create_and_send_card": `{"result":{"card":{"biz_id":"biz-1","atTag":"<a atId=D-one>甲</a> <a atId=D-two>乙</a> "}}}`,
"im/update_streaming_card": `{"result":{"updated":true}}`,
}}
helpers.InitDeps(fake)
@@ -619,6 +773,8 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--at-open-dingtalk-ids", "D-one,D-two,D-one",
"--at-all",
"--content", "完成",
"--flow-status", "3",
"--yes",
@@ -630,7 +786,20 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
fake.calls[1].tool != "update_streaming_card" {
t.Fatalf("card calls = %#v", fake.calls)
}
if fake.calls[1].args["bizId"] != "biz-1" || fake.calls[1].args["msgContent"] != "完成" ||
if got, want := fake.calls[0].args["atOpenDingTalkIds"], []string{"D-one", "D-two"}; !reflect.DeepEqual(got, want) {
t.Fatalf("card create atOpenDingTalkIds = %#v, want %#v", got, want)
}
if fake.calls[0].args["atAll"] != true {
t.Fatalf("card create atAll = %#v", fake.calls[0].args["atAll"])
}
if _, exists := fake.calls[1].args["atOpenDingTalkIds"]; exists {
t.Fatalf("card update leaked atOpenDingTalkIds: %#v", fake.calls[1].args)
}
if _, exists := fake.calls[1].args["atAll"]; exists {
t.Fatalf("card update leaked atAll: %#v", fake.calls[1].args)
}
if fake.calls[1].args["bizId"] != "biz-1" ||
fake.calls[1].args["msgContent"] != "<a atId=D-one>甲</a> <a atId=D-two>乙</a> 完成" ||
fake.calls[1].args["flowStatus"] != 3 {
t.Fatalf("card update args = %#v", fake.calls[1].args)
}
@@ -643,6 +812,49 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
}
}
func TestCrossPlatformCoverageMessagesSendCardKeepsContentWithoutAtTag(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/create_and_send_card": `{"result":{"bizId":"biz-no-mention"}}`,
"im/update_streaming_card": `{"result":{"updated":true}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--content", "正文",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[1].args["msgContent"] != "正文" {
t.Fatalf("card calls = %#v", fake.calls)
}
}
func TestCrossPlatformCoverageMessagesSendCardRejectsMissingRequestedAtTag(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/create_and_send_card": `{"result":{"bizId":"biz-missing-at-tag"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--at-open-dingtalk-ids", "D-mentioned",
"--content", "正文",
"--yes",
})
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), "biz-missing-at-tag") || !strings.Contains(err.Error(), "atTag") {
t.Fatalf("error = %v, want recoverable missing-atTag error containing bizId", err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "create_and_send_card" {
t.Fatalf("card calls = %#v, want create only", fake.calls)
}
}
func TestCrossPlatformCoverageMessagesSendCardResolvesReceiverForLowerTool(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
@@ -748,6 +960,50 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
}
})
t.Run("dry run mentions only in create", func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--at-open-dingtalk-ids", "D-mentioned",
"--at-all",
"--content", "处理中",
"--dry-run",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 0 {
t.Fatalf("card group dry-run calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
actions, _ := payload["actions"].([]any)
create, _ := actions[0].(map[string]any)
createArguments, _ := create["arguments"].(map[string]any)
update, _ := actions[1].(map[string]any)
updateArguments, _ := update["arguments"].(map[string]any)
if !reflect.DeepEqual(createArguments["atOpenDingTalkIds"], []any{"D-mentioned"}) || createArguments["atAll"] != true {
t.Fatalf("card create dry-run mentions = %#v", createArguments)
}
if _, exists := updateArguments["atOpenDingTalkIds"]; exists {
t.Fatalf("card update dry-run leaked atOpenDingTalkIds: %#v", updateArguments)
}
if _, exists := updateArguments["atAll"]; exists {
t.Fatalf("card update dry-run leaked atAll: %#v", updateArguments)
}
if updateArguments["msgContent"] != "<atTag from create_and_send_card>处理中" {
t.Fatalf("card update dry-run content = %#v", updateArguments["msgContent"])
}
})
t.Run("receiver resolution error", func(t *testing.T) {
fake := &larkAlignmentCaller{failProductTool: "contact/search_contact_by_key_word"}
helpers.InitDeps(fake)
@@ -793,6 +1049,14 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
},
wantError: "biz-preserved",
},
{
name: "unverified update preserves id",
fake: &larkAlignmentCaller{responses: map[string]string{
"im/create_and_send_card": `{"bizId":"biz-unverified"}`,
"im/update_streaming_card": `{"success":true,"errorCode":null}`,
}},
wantError: "biz-unverified",
},
} {
t.Run(tc.name, func(t *testing.T) {
helpers.InitDeps(tc.fake)
@@ -815,6 +1079,8 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
{"--group", "cid", "--flow-status", "2"},
{"--group", "cid", "--receiver-open-dingtalk-id", "D-direct"},
{"--receiver", "user-id", "--receiver-open-dingtalk-id", "D-direct"},
{"--receiver", "user-id", "--at-open-dingtalk-ids", "D-mentioned"},
{"--receiver-open-dingtalk-id", "D-direct", "--at-all"},
} {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
@@ -825,6 +1091,120 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
}
}
func TestCrossPlatformCoverageMessagesUpdateCardRejectsFalseSuccess(t *testing.T) {
t.Run("agent shortcut owns confirmation boundary", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/update_streaming_card": `{"result":{"bizId":"biz-confirm","updated":true}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetIn(strings.NewReader(""))
root.SetArgs([]string{
"chat", "+messages-update-card",
"--biz-id", "biz-confirm",
"--content", "高层更新",
"--flow-status", "3",
})
err := root.Execute()
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "confirmation_required" {
t.Fatalf("error = %#v, want confirmation_required", err)
}
if len(fake.calls) != 0 {
t.Fatalf("unconfirmed shortcut reached MCP: %#v", fake.calls)
}
})
t.Run("generic success is unverified", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/update_streaming_card": `{"success":true,"errorCode":null}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-update-card",
"--biz-id", "中文乱串",
"--content", "完成",
"--flow-status", "3",
"--yes",
})
err := root.Execute()
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "streaming_card_update_unverified" {
t.Fatalf("error = %#v, want streaming_card_update_unverified", err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "update_streaming_card" {
t.Fatalf("calls = %#v", fake.calls)
}
})
t.Run("explicit update evidence succeeds", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/update_streaming_card": `{"result":{"bizId":"biz-verified","updated":true}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-update-card",
"--biz-id", "biz-verified",
"--content", "完成",
"--flow-status", "3",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
})
t.Run("placeholder fails before write", func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-update-card",
"--biz-id", "<bizId>",
"--content", "完成",
"--flow-status", "3",
"--yes",
})
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "占位符") {
t.Fatalf("error = %v, want placeholder validation", err)
}
if len(fake.calls) != 0 {
t.Fatalf("invalid placeholder made calls: %#v", fake.calls)
}
})
t.Run("dry run only publishes plan", func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-update-card",
"--biz-id", "biz-preview",
"--content", "完成",
"--flow-status", "3",
"--dry-run",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 0 {
t.Fatalf("dry-run made calls: %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["executed"] != false || payload["verified"] != false {
t.Fatalf("dry-run payload = %#v", payload)
}
})
}
func TestCrossPlatformCoverageFindCardBizIDResponseShapes(t *testing.T) {
for _, tc := range []struct {
value any
@@ -858,6 +1238,24 @@ func TestCrossPlatformCoverageFindCardBizIDResponseShapes(t *testing.T) {
}
}
func TestCrossPlatformCoverageFindCardAtTagResponseShapes(t *testing.T) {
for _, tc := range []struct {
value any
want string
}{
{map[string]any{"atTag": "<a atId=D-direct>甲</a> "}, "<a atId=D-direct>甲</a> "},
{map[string]any{"result": map[string]any{"atTag": "<a atId=D-nested>乙</a> "}}, "<a atId=D-nested>乙</a> "},
{`{"result":{"card":{"atTag":"<a atId=D-json>丙</a> "}}}`, "<a atId=D-json>丙</a> "},
{map[string]any{"atTag": " "}, ""},
{map[string]any{"atTag": 42}, ""},
{map[string]any{"result": map[string]any{"created": true}}, ""},
} {
if got := findCardAtTag(tc.value); got != tc.want {
t.Errorf("findCardAtTag(%#v) = %q, want %q", tc.value, got, tc.want)
}
}
}
func TestCrossPlatformCoverageMessageResourceDownloadKeepsNestedMessageContext(t *testing.T) {
resetResourceDownloadHooks(t)
t.Chdir(t.TempDir())
@@ -968,6 +1366,9 @@ func TestCrossPlatformCoverageMessageFileResourceDownloadUsesDriveAndPreservesNa
fake.calls[0].args["fileId"] != "drive-file" {
t.Fatalf("drive call = %#v", fake.calls)
}
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"drive/download_file": `{"result":{"downloadUrl":"https://download.dingtalk.com/opaque"}}`,
}})
var ledger map[string]any
shortcut.Register(shortcut.Shortcut{
@@ -75,6 +75,8 @@ func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string,
text = string(payload)
case "im/create_group_conversation":
text = `{"result":{"cid":"internal-cid","openCid":"open-cid"}}`
case "im/create_and_send_card":
text = `{"result":{"bizId":"biz-created"}}`
case "im/list_messages_by_ids":
text = `{"result":[{"openMessageId":"msg","openConversationId":"cid","senderOpenDingTalkId":"D-inferred","content":"{\"mediaId\":\"@image\"}"}]}`
case "im/list_conversations_by_category":
+11 -4
View File
@@ -19,6 +19,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
)
@@ -372,12 +373,16 @@ func executeUnifiedMessageWrite(rt *shortcut.RuntimeContext, product, tool strin
if err != nil {
return err
}
return rt.Output(map[string]any{
payload := map[string]any{
"ok": true,
"identity": messagesSendIdentity(rt),
"tool": tool,
"result": data,
})
}
if messagesSendIdentity(rt) == "user" && tool == "send_personal_message" {
payload["sendReceipt"] = chatmsg.ProjectMessageSendReceipt(data)
}
return rt.Output(payload)
}
func messagesSendIdentity(rt *shortcut.RuntimeContext) string {
@@ -612,7 +617,7 @@ func executeMessagesSendUserFile(
if err != nil {
return err
}
return rt.Output(map[string]any{
payload := map[string]any{
"ok": true,
"identity": "user",
"tool": "send_personal_message",
@@ -624,7 +629,9 @@ func executeMessagesSendUserFile(
"sizeBytes": meta.FileSize,
},
"result": data,
})
}
payload["sendReceipt"] = chatmsg.ProjectMessageSendReceipt(data)
return rt.Output(payload)
}
func addMessagesSendUserTarget(params map[string]any, group, openID string) {
+65
View File
@@ -0,0 +1,65 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package chatmsg
import "strings"
// StreamingCardContractVersion identifies the additive card receipt emitted by
// high-level card shortcuts.
const StreamingCardContractVersion = "im.streaming-card.v1"
// ProjectStreamingCardReceipt publishes every server-returned identifier in a
// single cardRef. referencePairAvailable means this response contained both
// the update identifier and the visible message identifiers; it does not claim
// that older messages can be resolved without server-side mapping support.
func ProjectStreamingCardReceipt(created map[string]any, bizID string) map[string]any {
bizID = strings.TrimSpace(bizID)
messageID := firstSendStatusString(created, "openMessageId", "messageId", "msgId")
conversationID := firstSendStatusString(created, "openConversationId", "conversationId", "openCid")
cardRef := map[string]any{}
if bizID != "" {
cardRef["bizId"] = bizID
}
if messageID != "" {
cardRef["openMessageId"] = messageID
}
if conversationID != "" {
cardRef["openConversationId"] = conversationID
}
pairAvailable := bizID != "" && messageID != "" && conversationID != ""
payload := map[string]any{
"contractVersion": StreamingCardContractVersion,
"ok": true,
"cardRef": cardRef,
"referencePairAvailable": pairAvailable,
"created": created,
"nextActions": []map[string]any{},
}
if bizID != "" {
payload["nextActions"] = []map[string]any{{
"cliPath": "chat +messages-update-card",
"arguments": map[string]any{
"biz-id": bizID,
},
"requiredArguments": []string{"content", "flow-status"},
"ready": false,
}}
}
if !pairAvailable {
payload["capabilityGap"] = "服务端尚未同时返回 bizId、openMessageId 和 openConversationId;CLI 只能保留本次响应,不能据此承诺从历史消息反向恢复 bizId"
}
return payload
}
// ProjectStreamingCardUpdate preserves the lower response while making the
// verified target explicit for downstream consumers.
func ProjectStreamingCardUpdate(updated map[string]any, bizID, proof string) map[string]any {
payload := cloneSendStatusMap(updated)
payload["contractVersion"] = StreamingCardContractVersion
payload["cardRef"] = map[string]any{"bizId": strings.TrimSpace(bizID)}
payload["verified"] = true
payload["verificationEvidence"] = proof
return payload
}
@@ -0,0 +1,42 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package chatmsg
import "testing"
func TestCrossPlatformCoverageProjectStreamingCardReceipt(t *testing.T) {
complete := ProjectStreamingCardReceipt(map[string]any{
"result": map[string]any{
"bizId": "biz-1",
"openMessageId": "msg-1",
"openConversationId": "cid-1",
},
}, "biz-1")
if complete["contractVersion"] != StreamingCardContractVersion || complete["referencePairAvailable"] != true {
t.Fatalf("complete receipt = %#v", complete)
}
ref, _ := complete["cardRef"].(map[string]any)
if ref["bizId"] != "biz-1" || ref["openMessageId"] != "msg-1" || ref["openConversationId"] != "cid-1" {
t.Fatalf("cardRef = %#v", ref)
}
if _, exists := complete["capabilityGap"]; exists {
t.Fatalf("complete receipt has capability gap: %#v", complete)
}
partial := ProjectStreamingCardReceipt(map[string]any{"result": map[string]any{"bizId": "biz-2"}}, "biz-2")
if partial["referencePairAvailable"] != false || partial["capabilityGap"] == "" {
t.Fatalf("partial receipt = %#v", partial)
}
}
func TestCrossPlatformCoverageProjectStreamingCardUpdate(t *testing.T) {
payload := ProjectStreamingCardUpdate(map[string]any{"result": map[string]any{"updated": true}}, "biz-1", "updated=true")
if payload["contractVersion"] != StreamingCardContractVersion || payload["verified"] != true || payload["verificationEvidence"] != "updated=true" {
t.Fatalf("payload = %#v", payload)
}
if _, exists := payload["result"]; !exists {
t.Fatal("lower response was not preserved")
}
}
+176
View File
@@ -0,0 +1,176 @@
// 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 chatmsg
import (
"encoding/json"
"errors"
"fmt"
"strings"
"unicode"
)
var (
ErrCardUpdateNotApplied = errors.New("streaming card update was not applied")
ErrCardUpdateUnverified = errors.New("streaming card update could not be verified")
ErrCardUpdateBizIDDrift = errors.New("streaming card update returned a different bizId")
)
// NormalizeCardBizID performs only format-independent checks. bizId is an
// opaque server-issued identifier; a stricter character or prefix contract
// must not be invented by the CLI without an authoritative API declaration.
func NormalizeCardBizID(raw string) (string, error) {
bizID := strings.TrimSpace(raw)
if bizID == "" {
return "", fmt.Errorf("--biz-id 不能为空")
}
for _, r := range bizID {
if unicode.IsControl(r) || unicode.IsSpace(r) {
return "", fmt.Errorf("--biz-id 必须是 send-card 返回的单个不透明 ID,不能包含空白或控制字符")
}
}
if isCardBizIDPlaceholder(bizID) {
return "", fmt.Errorf("--biz-id 仍是占位符 %q;请传入 send-card 实际返回的 bizId", bizID)
}
return bizID, nil
}
func isCardBizIDPlaceholder(value string) bool {
normalized := strings.ToLower(strings.TrimSpace(value))
switch normalized {
case "bizid", "biz-id", "your-biz-id", "your_biz_id",
"<bizid>", "<biz-id>", "<your-biz-id>",
"{bizid}", "{biz-id}", "${bizid}", "${biz-id}":
return true
default:
return false
}
}
// VerifyStreamingCardUpdate requires affirmative evidence that the requested
// write took effect. A transport-level {success:true,errorCode:null} only says
// that the RPC returned normally and is deliberately not accepted as proof of
// a card update.
func VerifyStreamingCardUpdate(requestedBizID string, response map[string]any) (string, error) {
requestedBizID = strings.TrimSpace(requestedBizID)
observation := cardUpdateObservation{bizIDs: map[string]struct{}{}}
observeCardUpdate(response, &observation)
for responseBizID := range observation.bizIDs {
if requestedBizID != "" && responseBizID != requestedBizID {
return "", fmt.Errorf("%w: requested %q, response %q", ErrCardUpdateBizIDDrift, requestedBizID, responseBizID)
}
}
if observation.positiveEvidence != "" && observation.negativeEvidence != "" {
return "", fmt.Errorf("%w: conflicting evidence %s and %s", ErrCardUpdateUnverified, observation.positiveEvidence, observation.negativeEvidence)
}
if observation.positiveEvidence != "" {
return observation.positiveEvidence, nil
}
if observation.negativeEvidence != "" {
return "", fmt.Errorf("%w: %s", ErrCardUpdateNotApplied, observation.negativeEvidence)
}
return "", ErrCardUpdateUnverified
}
type cardUpdateObservation struct {
bizIDs map[string]struct{}
positiveEvidence string
negativeEvidence string
}
func observeCardUpdate(value any, observation *cardUpdateObservation) {
switch typed := value.(type) {
case map[string]any:
observeCardUpdateMap(typed, observation)
case []any:
for _, child := range typed {
observeCardUpdate(child, observation)
}
case bool:
if typed {
setPositiveCardUpdateEvidence(observation, "result=true")
} else {
setNegativeCardUpdateEvidence(observation, "result=false")
}
}
}
func observeCardUpdateMap(value map[string]any, observation *cardUpdateObservation) {
for _, key := range []string{"bizId", "bizID", "biz_id"} {
if candidate, ok := value[key].(string); ok && strings.TrimSpace(candidate) != "" {
observation.bizIDs[strings.TrimSpace(candidate)] = struct{}{}
}
}
for _, key := range []string{"updated", "applied"} {
if applied, ok := value[key].(bool); ok {
if applied {
setPositiveCardUpdateEvidence(observation, key+"=true")
} else {
setNegativeCardUpdateEvidence(observation, key+"=false")
}
}
}
for _, key := range []string{"affectedCount", "updatedCount", "modifiedCount"} {
if count, ok := cardUpdateCount(value[key]); ok {
if count > 0 {
setPositiveCardUpdateEvidence(observation, fmt.Sprintf("%s=%d", key, count))
} else if count == 0 {
setNegativeCardUpdateEvidence(observation, fmt.Sprintf("%s=%d", key, count))
}
}
}
// Only documented response envelopes are traversed. This prevents an
// unrelated extension field containing "updated":true from proving the
// business write.
for _, key := range []string{"result", "data", "response", "card"} {
if child, exists := value[key]; exists {
observeCardUpdate(child, observation)
}
}
}
func setPositiveCardUpdateEvidence(observation *cardUpdateObservation, evidence string) {
if observation.positiveEvidence == "" {
observation.positiveEvidence = evidence
}
}
func setNegativeCardUpdateEvidence(observation *cardUpdateObservation, evidence string) {
if observation.negativeEvidence == "" {
observation.negativeEvidence = evidence
}
}
func cardUpdateCount(value any) (int64, bool) {
switch typed := value.(type) {
case int:
return int64(typed), true
case int32:
return int64(typed), true
case int64:
return typed, true
case float32:
return int64(typed), float32(int64(typed)) == typed
case float64:
return int64(typed), float64(int64(typed)) == typed
case json.Number:
count, err := typed.Int64()
return count, err == nil
default:
return 0, false
}
}
@@ -0,0 +1,107 @@
// 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 chatmsg
import (
"encoding/json"
"errors"
"testing"
)
func TestCrossPlatformCoverageNormalizeCardBizID(t *testing.T) {
for _, test := range []struct {
name string
raw string
want string
wantErr bool
}{
{name: "opaque id", raw: " card-token-1 ", want: "card-token-1"},
{name: "opaque unicode remains server owned", raw: "中文乱串", want: "中文乱串"},
{name: "empty", raw: " ", wantErr: true},
{name: "placeholder", raw: "<bizId>", wantErr: true},
{name: "internal space", raw: "card token", wantErr: true},
{name: "control", raw: "card\ntoken", wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
got, err := NormalizeCardBizID(test.raw)
if test.wantErr {
if err == nil {
t.Fatalf("NormalizeCardBizID(%q) unexpectedly succeeded", test.raw)
}
return
}
if err != nil || got != test.want {
t.Fatalf("NormalizeCardBizID(%q) = %q, %v; want %q", test.raw, got, err, test.want)
}
})
}
}
func TestCrossPlatformCoverageVerifyStreamingCardUpdate(t *testing.T) {
for _, test := range []struct {
name string
response map[string]any
wantProof string
wantErrIs error
}{
{name: "updated", response: map[string]any{"result": map[string]any{"updated": true}}, wantProof: "updated=true"},
{name: "affected", response: map[string]any{"data": map[string]any{"affectedCount": float64(1)}}, wantProof: "affectedCount=1"},
{name: "boolean result", response: map[string]any{"result": true}, wantProof: "result=true"},
{name: "boolean false result", response: map[string]any{"result": false}, wantErrIs: ErrCardUpdateNotApplied},
{name: "matching id", response: map[string]any{"result": map[string]any{"bizId": "biz-1", "applied": true}}, wantProof: "applied=true"},
{name: "conflicting evidence", response: map[string]any{"updated": true, "applied": false}, wantErrIs: ErrCardUpdateUnverified},
{name: "zero affected", response: map[string]any{"affectedCount": 0}, wantErrIs: ErrCardUpdateNotApplied},
{name: "false success has no write proof", response: map[string]any{"success": true, "errorCode": nil}, wantErrIs: ErrCardUpdateUnverified},
{name: "explicitly not updated", response: map[string]any{"result": map[string]any{"updated": false}}, wantErrIs: ErrCardUpdateNotApplied},
{name: "mismatched id", response: map[string]any{"result": map[string]any{"bizId": "biz-2", "updated": true}}, wantErrIs: ErrCardUpdateBizIDDrift},
{name: "unrelated extension ignored", response: map[string]any{"extension": map[string]any{"updated": true}}, wantErrIs: ErrCardUpdateUnverified},
} {
t.Run(test.name, func(t *testing.T) {
proof, err := VerifyStreamingCardUpdate("biz-1", test.response)
if test.wantErrIs != nil {
if !errors.Is(err, test.wantErrIs) {
t.Fatalf("VerifyStreamingCardUpdate error = %v, want errors.Is(_, %v)", err, test.wantErrIs)
}
return
}
if err != nil || proof != test.wantProof {
t.Fatalf("VerifyStreamingCardUpdate = %q, %v; want %q", proof, err, test.wantProof)
}
})
}
}
func TestCrossPlatformCoverageCardUpdateCountScalarVariants(t *testing.T) {
for _, test := range []struct {
value any
want int64
ok bool
}{
{value: int(1), want: 1, ok: true},
{value: int32(2), want: 2, ok: true},
{value: int64(3), want: 3, ok: true},
{value: float32(4), want: 4, ok: true},
{value: float32(4.5), want: 4, ok: false},
{value: float64(5), want: 5, ok: true},
{value: float64(5.5), want: 5, ok: false},
{value: json.Number("6"), want: 6, ok: true},
{value: json.Number("6.5"), ok: false},
{value: "7", ok: false},
} {
got, ok := cardUpdateCount(test.value)
if got != test.want || ok != test.ok {
t.Errorf("cardUpdateCount(%#v) = (%d, %v), want (%d, %v)", test.value, got, ok, test.want, test.ok)
}
}
}
+174 -7
View File
@@ -91,6 +91,7 @@ var messageResultContractV1 = MessageResultContract{
"failedCount",
"failures",
"partial",
"scope",
"resourceDownloads",
},
}
@@ -283,9 +284,10 @@ func SenderType(m map[string]any) any {
// legacy aliases such as time or msgType, but the underlying identity,
// context, reaction, quote, forward, and resource semantics come from here.
func ProjectMessageV1(m map[string]any, includeReactions bool) map[string]any {
ownedResources := Resources(m)
row := map[string]any{
"sender": Sender(m),
"text": Text(m),
"text": projectedResourceText(m, ownedResources),
"createTime": CreateTime(m),
}
if value := MessageID(m); value != nil {
@@ -357,7 +359,8 @@ func QuotedMessage(m map[string]any) map[string]any {
if value := Sender(quoted); value != nil {
out["sender"] = value
}
if value := Text(quoted); value != nil {
resources := Resources(quoted)
if value := projectedResourceText(quoted, resources); value != nil {
out["text"] = value
}
if value := CreateTime(quoted); value != nil {
@@ -366,7 +369,7 @@ func QuotedMessage(m map[string]any) map[string]any {
if value := MessageType(quoted); value != nil {
out["messageType"] = value
}
if resources := Resources(quoted); len(resources) > 0 {
if len(resources) > 0 {
out["resourceRefs"] = resources
}
return out
@@ -400,10 +403,14 @@ func Resources(m map[string]any) []map[string]any {
collectResourceIDs(m, "mediaid", mediaIDTextRE, &mediaIDs)
mediaIDs = uniqueResourceIDs(mediaIDs)
sort.Strings(mediaIDs)
mediaNames := make(map[string]resourceNameCandidate)
collectResourceNames(m, "mediaid", mediaNames)
fileIDs := make([]string, 0)
collectResourceIDs(m, "fileid", fileIDTextRE, &fileIDs)
fileIDs = uniqueResourceIDs(fileIDs)
sort.Strings(fileIDs)
fileNames := make(map[string]resourceNameCandidate)
collectResourceNames(m, "fileid", fileNames)
if len(mediaIDs) == 0 && len(fileIDs) == 0 {
return nil
}
@@ -434,7 +441,7 @@ func Resources(m map[string]any) []map[string]any {
} else {
missing = append(missing, "open-conversation-id")
}
out = append(out, map[string]any{
resource := map[string]any{
"type": "mediaId",
"resourceId": id,
"download": map[string]any{
@@ -443,10 +450,14 @@ func Resources(m map[string]any) []map[string]any {
"ready": len(missing) == 0,
"missing": missing,
},
})
}
if candidate, ok := mediaNames[id]; ok {
resource["name"] = candidate.name
}
out = append(out, resource)
}
for _, id := range fileIDs {
out = append(out, map[string]any{
resource := map[string]any{
"type": "fileId",
"resourceId": id,
"download": map[string]any{
@@ -458,7 +469,11 @@ func Resources(m map[string]any) []map[string]any {
"ready": true,
"missing": []string{},
},
})
}
if candidate, ok := fileNames[id]; ok {
resource["name"] = candidate.name
}
out = append(out, resource)
}
return out
}
@@ -503,6 +518,158 @@ func resourcesDeep(m map[string]any, inheritedConversationID string, depth int)
var mediaIDTextRE = regexp.MustCompile(`(?i)\bmedia[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`)
var fileIDTextRE = regexp.MustCompile(`(?i)\bfile[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`)
var fileNameAndIDTextRE = regexp.MustCompile(`(?i)\[文件\]\s*([^\r\n]*?)\s+file[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`)
var legacyResourceDownloadHintRE = regexp.MustCompile(`\s*注意:如需下载使用dws\s+(?:chat message download-media|drive download)命令下载\s*`)
// projectedResourceText removes only the exact, machine-generated download
// hint emitted by older IM APIs. The readable resource marker and ID remain in
// text, while resourceRefs publishes the current executable download command.
// Text without an owned mediaId/fileId is left byte-for-byte unchanged so an
// ordinary user sentence mentioning a command can never be rewritten.
func projectedResourceText(m map[string]any, resources []map[string]any) any {
value := Text(m)
text, ok := value.(string)
if !ok || len(resources) == 0 ||
(!mediaIDTextRE.MatchString(text) && !fileIDTextRE.MatchString(text)) {
return value
}
return strings.TrimSpace(legacyResourceDownloadHintRE.ReplaceAllString(text, ""))
}
type resourceNameCandidate struct {
name string
priority int
}
const (
resourceNamePriorityText = 1
resourceNamePriorityStructured = 2
)
// collectResourceNames keeps a resource ID paired with a name only when both
// are present in the same structured object or in the legacy, machine-shaped
// "[文件] name fileId: id" text. This deliberately does not borrow a generic
// message title or sender name: an unknown resource name is safer than a
// plausible but incorrect one.
func collectResourceNames(value any, targetKey string, out map[string]resourceNameCandidate) {
switch typed := value.(type) {
case map[string]any:
directIDs := directResourceIDs(typed, targetKey)
if len(directIDs) == 1 {
if name := directResourceName(typed, targetKey); name != "" {
recordResourceName(out, directIDs[0], name, resourceNamePriorityStructured)
}
}
keys := make([]string, 0, len(typed))
for key := range typed {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if isNestedMessageBoundaryKey(normalizeMessageKey(key)) {
continue
}
collectResourceNames(typed[key], targetKey, out)
}
case []any:
for _, child := range typed {
collectResourceNames(child, targetKey, out)
}
case []map[string]any:
for _, child := range typed {
collectResourceNames(child, targetKey, out)
}
case string:
if targetKey == "fileid" {
for _, match := range fileNameAndIDTextRE.FindAllStringSubmatch(typed, -1) {
name := strings.TrimSpace(match[1])
id := resourceIDScalar(match[2])
if name != "" && id != "" {
recordResourceName(out, id, name, resourceNamePriorityText)
}
}
}
trimmed := strings.TrimSpace(typed)
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
var decoded any
if json.Unmarshal([]byte(trimmed), &decoded) == nil {
collectResourceNames(decoded, targetKey, out)
}
}
}
}
func directResourceIDs(value map[string]any, targetKey string) []string {
resourceType := normalizeMessageKey(strings.TrimSpace(fmt.Sprint(
firstMessageValue(value, "resourceType", "resource_type"))))
keys := make([]string, 0, len(value))
for key := range value {
keys = append(keys, key)
}
sort.Strings(keys)
ids := make([]string, 0, 1)
for _, key := range keys {
normalizedKey := normalizeMessageKey(key)
if normalizedKey != targetKey &&
!(normalizedKey == "resourceid" && resourceType == targetKey) {
continue
}
if id := resourceIDScalar(value[key]); id != "" {
ids = append(ids, id)
}
}
return uniqueResourceIDs(ids)
}
func directResourceName(value map[string]any, targetKey string) string {
for _, wanted := range []string{"filename", "resourcename", "originalfilename"} {
if name := directResourceString(value, wanted); name != "" {
return name
}
}
// A bare "name" is accepted only inside an explicit resource envelope.
// Message rows also commonly contain a sender/group name, which must never
// become the attachment filename merely because the row has a resource ID.
resourceType := normalizeMessageKey(strings.TrimSpace(fmt.Sprint(
firstMessageValue(value, "resourceType", "resource_type"))))
if resourceType == targetKey && directResourceString(value, "resourceid") != "" {
return directResourceString(value, "name")
}
return ""
}
func directResourceString(value map[string]any, wanted string) string {
keys := make([]string, 0, len(value))
for key := range value {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if normalizeMessageKey(key) != wanted {
continue
}
if text, ok := value[key].(string); ok {
return strings.TrimSpace(text)
}
}
return ""
}
func recordResourceName(
out map[string]resourceNameCandidate,
id, name string,
priority int,
) {
id = resourceIDScalar(id)
name = strings.TrimSpace(name)
if id == "" || name == "" {
return
}
if current, ok := out[id]; ok && current.priority >= priority {
return
}
out[id] = resourceNameCandidate{name: name, priority: priority}
}
func collectResourceIDs(value any, targetKey string, textPattern *regexp.Regexp, out *[]string) {
switch typed := value.(type) {
+22
View File
@@ -540,6 +540,28 @@ func TestCrossPlatformCoverageResourcesBuildsActionableDownloadReferences(t *tes
}
}
func TestCrossPlatformCoverageResourcesKeepsNestedResourceNamesWithTheirOwner(t *testing.T) {
message := map[string]any{
"openMessageId": "parent-message",
"openConversationId": "cid-1",
"content": `{"fileId":"parent-file","fileName":"parent.pdf"}`,
"quotedMessage": map[string]any{
"openMessageId": "quoted-message",
"content": `{"fileId":"quoted-file","file_name":"quoted.pdf"}`,
},
}
resources := ResourcesDeep(message)
if len(resources) != 2 {
t.Fatalf("resources = %#v", resources)
}
if resources[0]["resourceId"] != "parent-file" || resources[0]["name"] != "parent.pdf" {
t.Fatalf("parent resource = %#v", resources[0])
}
if resources[1]["resourceId"] != "quoted-file" || resources[1]["name"] != "quoted.pdf" {
t.Fatalf("quoted resource = %#v", resources[1])
}
}
func TestCrossPlatformCoverageResourcesReportsMissingDownloadContext(t *testing.T) {
resources := Resources(map[string]any{"content": `{"mediaId":"@image-a"}`})
if len(resources) != 1 {
@@ -28,16 +28,18 @@ func TestCrossPlatformCoverageQuotedResourcesAndScalarVariants(t *testing.T) {
}
resources := Resources(map[string]any{
"attachments": []map[string]any{
{"resourceType": "mediaId", "resourceId": "@file-a"},
{"resourceType": "fileId", "resourceId": "drive-file"},
{"resourceType": "mediaId", "resourceId": "@file-a", "name": "photo.png"},
{"resourceType": "fileId", "resourceId": "drive-file", "fileName": "canonical-report.txt"},
{"mediaId": 42, "fileId": 42},
},
"content": `[文件] report.txt fileId: drive-file`,
})
if len(resources) != 2 ||
resources[0]["resourceId"] != "@file-a" ||
resources[0]["name"] != "photo.png" ||
resources[1]["resourceId"] != "drive-file" ||
resources[1]["type"] != "fileId" {
resources[1]["type"] != "fileId" ||
resources[1]["name"] != "canonical-report.txt" {
t.Fatalf("resources = %#v", resources)
}
fileDownload := resources[1]["download"].(map[string]any)
@@ -53,6 +55,65 @@ func TestCrossPlatformCoverageQuotedResourcesAndScalarVariants(t *testing.T) {
}
}
func TestCrossPlatformCoverageResourcesExtractsLegacyFileNameWithoutGuessing(t *testing.T) {
resources := Resources(map[string]any{
"name": "sender-name-must-not-leak",
"openMessageId": "msg-1",
"openConversationId": "cid-1",
"content": `[文件] 项目最终报告 2026.pdf fileId: drive-file 注意:如需下载使用旧命令`,
"unrelatedAttachment": map[string]any{"mediaId": "@image-without-name"},
})
if len(resources) != 2 {
t.Fatalf("resources = %#v", resources)
}
if resources[0]["resourceId"] != "@image-without-name" {
t.Fatalf("first resource = %#v", resources[0])
}
if _, leaked := resources[0]["name"]; leaked {
t.Fatalf("message sender name leaked into media resource: %#v", resources[0])
}
if resources[1]["resourceId"] != "drive-file" ||
resources[1]["name"] != "项目最终报告 2026.pdf" {
t.Fatalf("file resource = %#v", resources[1])
}
}
func TestCrossPlatformCoverageResourceNameRejectsIncompletePairs(t *testing.T) {
names := map[string]resourceNameCandidate{}
recordResourceName(names, "", "report.pdf", resourceNamePriorityStructured)
recordResourceName(names, "file-1", "", resourceNamePriorityStructured)
if len(names) != 0 {
t.Fatalf("incomplete resource-name pairs were retained: %#v", names)
}
}
func TestCrossPlatformCoverageProjectionRemovesOnlyLegacyResourceDownloadHint(t *testing.T) {
legacy := `[文件] 项目最终报告 2026.pdf fileId: drive-file 注意:如需下载使用dws drive download命令下载`
row := ProjectMessageV1(map[string]any{"content": legacy}, false)
if row["text"] != `[文件] 项目最终报告 2026.pdf fileId: drive-file` {
t.Fatalf("projected text = %#v", row["text"])
}
resources := row["resourceRefs"].([]map[string]any)
if len(resources) != 1 ||
resources[0]["name"] != "项目最终报告 2026.pdf" ||
resources[0]["download"].(map[string]any)["shortcut"] != "+messages-resource-download" {
t.Fatalf("projected resources = %#v", resources)
}
mediaRow := ProjectMessageV1(map[string]any{
"openMessageId": "msg-media",
"openConversationId": "cid-media",
"content": `[图片消息](mediaId=@media) 注意:如需下载使用dws chat message download-media命令下载`,
}, false)
if mediaRow["text"] != `[图片消息](mediaId=@media)` {
t.Fatalf("projected media text = %#v", mediaRow["text"])
}
ordinary := `团队规范:注意:如需下载使用dws drive download命令下载`
if got := ProjectMessageV1(map[string]any{"content": ordinary}, false)["text"]; got != ordinary {
t.Fatalf("ordinary text was rewritten: got %#v, want %q", got, ordinary)
}
}
func TestCrossPlatformCoverageReactionShapeVariants(t *testing.T) {
got := Reactions(map[string]any{
"reactions": []map[string]any{
+192
View File
@@ -0,0 +1,192 @@
// 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 chatmsg
import (
"fmt"
"strings"
)
// SearchItems locates and flattens the message list returned by the two
// DingTalk message-search interfaces. Grouped search responses carry the
// conversation identity on the group rather than each message, so the
// flattener copies that identity onto every returned message before callers
// perform scope checks.
func SearchItems(data map[string]any) []map[string]any {
if data == nil {
return nil
}
for _, root := range []map[string]any{data, childMap(data, "result")} {
if root == nil {
continue
}
if groups, ok := root["conversationMessagesList"].([]any); ok {
return flattenSearchGroups(groups)
}
}
keys := []string{"list", "messages", "messageList", "items", "data", "records", "result"}
for _, key := range keys {
if arr, ok := data[key].([]any); ok {
return searchMaps(arr)
}
if inner, ok := data[key].(map[string]any); ok {
for _, innerKey := range []string{"list", "messages", "messageList", "items", "data", "records"} {
if arr, ok := inner[innerKey].([]any); ok {
return searchMaps(arr)
}
}
}
}
return nil
}
func childMap(data map[string]any, key string) map[string]any {
if value, ok := data[key].(map[string]any); ok {
return value
}
return nil
}
func flattenSearchGroups(groups []any) []map[string]any {
out := make([]map[string]any, 0)
for _, rawGroup := range groups {
group, ok := rawGroup.(map[string]any)
if !ok {
continue
}
messages, ok := group["messages"].([]any)
if !ok {
continue
}
conversationID := cleanSearchScalar(group["openConversationId"])
conversationTitle := cleanSearchScalar(group["title"])
singleChat, hasSingleChat := group["singleChat"]
for _, rawMessage := range messages {
message, ok := rawMessage.(map[string]any)
if !ok {
continue
}
item := make(map[string]any, len(message)+3)
for key, value := range message {
item[key] = value
}
if _, exists := item["openConversationId"]; !exists && conversationID != "" {
item["openConversationId"] = conversationID
}
if _, exists := item["conversationTitle"]; !exists && conversationTitle != "" {
item["conversationTitle"] = conversationTitle
}
if _, exists := item["singleChat"]; !exists && hasSingleChat {
item["singleChat"] = singleChat
}
out = append(out, item)
}
}
return out
}
func searchMaps(items []any) []map[string]any {
out := make([]map[string]any, 0, len(items))
for _, item := range items {
if message, ok := item.(map[string]any); ok {
out = append(out, message)
}
}
return out
}
// FilterConversationScope keeps only messages belonging to the explicitly
// requested conversations. A message without a conversation identity is
// reported as unverifiable rather than treated as in-scope.
func FilterConversationScope(messages []map[string]any, conversationIDs []string) (matched []map[string]any, unverifiableMessageIDs []string) {
requested := make(map[string]struct{}, len(conversationIDs))
for _, conversationID := range conversationIDs {
if value := strings.TrimSpace(conversationID); value != "" {
requested[value] = struct{}{}
}
}
matched = make([]map[string]any, 0, len(messages))
for _, message := range messages {
conversationID := cleanSearchScalar(ConversationID(message))
if conversationID == "" {
messageID := cleanSearchScalar(MessageID(message))
if messageID == "" {
messageID = "<unknown>"
}
unverifiableMessageIDs = append(unverifiableMessageIDs, messageID)
continue
}
if _, ok := requested[conversationID]; ok {
matched = append(matched, message)
}
}
return matched, uniqueStrings(unverifiableMessageIDs)
}
// GroupSearchMessages restores the established typed search envelope after a
// client-side scoped scan. Group order follows first occurrence in the search
// result, and each message retains its original fields.
func GroupSearchMessages(messages []map[string]any) []map[string]any {
groups := make([]map[string]any, 0)
index := make(map[string]int)
for _, message := range messages {
conversationID := cleanSearchScalar(ConversationID(message))
if conversationID == "" {
continue
}
groupIndex, ok := index[conversationID]
if !ok {
group := map[string]any{
"openConversationId": conversationID,
"messages": []map[string]any{},
}
if title := cleanSearchScalar(message["conversationTitle"]); title != "" {
group["title"] = title
}
if singleChat, exists := message["singleChat"]; exists {
group["singleChat"] = singleChat
}
groups = append(groups, group)
groupIndex = len(groups) - 1
index[conversationID] = groupIndex
}
groupMessages := groups[groupIndex]["messages"].([]map[string]any)
groups[groupIndex]["messages"] = append(groupMessages, message)
}
return groups
}
func cleanSearchScalar(value any) string {
if value == nil {
return ""
}
text := strings.TrimSpace(fmt.Sprint(value))
if text == "" || text == "<nil>" || strings.EqualFold(text, "null") {
return ""
}
return text
}
func uniqueStrings(values []string) []string {
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
+104
View File
@@ -0,0 +1,104 @@
// 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 chatmsg
import (
"reflect"
"testing"
)
func TestSearchItemsCarriesGroupedConversationIdentity(t *testing.T) {
data := map[string]any{
"result": map[string]any{
"conversationMessagesList": []any{
map[string]any{
"openConversationId": "cid-group",
"title": "项目群",
"singleChat": false,
"messages": []any{
map[string]any{"openMessageId": "m1", "content": "hello"},
},
},
},
},
}
items := SearchItems(data)
if len(items) != 1 || items[0]["openConversationId"] != "cid-group" ||
items[0]["conversationTitle"] != "项目群" || items[0]["singleChat"] != false {
t.Fatalf("items = %#v", items)
}
}
func TestFilterConversationScopeDropsOtherConversationsAndRejectsMissingIdentity(t *testing.T) {
messages := []map[string]any{
{"openMessageId": "m1", "openConversationId": "cid-target"},
{"openMessageId": "m2", "openConversationId": "cid-other"},
{"openMessageId": "m3"},
}
matched, missing := FilterConversationScope(messages, []string{"cid-target"})
if len(matched) != 1 || matched[0]["openMessageId"] != "m1" {
t.Fatalf("matched = %#v", matched)
}
if !reflect.DeepEqual(missing, []string{"m3"}) {
t.Fatalf("missing = %#v", missing)
}
}
func TestGroupSearchMessagesPreservesFirstSeenOrder(t *testing.T) {
messages := []map[string]any{
{"openMessageId": "m1", "openConversationId": "cid-b", "conversationTitle": "B"},
{"openMessageId": "m2", "openConversationId": "cid-a", "conversationTitle": "A"},
{"openMessageId": "m3", "openConversationId": "cid-b", "conversationTitle": "B"},
}
groups := GroupSearchMessages(messages)
if len(groups) != 2 || groups[0]["openConversationId"] != "cid-b" || groups[1]["openConversationId"] != "cid-a" {
t.Fatalf("groups = %#v", groups)
}
groupMessages, _ := groups[0]["messages"].([]map[string]any)
if len(groupMessages) != 2 || groupMessages[1]["openMessageId"] != "m3" {
t.Fatalf("group messages = %#v", groupMessages)
}
}
func TestCrossPlatformCoverageSearchProjectionEdgeBranches(t *testing.T) {
if SearchItems(nil) != nil {
t.Fatal("nil search response returned messages")
}
matched, missing := FilterConversationScope(
[]map[string]any{{}},
[]string{"", "cid-target"},
)
if len(matched) != 0 || !reflect.DeepEqual(missing, []string{"<unknown>"}) {
t.Fatalf("scope result = matched:%#v missing:%#v", matched, missing)
}
groups := GroupSearchMessages([]map[string]any{
{"openMessageId": "missing-scope"},
{"openMessageId": "m1", "openConversationId": "cid-1", "singleChat": true},
})
if len(groups) != 1 || groups[0]["singleChat"] != true {
t.Fatalf("groups = %#v", groups)
}
if cleanSearchScalar(nil) != "" || cleanSearchScalar(" null ") != "" || cleanSearchScalar(" value ") != "value" {
t.Fatal("cleanSearchScalar did not normalize sentinel values")
}
if got := uniqueStrings([]string{"m1", "m1", "m2"}); !reflect.DeepEqual(got, []string{"m1", "m2"}) {
t.Fatalf("uniqueStrings = %#v", got)
}
}
+162
View File
@@ -0,0 +1,162 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package chatmsg
import (
"strings"
)
// MessageSendStatusContractVersion identifies the additive workflow fields
// projected by the high-level send-status shortcut. The lower response fields
// remain at their original locations for compatibility.
const MessageSendStatusContractVersion = "im.message-send-status.v1"
// MessageSendReceiptContractVersion identifies the additive receipt attached
// to high-level current-user send results.
const MessageSendReceiptContractVersion = "im.message-send-receipt.v1"
// ProjectMessageSendReceipt connects a send result to its asynchronous status
// query without treating openTaskId as a message identifier.
func ProjectMessageSendReceipt(raw map[string]any) map[string]any {
taskID := firstSendStatusString(raw, "openTaskId", "taskId")
messageID := firstSendStatusString(raw, "openMessageId", "messageId", "msgId")
conversationID := firstSendStatusString(raw, "openConversationId", "conversationId", "openCid")
ready := messageID != "" && conversationID != ""
receipt := map[string]any{
"contractVersion": MessageSendReceiptContractVersion,
"openTaskId": taskID,
"readyForMessageActions": ready,
"nextActions": []map[string]any{},
}
if messageID != "" || conversationID != "" {
messageRef := map[string]any{}
if messageID != "" {
messageRef["openMessageId"] = messageID
}
if conversationID != "" {
messageRef["openConversationId"] = conversationID
}
receipt["messageRef"] = messageRef
}
switch {
case ready:
receipt["nextActions"] = sendStatusNextActions(taskID, messageID, conversationID, true)
case taskID != "":
receipt["nextActions"] = []map[string]any{{
"cliPath": "chat +messages-query-send-status",
"arguments": map[string]any{
"open-task-id": taskID,
},
"ready": true,
"when": "需要确认投递结果或取得真实消息 ID 时",
}}
default:
receipt["capabilityGap"] = "下层发送响应未返回 openTaskId 或完整 messageRef,CLI 无法生成后续状态查询"
}
return receipt
}
// ProjectMessageSendStatus preserves the lower response and adds a stable
// receipt that connects openTaskId to the message identifiers required by
// edit, recall, and read-status. It never manufactures a message reference:
// downstream actions are marked ready only when both IDs are actually present.
func ProjectMessageSendStatus(raw map[string]any, requestedTaskID string) map[string]any {
payload := cloneSendStatusMap(raw)
taskID := firstSendStatusString(payload, "openTaskId", "taskId")
if taskID == "" {
taskID = strings.TrimSpace(requestedTaskID)
}
messageID := firstSendStatusString(payload, "openMessageId", "messageId", "msgId")
conversationID := firstSendStatusString(payload, "openConversationId", "conversationId", "openCid")
payload["contractVersion"] = MessageSendStatusContractVersion
payload["openTaskId"] = taskID
messageRef := map[string]any{}
if messageID != "" {
messageRef["openMessageId"] = messageID
}
if conversationID != "" {
messageRef["openConversationId"] = conversationID
}
if len(messageRef) > 0 {
payload["messageRef"] = messageRef
}
ready := messageID != "" && conversationID != ""
payload["readyForMessageActions"] = ready
payload["nextActions"] = sendStatusNextActions(taskID, messageID, conversationID, ready)
return payload
}
func sendStatusNextActions(taskID, messageID, conversationID string, ready bool) []map[string]any {
if !ready {
return []map[string]any{{
"cliPath": "chat message query-send-status",
"arguments": map[string]any{
"open-task-id": taskID,
},
"ready": false,
"when": "投递任务尚未返回 openMessageId 和 openConversationId 时稍后重查",
}}
}
messageArgs := map[string]any{
"conversation-id": conversationID,
"msg-id": messageID,
}
return []map[string]any{
{
"cliPath": "chat message recall",
"arguments": cloneSendStatusMap(messageArgs),
"ready": true,
},
{
"cliPath": "chat message edit",
"arguments": map[string]any{
"conversation-id": conversationID,
"msg-id": messageID,
},
"requiredArguments": []string{"text 或 content"},
"ready": false,
},
{
"cliPath": "chat message read-status",
"arguments": map[string]any{
"conversation-id": conversationID,
"message-id": messageID,
},
"ready": true,
},
}
}
func firstSendStatusString(value any, keys ...string) string {
switch typed := value.(type) {
case map[string]any:
for _, key := range keys {
if candidate, ok := typed[key].(string); ok && strings.TrimSpace(candidate) != "" {
return strings.TrimSpace(candidate)
}
}
for _, key := range []string{"result", "data", "response", "content", "message"} {
if candidate := firstSendStatusString(typed[key], keys...); candidate != "" {
return candidate
}
}
case []any:
for _, item := range typed {
if candidate := firstSendStatusString(item, keys...); candidate != "" {
return candidate
}
}
}
return ""
}
func cloneSendStatusMap(source map[string]any) map[string]any {
clone := make(map[string]any, len(source)+5)
for key, value := range source {
clone[key] = value
}
return clone
}
@@ -0,0 +1,94 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package chatmsg
import "testing"
func TestCrossPlatformCoverageProjectMessageSendReceiptLinksStatusQuery(t *testing.T) {
receipt := ProjectMessageSendReceipt(map[string]any{
"result": map[string]any{"openTaskId": "task-1"},
})
if receipt["contractVersion"] != MessageSendReceiptContractVersion || receipt["openTaskId"] != "task-1" || receipt["readyForMessageActions"] != false {
t.Fatalf("receipt = %#v", receipt)
}
actions, _ := receipt["nextActions"].([]map[string]any)
if len(actions) != 1 || actions[0]["cliPath"] != "chat +messages-query-send-status" || actions[0]["ready"] != true {
t.Fatalf("nextActions = %#v", actions)
}
}
func TestCrossPlatformCoverageProjectMessageSendReceiptReadyWorkflow(t *testing.T) {
receipt := ProjectMessageSendReceipt(map[string]any{
"openTaskId": "task-ready",
"openMessageId": "msg-ready",
"openConversationId": "cid-ready",
})
if receipt["readyForMessageActions"] != true {
t.Fatalf("receipt = %#v", receipt)
}
ref, _ := receipt["messageRef"].(map[string]any)
if ref["openMessageId"] != "msg-ready" || ref["openConversationId"] != "cid-ready" {
t.Fatalf("messageRef = %#v", ref)
}
actions, _ := receipt["nextActions"].([]map[string]any)
if len(actions) != 3 || actions[0]["ready"] != true {
t.Fatalf("nextActions = %#v", actions)
}
}
func TestCrossPlatformCoverageProjectMessageSendStatusReadyWorkflow(t *testing.T) {
raw := map[string]any{
"result": map[string]any{
"openTaskId": "task-1",
"openMessageId": "msg-1",
"openConversationId": "cid-1",
"status": "SUCCESS",
},
}
payload := ProjectMessageSendStatus(raw, "ignored")
if payload["contractVersion"] != MessageSendStatusContractVersion ||
payload["openTaskId"] != "task-1" || payload["readyForMessageActions"] != true {
t.Fatalf("payload = %#v", payload)
}
ref, _ := payload["messageRef"].(map[string]any)
if ref["openMessageId"] != "msg-1" || ref["openConversationId"] != "cid-1" {
t.Fatalf("messageRef = %#v", ref)
}
actions, _ := payload["nextActions"].([]map[string]any)
if len(actions) != 3 || actions[0]["cliPath"] != "chat message recall" || actions[2]["cliPath"] != "chat message read-status" {
t.Fatalf("nextActions = %#v", actions)
}
if _, ok := payload["result"]; !ok {
t.Fatal("raw response field was not preserved")
}
}
func TestCrossPlatformCoverageProjectMessageSendStatusPendingDoesNotInventMessageRef(t *testing.T) {
payload := ProjectMessageSendStatus(map[string]any{
"result": map[string]any{"status": "PENDING"},
}, "task-pending")
if payload["openTaskId"] != "task-pending" || payload["readyForMessageActions"] != false {
t.Fatalf("payload = %#v", payload)
}
if _, exists := payload["messageRef"]; exists {
t.Fatalf("pending payload invented messageRef: %#v", payload)
}
actions, _ := payload["nextActions"].([]map[string]any)
if len(actions) != 1 || actions[0]["ready"] != false {
t.Fatalf("nextActions = %#v", actions)
}
}
func TestCrossPlatformCoverageFirstSendStatusStringTraversesArrays(t *testing.T) {
value := []any{
nil,
map[string]any{"result": []any{
map[string]any{"openTaskId": " task-from-array "},
}},
}
if got := firstSendStatusString(value, "openTaskId"); got != "task-from-array" {
t.Fatalf("firstSendStatusString() = %q", got)
}
}
+189 -15
View File
@@ -5,6 +5,7 @@ package doc
import (
"encoding/json"
"errors"
"fmt"
"io"
"path/filepath"
@@ -16,7 +17,11 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const compositeInterfaceReason = "Reviewed Doc Shortcut composite: the executable CLI owns validation, multi-step orchestration, local I/O, output projection, and confirmation; no single MCP interface represents the complete command contract."
const (
compositeInterfaceReason = "Reviewed Doc Shortcut composite: the executable CLI owns validation, multi-step orchestration, local I/O, output projection, and confirmation; no single MCP interface represents the complete command contract."
docContentInputDescription = "内容字面量、@工作目录相对文件或 - 表示 stdin"
docRequiredContentInputDescription = docContentInputDescription + ";相关动作要求时不能为空"
)
func docContract(command, description, intent string, examples []string, params ...contract.ParamDecl) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
@@ -66,7 +71,7 @@ func readShortcutContent(rt *shortcut.RuntimeContext, flag string) (string, erro
}
path := strings.TrimSpace(strings.TrimPrefix(raw, "@"))
if path == "" || filepath.IsAbs(path) {
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 只接受工作目录内的相对路径", flag))
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 只接受工作目录内的相对路径;请先暂存到工作目录后传 @相对路径,或改用 --%s - 从 stdin 读取", flag, flag))
}
cwd, err := docGetwd()
if err != nil {
@@ -82,7 +87,7 @@ func readShortcutContent(rt *shortcut.RuntimeContext, flag string) (string, erro
}
rel, err := docRel(realBase, realPath)
if err != nil || rel == ".." || strings.HasPrefix(filepath.ToSlash(rel), "../") {
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 不能逃逸工作目录", flag))
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 不能逃逸工作目录;请先暂存到工作目录后传 @相对路径,或改用 --%s - 从 stdin 读取", flag, flag))
}
data, err := docReadFile(realPath)
if err != nil {
@@ -91,30 +96,198 @@ func readShortcutContent(rt *shortcut.RuntimeContext, flag string) (string, erro
return string(data), nil
}
func validateWorkspaceInputPath(flag, raw string) error {
path := strings.TrimSpace(raw)
if path == "" || filepath.IsAbs(path) {
return apperrors.NewValidation(fmt.Sprintf("--%s 只接受工作目录内已暂存文件的相对路径", flag))
}
cwd, err := docGetwd()
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("读取工作目录失败: %v", err))
}
realBase, err := docEvalSymlinks(cwd)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err))
}
realPath, err := docEvalSymlinks(filepath.Join(realBase, filepath.Clean(path)))
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("--%s: 读取文件 %q 失败: %v", flag, path, err))
}
rel, err := docRel(realBase, realPath)
if err != nil || rel == ".." || strings.HasPrefix(filepath.ToSlash(rel), "../") {
return apperrors.NewValidation(fmt.Sprintf("--%s 不能逃逸工作目录;请先把文件暂存到工作目录", flag))
}
return nil
}
func validateJSONML(raw string) (string, error) {
var value any
if err := json.Unmarshal([]byte(raw), &value); err != nil {
return "", apperrors.NewValidation(fmt.Sprintf("JSONML 解析失败: %v", err))
}
if _, ok := value.([]any); !ok {
root, ok := value.([]any)
if !ok {
return "", apperrors.NewValidation("JSONML 顶层必须是数组")
}
if len(root) == 0 {
return "", apperrors.NewValidation("JSONML 顶层数组不能为空")
}
if _, nestedRoot := root[0].([]any); nestedRoot {
return "", apperrors.NewValidation("JSONML 顶层必须是单个元素,不能使用元素数组包裹;请传 [\"root\", {...}, ...] 或单个 block 元素")
}
if tag, ok := root[0].(string); !ok || strings.TrimSpace(tag) == "" {
return "", apperrors.NewValidation("JSONML 第一个元素必须是非空标签名")
}
normalized, _ := json.Marshal(value) // decoded JSON trees are always marshalable
return string(normalized), nil
}
func docEnvelope(operation string, data any, steps ...map[string]any) map[string]any {
return map[string]any{
"ok": true,
"status": "success",
"operation": operation,
"steps": steps,
"data": data,
"warnings": []string{},
"compensation": map[string]any{"available": false, "reason": ""},
"contractVersion": "doc.operation.v1",
"ok": true,
"status": "success",
"complete": true,
"operation": operation,
"steps": steps,
"data": data,
"warnings": []string{},
"compensation": map[string]any{"available": false, "reason": ""},
}
}
type docFailureState string
const (
docFailureFailed docFailureState = "failed"
docFailureRetryable docFailureState = "retryable"
docFailureUnknown docFailureState = "unknown"
)
type docFailureTransition struct {
state docFailureState
reason string
retryable bool
executionStarted bool
message string
actions []string
}
func classifyDocWriteFailure(cause error) docFailureTransition {
transition := docFailureTransition{
state: docFailureUnknown,
reason: "doc_write_commit_unknown",
retryable: false,
executionStarted: true,
message: "文档写入结果未知;为避免重复创建或重复追加,已停止自动重试",
actions: []string{"先读取目标文档确认实际写入状态", "仅在确认服务端未提交后重新执行"},
}
var typed *apperrors.Error
if !errors.As(cause, &typed) {
return transition
}
reason := strings.ToLower(strings.TrimSpace(typed.Reason))
message := strings.ToLower(typed.Message)
if typed.Category == apperrors.CategoryValidation {
if reason == "" {
reason = "invalid_input"
}
return docFailureTransition{
state: docFailureFailed,
reason: reason,
retryable: false,
executionStarted: false,
message: typed.Message,
actions: []string{"根据错误中的参数约束修正输入后重新执行"},
}
}
permissionDenied := strings.Contains(reason, "403") || strings.Contains(reason, "permission") ||
strings.Contains(message, "permission") || strings.Contains(message, "forbidden") || strings.Contains(message, "权限")
if permissionDenied {
return docFailureTransition{
state: docFailureFailed,
reason: "permission_denied",
retryable: false,
executionStarted: false,
message: "当前身份没有执行该文档操作的权限;已终止,不会重试或改走同义命令",
actions: []string{"检查当前 profile 与文档权限", "获得权限后重新发起一次原操作"},
}
}
if typed.Category == apperrors.CategoryAuth {
return docFailureTransition{
state: docFailureFailed,
reason: "authentication_required",
retryable: false,
executionStarted: false,
message: "当前身份认证不可用;已终止文档写入",
actions: []string{"检查当前 profile 与登录状态", "认证恢复后重新发起一次原操作"},
}
}
if typed.ExecutionStarted != nil && !*typed.ExecutionStarted {
transition.state = docFailureFailed
transition.executionStarted = false
transition.retryable = typed.RetryableSet && typed.Retryable
if transition.retryable {
transition.state = docFailureRetryable
}
if reason != "" {
transition.reason = reason
} else {
transition.reason = "doc_write_not_started"
}
transition.message = "文档写入已确认尚未开始"
transition.actions = []string{"仅在 retryable=true 时按服务端 retry-after 有界重试一次"}
}
return transition
}
func docUnknownWriteError(operation, stage, nodeID string, cause error) error {
var existing *apperrors.Error
if errors.As(cause, &existing) {
if existing.Category == apperrors.CategoryValidation {
return cause
}
if status, ok := existing.Details["status"].(string); ok && (status == "unknown" || status == "partial_success") {
return cause
}
}
transition := classifyDocWriteFailure(cause)
details := map[string]any{
"contractVersion": "doc.operation.v1",
"status": string(transition.state),
"state": string(transition.state),
"nodeId": nodeID,
"stage": stage,
}
options := []apperrors.Option{
apperrors.WithOperation(operation),
apperrors.WithReason(transition.reason),
apperrors.WithFailureStage(stage),
apperrors.WithExecutionStarted(transition.executionStarted),
apperrors.WithRetryable(transition.retryable),
apperrors.WithActions(transition.actions...),
apperrors.WithDetails(details),
apperrors.WithCause(cause),
}
if transition.reason == "permission_denied" || transition.reason == "authentication_required" {
return apperrors.NewAuth(transition.message, options...)
}
return apperrors.NewAPI(transition.message, options...)
}
func docVerificationError(operation, stage, nodeID string, cause error, steps []map[string]any) error {
return docPartialWriteError(
operation,
"doc_write_verification_failed",
stage,
"文档写入已经执行,但回读验证失败;请先检查当前内容,不要直接重试写入",
cause,
map[string]any{"nodeId": nodeID, "verified": false},
steps,
map[string]any{"available": false, "reason": "inspect the current document before choosing recovery"},
)
}
func docPartialWriteError(operation, reason, stage, message string, cause error, data map[string]any, steps []map[string]any, compensation map[string]any) error {
return apperrors.NewAPI(
message,
@@ -125,10 +298,11 @@ func docPartialWriteError(operation, reason, stage, message string, cause error,
apperrors.WithRetryable(false),
apperrors.WithActions("inspect the completed steps before retrying", "use the compensation details to clean up or restore the document"),
apperrors.WithDetails(map[string]any{
"status": "partial_success",
"data": data,
"steps": steps,
"compensation": compensation,
"contractVersion": "doc.operation.v1",
"status": "partial_success",
"data": data,
"steps": steps,
"compensation": compensation,
}),
apperrors.WithCause(cause),
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,339 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package doc
import (
"context"
"errors"
"os"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageDocFinalCommonAndCanonicalBranches(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("staged.md", []byte("body"), 0o600); err != nil {
t.Fatal(err)
}
if err := validateWorkspaceInputPath("file", "staged.md"); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
stub func(*testing.T)
}{
{"getwd", func(t *testing.T) {
testseam.Swap(t, &docGetwd, func() (string, error) { return "", errors.New("getwd") })
}},
{"base symlink", func(t *testing.T) {
testseam.Swap(t, &docEvalSymlinks, func(string) (string, error) { return "", errors.New("base") })
}},
{"file symlink", func(t *testing.T) {
calls := 0
testseam.Swap(t, &docEvalSymlinks, func(path string) (string, error) {
calls++
if calls == 2 {
return "", errors.New("file")
}
return path, nil
})
}},
{"relative escape", func(t *testing.T) {
testseam.Swap(t, &docRel, func(string, string) (string, error) { return "../escape", nil })
}},
{"relative error", func(t *testing.T) {
testseam.Swap(t, &docRel, func(string, string) (string, error) { return "", errors.New("rel") })
}},
} {
t.Run(tc.name, func(t *testing.T) {
tc.stub(t)
if err := validateWorkspaceInputPath("file", "staged.md"); err == nil {
t.Fatal("expected validation failure")
}
})
}
if _, err := validateJSONML(`["",{}]`); err == nil {
t.Fatal("empty JSONML tag succeeded")
}
validation := classifyDocWriteFailure(apperrors.NewValidation("bad"))
if validation.reason != "invalid_input" || validation.executionStarted {
t.Fatalf("validation transition = %#v", validation)
}
permission := classifyDocWriteFailure(apperrors.NewAPI("forbidden"))
if permission.reason != "permission_denied" {
t.Fatalf("permission transition = %#v", permission)
}
auth := classifyDocWriteFailure(apperrors.NewAuth("login"))
if auth.reason != "authentication_required" {
t.Fatalf("auth transition = %#v", auth)
}
notStarted := classifyDocWriteFailure(apperrors.NewAPI("later", apperrors.WithExecutionStarted(false), apperrors.WithRetryable(true)))
if notStarted.state != docFailureRetryable || notStarted.reason != "doc_write_not_started" {
t.Fatalf("retryable transition = %#v", notStarted)
}
for _, cause := range []error{apperrors.NewAPI("permission denied"), apperrors.NewAuth("login")} {
var typed *apperrors.Error
if err := docUnknownWriteError("doc.test", "write", "n", cause); !errors.As(err, &typed) || typed.Category != apperrors.CategoryAuth {
t.Fatalf("auth-classified error = %#v", err)
}
}
for _, existing := range []error{
apperrors.NewValidation("existing"),
apperrors.NewAPI("existing", apperrors.WithDetails(map[string]any{"status": "partial_success"})),
} {
if got := docUnknownWriteError("doc.test", "write", "n", existing); got != existing {
t.Fatalf("existing classified error was wrapped: %p != %p", got, existing)
}
}
if !containsText(nil, "") || !containsText("alpha", "ph") || containsText("alpha", "z") ||
!containsText(map[string]any{"nested": "alpha"}, "alpha") || containsText(map[string]any{"nested": "beta"}, "alpha") ||
!containsText([]any{"alpha"}, "alpha") || containsText([]any{"beta"}, "alpha") || containsText(3, "alpha") {
t.Fatal("containsText branch contract failed")
}
jsonElement := []any{"p", map[string]any{"uuid": "b"}, "text"}
jsonPayload := map[string]any{"jsonml": `["p",{"uuid":"b"},"text"]`}
for _, value := range []any{jsonPayload, `["p",{"uuid":"b"},"text"]`, jsonElement} {
if canonicalBlockContent(value, "jsonml") == "" {
t.Fatalf("empty canonical JSONML for %#v", value)
}
}
for _, value := range []any{
map[string]any{"text": "alpha"},
map[string]any{"id": "ignored", "nested": map[string]any{"text": "beta"}},
[]any{"p", map[string]any{"text": "gamma"}},
[]any{map[string]any{"text": "delta"}},
"epsilon",
} {
if canonicalBlockContent(value, "markdown") == "" {
t.Fatalf("empty canonical markdown for %#v", value)
}
}
jsonTree := map[string]any{
"jsonml": `["root",{},["p",{"uuid":"ref"},"before"],["p",{"uuid":"new"},"after"]]`,
"content": "not-json",
"nested": []any{"p", map[string]any{"uuid": "direct"}, "direct"},
}
if len(orderedCanonicalBlocks(jsonTree, "jsonml")) < 3 || findJSONMLBlock(jsonTree, "new") == nil {
t.Fatalf("JSONML traversal failed: %#v", orderedJSONMLBlocks(jsonTree))
}
if findJSONMLBlock(jsonTree, "missing") != nil || jsonMLBlockIdentity([]any{"p"}) != "" || jsonMLBlockIdentity([]any{"p", "attrs"}) != "" {
t.Fatal("JSONML identity negative contract failed")
}
if canonicalBlockIdentity(jsonElement, "jsonml") != "b" || canonicalBlockIdentity("bad", "jsonml") != "" {
t.Fatal("JSONML canonical identity failed")
}
elementTree := map[string]any{"wrapper": []any{map[string]any{"id": "ref", "text": "before"}, map[string]any{"id": "new", "text": "after"}}}
if len(orderedCanonicalBlocks(elementTree, "element")) != 2 || canonicalBlockIdentity(elementTree, "element") != "" || canonicalBlockIdentity("bad", "element") != "" {
t.Fatal("element canonical traversal failed")
}
if !verifyInsertedCanonicalBlock(map[string]any{"blockId": "new"}, elementTree, "ref", "after", "element") ||
!verifyInsertedCanonicalBlock(map[string]any{}, elementTree, "ref", "after", "element") ||
verifyInsertedCanonicalBlock(map[string]any{}, elementTree, "new", "after", "element") {
t.Fatal("inserted block verification branch contract failed")
}
if blockContentEquals(map[string]any{}, "missing", "after", "element") {
t.Fatal("missing block matched")
}
if blockContentEquals(nil, "missing", "after", "element") {
t.Fatal("nil block tree matched")
}
if blockContentEquals(map[string]any{}, "missing", "after", "jsonml") {
t.Fatal("missing JSONML block matched")
}
if got := documentContentCandidates("root", "markdown"); len(got) != 1 || got[0] != "root" {
t.Fatalf("root content candidates = %#v", got)
}
if got := documentContentCandidates([]any{map[string]any{"content": "nested"}}, "markdown"); len(got) != 1 || got[0] != "nested" {
t.Fatalf("nested content candidates = %#v", got)
}
if got := splitDocMarkdown("a\nb", 0); len(got) != 1 {
t.Fatalf("disabled split = %#v", got)
}
if got := splitDocMarkdown("ab\ncd", 4); len(got) != 2 || got[0] != "ab\n" {
t.Fatalf("newline split = %#v", got)
}
}
func TestCrossPlatformCoverageDocFinalExecutionFailureBranches(t *testing.T) {
longContent := strings.Repeat("x", 10001)
if err := runDocCoverage(t, Create, &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}, "--name", "n", "--content", longContent); err == nil {
t.Fatal("partial chunk create succeeded")
}
for _, failAt := range []int{1, 2} {
err := runDocCoverage(t, Update, &docCoverageCaller{failAt: failAt, responses: map[string][]map[string]any{}},
"--node", "n", "--command", "block_insert_after", "--after-block-id", "block-1", "--content", "x", "--yes")
if err == nil {
t.Fatalf("block mutation failAt=%d succeeded", failAt)
}
}
mismatch := &docCoverageCaller{responses: map[string][]map[string]any{
"insert_document_block": {{"blockId": "new"}},
"list_document_blocks": {{"blocks": []any{map[string]any{"id": "new", "text": "wrong"}}}},
}}
if err := runDocCoverage(t, Update, mismatch, "--node", "n", "--command", "block_insert_after", "--after-block-id", "block-1", "--content", "x", "--yes"); err == nil {
t.Fatal("mismatched block verification succeeded")
}
for _, tc := range []struct {
name string
responses map[string][]map[string]any
failAt int
args []string
}{
{"query error", map[string][]map[string]any{}, 1, []string{"--job-id", "j"}},
{"unknown", map[string][]map[string]any{"query_export_job": {{}}}, 0, []string{"--job-id", "j"}},
{"failed", map[string][]map[string]any{"query_export_job": {{"status": "CANCELLED"}}}, 0, []string{"--job-id", "j"}},
{"missing url", map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS"}}}, 0, []string{"--job-id", "j", "--output", "out"}},
} {
t.Run(tc.name, func(t *testing.T) {
_ = runDocCoverage(t, ExportGet, &docCoverageCaller{failAt: tc.failAt, responses: tc.responses}, tc.args...)
})
}
t.Run("export getwd", func(t *testing.T) {
testseam.Swap(t, &docGetwd, func() (string, error) { return "", errors.New("getwd") })
_ = runDocCoverage(t, ExportGet, &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS", "downloadUrl": "https://example.com/x"}}}}, "--job-id", "j", "--output", "out")
})
t.Run("export download", func(t *testing.T) {
testseam.Swap(t, &docDownload, func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) {
return localio.DownloadResult{}, errors.New("download")
})
_ = runDocCoverage(t, ExportGet, &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS", "downloadUrl": "https://example.com/x"}}}}, "--job-id", "j", "--output", "out")
})
searchCaller := &docCoverageCaller{responses: map[string][]map[string]any{"search_documents": {{"documents": []any{}, "hasMore": false}}}}
if err := runDocCoverage(t, Search, searchCaller, "--query", "q", "--creator-uids", "u", "--editor-uids", "e", "--mentioned-uids", "m", "--workspace-ids", "w"); err != nil {
t.Fatal(err)
}
if got := searchCaller.history[0].params["pageSize"]; got != 10 {
t.Fatalf("default search page size = %#v", got)
}
listCaller := &docCoverageCaller{responses: map[string][]map[string]any{"list_nodes": {{"nodes": []any{}, "hasMore": false}}}}
if err := runDocCoverage(t, List, listCaller, "--folder", "f", "--workspace", "w"); err != nil {
t.Fatal(err)
}
if got := listCaller.history[0].params["pageSize"]; got != 50 {
t.Fatalf("default list page size = %#v", got)
}
_ = runDocCoverage(t, CommentReply, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--comment-key", "c", "--content", "x", "--emoji", "--mention", "[bad", "--yes")
if err := runDocCoverage(t, CommentReply, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--comment-key", "c", "--content", "x", "--mention", "u", "--yes"); err != nil {
t.Fatal(err)
}
_ = runDocCoverage(t, CommentUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--comment-key", "c", "--content", "x", "--mention", "[bad")
_ = runDocCoverage(t, ExportSubmit, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--export-format", "docx")
if err := runDocCoverage(t, ExportSubmit, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--export-format", "docx"); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
responses map[string][]map[string]any
failAt int
args []string
}{
{"template search error", map[string][]map[string]any{}, 1, []string{"--query", "q"}},
{"template not found", map[string][]map[string]any{"search_doc_templates": {{"templates": []any{}, "hasMore": false}}}, 0, []string{"--query", "q"}},
{"template resolved", map[string][]map[string]any{"search_doc_templates": {{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": false}}}, 0, []string{"--query", "q", "--source", "PUBLIC"}},
{"template continue", map[string][]map[string]any{"search_doc_templates": {{"templates": []any{map[string]any{"templateId": "t"}}, "nextCursor": "p2"}}}, 0, []string{"--query", "q", "--cursor", "p1"}},
} {
t.Run(tc.name, func(t *testing.T) {
_ = runDocCoverage(t, TemplateSearch, &docCoverageCaller{responses: tc.responses, failAt: tc.failAt}, tc.args...)
})
}
for _, raw := range [][]string{{"[bad"}, {"", " "}} {
if _, err := normalizeMentionUserIDs(raw); err == nil {
t.Fatalf("invalid mentions %#v succeeded", raw)
}
}
if _, ok := sliceUTF16Range("A😀B", 1, 2); ok {
t.Fatal("selection ending inside surrogate pair succeeded")
}
if _, ok := sliceUTF16Range("abc", -1, 1); ok {
t.Fatal("negative selection range succeeded")
}
commentCases := []struct {
name string
caller *docCoverageCaller
args []string
wantError bool
}{
{"block read", &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, []string{"--node", "n", "--content", "c", "--block-id", "b", "--start", "0", "--end", "1", "--yes"}, true},
{"range", &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"id": "b", "text": ""}}}}, []string{"--node", "n", "--content", "c", "--block-id", "b", "--start", "0", "--end", "1", "--yes"}, true},
{"selected mismatch", &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"id": "b", "text": "alpha"}}}}, []string{"--node", "n", "--content", "c", "--block-id", "b", "--start", "0", "--end", "1", "--selected-text", "z", "--yes"}, true},
{"write error", &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, []string{"--node", "n", "--content", "c", "--yes"}, true},
{"mention", &docCoverageCaller{responses: map[string][]map[string]any{}}, []string{"--node", "n", "--content", "c", "--mention", "[bad", "--yes"}, true},
}
for _, tc := range commentCases {
t.Run(tc.name, func(t *testing.T) {
err := runDocCoverage(t, CommentCreate, tc.caller, tc.args...)
if (err != nil) != tc.wantError {
t.Fatalf("err=%v wantError=%v", err, tc.wantError)
}
})
}
if !versionEvidenceMatches([]any{map[string]any{"target_version": 3}}, 3, map[string]bool{"targetversion": true}) {
t.Fatal("array version evidence did not match")
}
if !versionEvidenceMatches(map[string]any{"wrapper": map[string]any{"target_version": 3}}, 3, map[string]bool{"targetversion": true}) {
t.Fatal("nested version evidence did not match")
}
withDescription := collectTemplateCandidates(map[string]any{"templateId": "t", "description": "d"})
if len(withDescription) != 1 || withDescription[0]["description"] != "d" {
t.Fatalf("template description = %#v", withDescription)
}
duplicateTemplates := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {
{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": true, "nextCursor": "p2"},
{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": false},
}}}
_ = runDocCoverage(t, CreateFromTemplate, duplicateTemplates, "--query", "q")
stalledTemplates := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {
{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": true, "nextCursor": "p2"},
{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": true, "nextCursor": "p2"},
}}}
if err := runDocCoverage(t, CreateFromTemplate, stalledTemplates, "--query", "q"); err == nil {
t.Fatal("stalled template cursor succeeded")
}
maxPages := make([]map[string]any, 20)
for index := range maxPages {
maxPages[index] = map[string]any{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": true, "nextCursor": "p" + strings.Repeat("x", index+1)}
}
if err := runDocCoverage(t, CreateFromTemplate, &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": maxPages}}, "--query", "q"); err == nil {
t.Fatal("template max-pages succeeded")
}
t.Chdir(t.TempDir())
if err := os.WriteFile("media.bin", []byte("media"), 0o600); err != nil {
t.Fatal(err)
}
_ = runDocCoverage(t, ResourceUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--file", "media.bin", "--dry-run", "--yes")
if err := runDocCoverage(t, Import, &docCoverageCaller{dryRun: true, responses: map[string][]map[string]any{}}, "--file", "media.bin", "--folder", "f", "--dry-run"); err != nil {
t.Fatal(err)
}
t.Run("empty preview", func(t *testing.T) {
testseam.Swap(t, &docDownload, func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) {
return localio.DownloadResult{}, nil
})
_ = runDocCoverage(t, MediaPreview, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--resource-id", "r")
})
t.Run("empty download", func(t *testing.T) {
testseam.Swap(t, &docDownload, func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) {
return localio.DownloadResult{}, nil
})
_ = runDocCoverage(t, MediaDownload, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--resource-id", "r", "--output", "out")
})
}
+142 -108
View File
@@ -17,8 +17,11 @@
package doc
import (
"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/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
@@ -77,6 +80,9 @@ var Search = shortcut.Shortcut{
{Name: "workspace-ids", Type: shortcut.FlagStringSlice, Desc: "按知识库 ID 过滤"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 10,最大 30)"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上次结果的 nextPageToken)"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "有界读取全部后续页"},
{Name: "max-pages", Type: shortcut.FlagInt, Default: "20", Desc: "--page-all 最大页数"},
{Name: "max-items", Type: shortcut.FlagInt, Default: "500", Desc: "最多返回文档数"},
},
Tips: []string{`dws doc +search --query "会议纪要"`, `dws doc +search --extensions pdf,docx`},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -111,18 +117,17 @@ var Search = shortcut.Shortcut{
if rt.Changed("workspace-ids") {
params["workspaceIds"] = rt.StrSlice("workspace-ids")
}
if rt.Changed("limit") {
params["pageSize"] = rt.Int("limit")
pageSize := rt.Int("limit")
if pageSize == 0 {
pageSize = 10
}
if rt.Changed("cursor") {
params["pageToken"] = rt.Str("cursor")
}
data, err := rt.CallMCPData(productDoc, "search_documents", params)
result, err := collectDocPages(rt, "search_documents", "documents", params, searchDocsProject, docPageOptions{
PageAll: rt.Bool("page-all"), PageSize: pageSize, MaxPages: rt.Int("max-pages"), MaxItems: rt.Int("max-items"), Cursor: rt.Str("cursor"),
})
if err != nil {
return err
}
docs := searchDocsProject(data)
return rt.Output(map[string]any{"count": len(docs), "documents": docs})
return rt.Output(result)
},
}
@@ -241,6 +246,9 @@ var List = shortcut.Shortcut{
{Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上次结果的 nextPageToken)"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "有界读取全部后续页"},
{Name: "max-pages", Type: shortcut.FlagInt, Default: "20", Desc: "--page-all 最大页数"},
{Name: "max-items", Type: shortcut.FlagInt, Default: "500", Desc: "最多返回节点数"},
},
Tips: []string{`dws doc +list --folder DOC_FOLDER_NODE_ID`, `dws doc +list --workspace WS_ID --limit 20`},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -251,18 +259,17 @@ var List = shortcut.Shortcut{
if rt.Changed("workspace") {
params["workspaceId"] = rt.Str("workspace")
}
if rt.Changed("limit") {
params["pageSize"] = rt.Int("limit")
pageSize := rt.Int("limit")
if pageSize == 0 {
pageSize = 50
}
if rt.Changed("cursor") {
params["pageToken"] = rt.Str("cursor")
}
data, err := rt.CallMCPData(productDoc, "list_nodes", params)
result, err := collectDocPages(rt, "list_nodes", "nodes", params, listNodesProject, docPageOptions{
PageAll: rt.Bool("page-all"), PageSize: pageSize, MaxPages: rt.Int("max-pages"), MaxItems: rt.Int("max-items"), Cursor: rt.Str("cursor"),
})
if err != nil {
return err
}
nodes := listNodesProject(data)
return rt.Output(map[string]any{"count": len(nodes), "nodes": nodes})
return rt.Output(result)
},
}
@@ -414,7 +421,7 @@ var CommentList = shortcut.Shortcut{
Command: "+comment-list",
Product: productComment,
Description: "查询文档评论列表",
Intent: "当你想查看某篇文档上已有的评论、了解有哪些反馈或待处理意见(可按全文/划词、已解决/未解决过滤)时使用;输入 node,返回评论列表及其 commentKey 以便后续回复。",
Intent: "当你想查看某篇文档上已有的评论、了解有哪些反馈或待处理意见(可按全文/划词、已解决/未解决过滤)时使用;输入 node,可用 --limit/--cursor 分页,返回评论列表及其 commentKey 以便后续回复。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -436,26 +443,26 @@ var CommentList = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "查询文档评论列表",
UseWhen: []string{"当你想查看某篇文档上已有的评论、了解有哪些反馈或待处理意见(可按全文/划词、已解决/未解决过滤)时使用;输入 node,返回评论列表及其 commentKey 以便后续回复。"},
UseWhen: []string{"当你想查看某篇文档上已有的评论、了解有哪些反馈或待处理意见(可按全文/划词、已解决/未解决过滤)时使用;输入 node,可用 --limit/--cursor 分页,返回评论列表及其 commentKey 以便后续回复。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{
"dws doc +comment-list --node DOC_ID",
"dws doc +comment-list --node DOC_ID --type inline --resolve-status unresolved",
"dws doc +comment-list --node DOC_ID --limit 20",
"dws doc +comment-list --node DOC_ID --limit 20 --cursor NEXT_TOKEN",
},
},
},
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)", Aliases: []string{"page-size"}},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上一页返回的 nextToken)"},
{Name: "type", Type: shortcut.FlagString, Desc: "评论类型: global (全文) / inline (划词)", Enum: []string{"global", "inline"}},
{Name: "resolve-status", Type: shortcut.FlagString, Desc: "解决状态: resolved / unresolved", Enum: []string{"resolved", "unresolved"}},
},
Tips: []string{`dws doc +comment-list --node DOC_ID`, `dws doc +comment-list --node DOC_ID --type inline --resolve-status unresolved`},
Tips: []string{`dws doc +comment-list --node DOC_ID --limit 20`, `dws doc +comment-list --node DOC_ID --limit 20 --cursor NEXT_TOKEN`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node")}
if rt.Changed("limit") {
params["pageSize"] = rt.Int("limit")
if rt.Changed("limit") || rt.Changed("page-size") {
params["pageSize"] = rt.IntFirst("limit", "page-size")
}
if v := rt.Str("cursor"); v != "" {
params["nextToken"] = v
@@ -470,54 +477,44 @@ var CommentList = shortcut.Shortcut{
},
}
const commentCreateTargetConstraint = "selection 与 block-id/start/end 高级通道互斥;block-id/start/end 必须一起提供;selection 必须唯一匹配"
var CommentCreate = shortcut.Shortcut{
Service: "doc",
Command: "+comment-create",
Product: productComment,
Description: "在文档上创建一条评论",
Intent: "当你想对整篇文档留一条全文评论、给出反馈或 @ 相关同事时使用;输入 node 与评论 content(可带 mention),会实际在文档上发布一条新评论。",
Description: "创建全文评论,或按 selection 创建划词评论",
Intent: "当用户要对整篇文档留言,或针对文档中唯一匹配的一段文字创建精确划词评论时使用;已知 block/start/end 时也可直接走高级通道。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "doc",
Name: "shortcut_comment_create",
CanonicalPath: "doc.shortcut_comment_create",
CLIPath: "doc +comment-create",
PrimaryCLIPath: "doc +comment-create",
},
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{"当你想对整篇文档留一条全文评论、给出反馈或 @ 相关同事时使用;输入 node 与评论 content(可带 mention),会实际在文档上发布一条新评论。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws doc +comment-create --node DOC_ID --content \"这里需要修改\""},
},
},
Contract: docContract("+comment-create", "创建全文评论,或按 selection 创建划词评论",
"当用户要对整篇文档留言,或针对文档中唯一匹配的一段文字创建精确划词评论时使用;已知 block/start/end 时也可直接走高级通道。",
[]string{`dws doc +comment-create --node <DOC_ID> --content "请补充数据来源"`, `dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"`},
contract.ParamDecl{Name: "node", Property: "node"},
contract.ParamDecl{Name: "content", Property: "content"},
contract.ParamDecl{Name: "selection", Property: "selection"},
contract.ParamDecl{Name: "block-id", Property: "blockId"},
contract.ParamDecl{Name: "start", Property: "start"},
contract.ParamDecl{Name: "end", Property: "end"},
contract.ParamDecl{Name: "selected-text", Property: "selectedText"},
contract.ParamDecl{Name: "mention", Property: "mention"}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容 (纯文本)", Required: true},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
},
Tips: []string{`dws doc +comment-create --node DOC_ID --content "这里需要修改"`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"nodeId": rt.Str("node"),
"content": rt.Str("content"),
}
if rt.Changed("mention") {
params["mentionedUserIds"] = rt.StrSlice("mention")
}
return rt.CallMCP("create_comment", params)
{Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容", Required: true},
{Name: "selection", Type: shortcut.FlagString, Desc: "完整文字或 前缀...后缀;" + commentCreateTargetConstraint},
{Name: "block-id", Type: shortcut.FlagString, Desc: "高级通道 block ID;" + commentCreateTargetConstraint},
{Name: "start", Type: shortcut.FlagInt, Desc: "块内 UTF-16 起始偏移;" + commentCreateTargetConstraint},
{Name: "end", Type: shortcut.FlagInt, Desc: "块内 UTF-16 结束偏移;" + commentCreateTargetConstraint},
{Name: "selected-text", Type: shortcut.FlagString, Desc: "可选引用原文;CLI 会从 block 回读并交叉校验"},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid,多个值用逗号分隔;不要传 JSON 数组"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"selection", "block-id", "start", "end"}, Description: commentCreateTargetConstraint}},
Tips: []string{`dws doc +comment-create --node <DOC_ID> --content "请补充数据来源"`, `dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"`},
Validate: validateCommentCreate,
Execute: executeCommentCreate,
}
var CommentReply = shortcut.Shortcut{
@@ -557,7 +554,7 @@ var CommentReply = shortcut.Shortcut{
{Name: "content", Type: shortcut.FlagString, Desc: "回复文字内容 (表情回复时填表情名称)", Required: true},
{Name: "comment-key", Type: shortcut.FlagString, Desc: "被回复评论的 commentKey (从 list/create 获取)", Required: true},
{Name: "emoji", Type: shortcut.FlagBool, Desc: "作为表情贴图回复"},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid,多个值用逗号分隔;不要传 JSON 数组"},
},
Tips: []string{`dws doc +comment-reply --node DOC_ID --comment-key COMMENT_KEY --content "同意"`},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -570,7 +567,11 @@ var CommentReply = shortcut.Shortcut{
params["emoji"] = true
}
if rt.Changed("mention") {
params["mentionedUserIds"] = rt.StrSlice("mention")
mentions, err := normalizeMentionUserIDs(rt.StrSlice("mention"))
if err != nil {
return err
}
params["mentionedUserIds"] = mentions
}
return rt.CallMCP("reply_comment", params)
},
@@ -580,9 +581,10 @@ var CommentCreateInline = shortcut.Shortcut{
Service: "doc",
Command: "+comment-create-inline",
Product: productComment,
Description: "在文档选中文本区域上创建划词评论",
Intent: "当你想针对文档里某段具体文字(而非整篇)留评论、做精确批注时使用;需先用 +block-list 定位块,再输入 node、block-id 及该块内的 start/end 字符偏移量,会实际在选中文本上创建划词评论。",
Description: "兼容入口:按 block/start/end 创建划词评论",
Intent: "仅兼容既有调用;新任务统一使用 +comment-create 的 selection 或 block/start/end 通道。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容 (纯文本)", Required: true},
@@ -590,25 +592,12 @@ var CommentCreateInline = shortcut.Shortcut{
{Name: "start", Type: shortcut.FlagInt, Desc: "块内文本起始字符偏移量 (从 0 开始)", Required: true},
{Name: "end", Type: shortcut.FlagInt, Desc: "块内文本结束字符偏移量 (须大于 start)", Required: true},
{Name: "selected-text", Type: shortcut.FlagString, Desc: "选中文本内容 (展示引用原文)"},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
},
Tips: []string{`dws doc +comment-create-inline --node DOC_ID --block-id BLOCK_ID --start 0 --end 10 --content "这里需要修改"`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"nodeId": rt.Str("node"),
"content": rt.Str("content"),
"blockId": rt.Str("block-id"),
"start": rt.Int("start"),
"end": rt.Int("end"),
}
if v := rt.Str("selected-text"); v != "" {
params["selectedText"] = v
}
if rt.Changed("mention") {
params["mentionedUserIds"] = rt.StrSlice("mention")
}
return rt.CallMCP("create_inline_comment", params)
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid,多个值用逗号分隔;不要传 JSON 数组"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"block-id", "start", "end"}, Description: "block-id/start/end 必须一起提供,CLI 回读并校验 selectedText"}},
Tips: []string{`dws doc +comment-create --node DOC_ID --block-id BLOCK_ID --start 0 --end 10 --content "这里需要修改"`},
Validate: validateCommentCreate,
Execute: executeCommentCreate,
}
// ── 协作权限 ─────────────────────────────────────────────────
@@ -620,7 +609,7 @@ var ExportSubmit = shortcut.Shortcut{
Command: "+export-submit",
Product: productDoc,
Description: "提交在线文档导出任务 (docx/markdown/pdf),返回 jobId",
Intent: "当你想把在线文档导出成 docx/markdown/pdf 文件(例如离线保存或外发)时使用;这是异步任务的第一步,输入 node 与 export-format 提交导出,返回 jobId,随后用 +export-get 轮询结果。",
Intent: "仅当用户明确要求手工接管异步导出 job,且不需要当前命令下载文件时使用;返回 jobId 后只能用 +export-get 恢复。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -642,25 +631,25 @@ var ExportSubmit = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "提交在线文档导出任务 (docx/markdown/pdf),返回 jobId",
UseWhen: []string{"当你想把在线文档导出成 docx/markdown/pdf 文件(例如离线保存或外发)时使用;这是异步任务的第一步,输入 node 与 export-format 提交导出,返回 jobId,随后用 +export-get 轮询结果。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
UseWhen: []string{"仅当用户明确要求手工接管异步导出 job,且不需要当前命令下载文件时使用;返回 jobId 后只能用 +export-get 恢复。"},
AvoidWhen: []string{"正常导出和保存本地文件使用 doc +export;不要手工编排 submit/get,也不要用它绕过 +export 的安全下载"},
Examples: []string{"dws doc +export-submit --node DOC_ID --export-format markdown"},
},
},
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "要导出的文档 ID 或 URL", Required: true},
{Name: "export-format", Type: shortcut.FlagString, Default: "docx", Desc: "导出格式", Enum: []string{"docx", "markdown", "pdf"}},
{Name: "export-format", Type: shortcut.FlagString, Default: "docx", Desc: "导出格式;省略时默认为 docx", Enum: []string{"docx", "markdown", "pdf"}},
},
Tips: []string{`dws doc +export-submit --node DOC_ID --export-format markdown`},
Execute: func(rt *shortcut.RuntimeContext) error {
format := rt.Str("export-format")
if format == "" {
format = "docx"
}
return rt.CallMCP("submit_export_job", map[string]any{
result, err := rt.CallMCPWriteData(productDoc, "submit_export_job", map[string]any{
"nodeId": rt.Str("node"),
"exportFormat": format,
"exportFormat": rt.Str("export-format"),
})
if err != nil {
return docUnknownWriteError("doc.export_submit", "submit_export_job", rt.Str("node"), err)
}
return rt.Output(docEnvelope("doc.export_submit", map[string]any{"nodeId": rt.Str("node"), "result": result}, map[string]any{"name": "submit_export_job", "status": "success"}))
},
}
@@ -669,7 +658,7 @@ var ExportGet = shortcut.Shortcut{
Command: "+export-get",
Product: productDoc,
Description: "根据 jobId 查询文档导出任务结果",
Intent: "当你已用 +export-submit 提交了导出任务、想查询它是否完成并拿到导出文件的下载链接时使用;输入上一步返回的 job-id,返回任务状态与结果。",
Intent: "当 +export 已返回 jobId 但轮询、中断或下载失败时使用;复用同一 job 查询,给 output 时通过 CLI 安全下载,禁止重新提交或 curl 临时链接。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -691,18 +680,24 @@ var ExportGet = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "根据 jobId 查询文档导出任务结果",
UseWhen: []string{"当你已用 +export-submit 提交了导出任务、想查询它是否完成并拿到导出文件的下载链接时使用;输入上一步返回的 job-id,返回任务状态与结果。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws doc +export-get --job-id JOB_ID"},
UseWhen: []string{"当 +export 已返回 jobId 但轮询、中断或下载失败时使用;复用同一 job 查询,给 output 时通过 CLI 安全下载,禁止重新提交或 curl 临时链接。"},
AvoidWhen: []string{"尚未提交导出或没有真实 jobId 时不要使用;正常首次导出使用 doc +export"},
Examples: []string{"dws doc +export-get --job-id JOB_ID", "dws doc +export-get --job-id JOB_ID --output ./exports/"},
},
},
Flags: []shortcut.Flag{
{Name: "job-id", Type: shortcut.FlagString, Desc: "导出任务 ID", Required: true},
{Name: "output", Shorthand: "o", Type: shortcut.FlagString, Desc: "可选:任务完成后安全下载到工作目录内相对路径"},
},
Tips: []string{`dws doc +export-get --job-id JOB_ID`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("query_export_job", map[string]any{"jobId": rt.Str("job-id")})
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Str("output") == "" {
return nil
}
return localio.ValidateOutput(rt.Str("output"))
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"output"}, Description: "提供 --output 时必须是工作目录内相对路径;默认 no-clobber"}},
Tips: []string{`dws doc +export-get --job-id JOB_ID`, `dws doc +export-get --job-id JOB_ID --output ./exports/`},
Execute: executeExportGet,
}
// ── 历史版本 (server: doc) ───────────────────────────────────
@@ -938,17 +933,57 @@ var TemplateSearch = shortcut.Shortcut{
},
Tips: []string{`dws doc +template-search --query "周报"`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"searchName": rt.Str("query")}
pageSize := rt.Int("limit")
if pageSize <= 0 {
pageSize = 50
}
params := map[string]any{"searchName": rt.Str("query"), "maxResults": pageSize}
if v := rt.Str("source"); v != "" {
params["templateSource"] = v
}
if rt.Changed("limit") {
params["maxResults"] = rt.Int("limit")
}
if v := rt.Str("cursor"); v != "" {
params["nextCursor"] = v
}
return rt.CallMCP("search_doc_templates", params)
found, err := rt.CallMCPData(productDoc, "search_doc_templates", params)
if err != nil {
return err
}
candidates := collectTemplateCandidates(found)
hasMore, hasMoreKnown, nextCursor := docPageState(found)
nextCursor = strings.TrimSpace(nextCursor)
complete := hasMoreKnown && !hasMore
if !hasMoreKnown && nextCursor == "" && len(candidates) < pageSize {
complete = true
}
globalComplete := complete && rt.Str("cursor") == ""
status := "selection_required"
if globalComplete && len(candidates) == 0 {
status = "not_found"
} else if globalComplete && len(candidates) == 1 {
status = "resolved"
}
selectedTemplateID := ""
if status == "resolved" {
selectedTemplateID = candidates[0]["templateId"].(string)
}
nextAction := map[string]string{"resolved": "create_once", "not_found": "stop", "selection_required": "ask_user"}[status]
if !complete {
nextAction = "continue_search"
}
return rt.Output(docEnvelope("doc.template_search", map[string]any{
"query": rt.Str("query"),
"source": rt.Str("source"),
"count": len(candidates),
"candidates": candidates,
"complete": complete,
"hasMore": !complete,
"nextCursor": nextCursor,
"selection": map[string]any{
"status": status,
"templateId": selectedTemplateID,
"nextAction": nextAction,
},
}))
},
}
@@ -986,7 +1021,6 @@ func init() {
CommentCreateInline.Contract = corecmd.ContractDecl{}
TemplateApply.Contract = corecmd.ContractDecl{}
canonicalizeHistoryShortcuts()
canonicalizeCommentShortcuts()
shortcut.Register(
Search,
List,
@@ -998,9 +1032,9 @@ func init() {
CommentCreateInline,
ExportSubmit,
ExportGet,
legacyVersionSave,
legacyVersionList,
legacyVersionRevert,
compatHistorySave,
compatHistoryList,
compatHistoryRevert,
VersionSave,
VersionList,
VersionRevert,

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