Compare commits

...
Author SHA1 Message Date
Dennis4477 4bb8c8b586 Merge branch 'main' into codex/ai-table-shortcut 2026-08-07 15:55:46 +08:00
Dennis 48e522ec00 fix(aitable): harden pagination and upload inputs 2026-08-07 15:50:35 +08:00
github-actions[bot] 0e0007d7b7 Merge pull request #903 from DingTalk-Real-AI/codex/update-reviewer-pool
chore: 更新 Reviewer Router 评审人池
2026-08-07 15:36:41 +08:00
Dennis 176b217139 fix(aitable): require bootstrap confirmation 2026-08-07 15:32:01 +08:00
chichuan 5c8fd0a48c fix: preserve reviewer routing fallback 2026-08-07 15:20:41 +08:00
Dennis 7490bb95c5 fix(aitable): scope strict write response checks 2026-08-07 15:09:58 +08:00
chichuan b186e59a01 feat: route reviewers by module ownership 2026-08-07 14:42:56 +08:00
Dennis a92f54df3d fix(paging): restore zero-value safety limit 2026-08-07 14:42:27 +08:00
Dennis 307c9e797b fix(aitable): reject empty bulk patch selectors 2026-08-07 14:08:59 +08:00
Dennis 07ce090eeb test(aitable): close shortcut coverage gaps 2026-08-07 12:01:36 +08:00
Dennis 201949aec1 feat(aitable): add verified shortcut workflows 2026-08-07 12:01:33 +08:00
chichuan 8eeabd1419 chore: update reviewer pool 2026-08-07 10:45:59 +08:00
github-actions[bot] 6035d43899 Merge pull request #877 from xlb1130/feat/chat-toolbar-commands
feat(chat): add toolbar commands for conversation shortcut bar
2026-08-07 10:45:32 +08:00
xlb1130 ba375b40fa Merge branch 'main' into feat/chat-toolbar-commands 2026-08-06 22:25:56 +08:00
长真 0a063662a0 test(chat): use testseam for toolbar deps 2026-08-06 22:24:27 +08:00
github-actions[bot] 4148a90bf5 Merge pull request #889 from DingTalk-Real-AI/codex/ci-pr-release-compatibility
ci: run release compatibility in PR admission
2026-08-06 13:53:32 +00:00
chichuan 262248d08d Merge branch 'main' into codex/ci-pr-release-compatibility 2026-08-06 21:38:02 +08:00
github-actions[bot] e7955b5891 Merge pull request #864 from DingTalk-Real-AI/fix/param-hallucination
fix(cli): harden command and parameter hallucination recovery
2026-08-06 21:30:44 +08:00
chichuan efb172dcd6 Merge branch 'main' into codex/ci-pr-release-compatibility 2026-08-06 21:18:39 +08:00
长真 6572010922 fix(chat): preserve chmod yes shorthand 2026-08-06 20:27:18 +08:00
克谨 0cbb1b8d30 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-06 20:16:11 +08:00
xlb1130 6738d0f29e Merge branch 'main' into feat/chat-toolbar-commands 2026-08-06 20:11:24 +08:00
github-actions[bot] db6addfc0e Merge pull request #873 from maoqxxmm/codex/csv-put-formula-pr
feat(sheet): expose csv-put formula semantics
2026-08-06 12:05:46 +00:00
xlb1130 384b067ead Merge branch 'main' into feat/chat-toolbar-commands 2026-08-06 20:00:42 +08:00
长真 c32d10bd43 fix(chat): gate chmod confirmation 2026-08-06 19:51:41 +08:00
chichuan 17f153a070 Merge branch 'main' into codex/csv-put-formula-pr 2026-08-06 19:50:54 +08:00
克谨 959bc4c1a8 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-06 19:46:24 +08:00
github-actions[bot] 9f80006b3b chore: update formula for v1.0.57 [skip ci] 2026-08-06 11:25:13 +00:00
克谨 e530aea14d Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-06 19:23:34 +08:00
chichuan 32515729af Merge pull request #898 from DingTalk-Real-AI/codex/recover-v1.0.57-formula-parent
chore: prepare safe v1.0.57 Formula recovery
2026-08-06 19:08:28 +08:00
xlb1130 2a7ab22e85 Merge branch 'main' into feat/chat-toolbar-commands 2026-08-06 18:52:26 +08:00
长真 757f45df31 fix(chat): align toolbar mcp contracts 2026-08-06 18:20:18 +08:00
chichuan ef71673c69 chore: prepare v1.0.57 Formula recovery 2026-08-06 18:13:16 +08:00
克谨 af0086c9a8 test: cover command fallback platform gates 2026-08-06 17:53:57 +08:00
克谨 28f75dec0a Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-06 17:42:40 +08:00
github-actions[bot] 15d5be6000 chore: update formula for v1.0.57 [skip ci] 2026-08-06 09:38:41 +00:00
github-actions[bot] ebfba82ebc Merge pull request #867 from Anonymity-0/feat/robot-message-image-file
feat(chat): support robot image and file messages
2026-08-06 17:35:32 +08:00
克谨 ec9897678f Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-06 17:20:38 +08:00
克谨 af6e566abd fix(cli): preserve native doc export task alias 2026-08-06 17:19:21 +08:00
前津 4ea298ec61 Merge remote-tracking branch 'upstream/main' into feat/robot-message-image-file 2026-08-06 17:12:30 +08:00
前津 7aba24b690 fix(chat): preserve schema compatibility 2026-08-06 17:11:19 +08:00
克谨 60e278f32f Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-06 17:11:17 +08:00
克谨 7acbec2615 fix(cli): extend doc hallucination recovery 2026-08-06 17:11:09 +08:00
chichuan 6811a12330 Merge pull request #896 from DingTalk-Real-AI/codex/accept-successful-release-rerun
fix(release): accept successful sealed run reruns
2026-08-06 17:08:02 +08:00
前津 c54a9e1e5e Merge remote-tracking branch 'upstream/main' into feat/robot-message-image-file 2026-08-06 16:56:58 +08:00
前津 a15fb22671 Merge remote-tracking branch 'origin/feat/robot-message-image-file' into feat/robot-message-image-file 2026-08-06 16:48:39 +08:00
chichuan a1712337a8 fix(release): accept successful sealed run reruns 2026-08-06 16:48:29 +08:00
前津 d65ad9aa2e fix(chat): preserve robot markdown requirements 2026-08-06 16:48:27 +08:00
长真 5d25a10223 fix(chat): cover toolbar command mappings 2026-08-06 16:46:39 +08:00
前津 7688f95d2b Merge remote-tracking branch 'upstream/main' into feat/robot-message-image-file 2026-08-06 16:37:08 +08:00
github-actions[bot] c2ee691db7 chore: update beta formula for v1.0.57-beta.4 [skip ci] 2026-08-06 08:36:13 +00:00
xiatian 368c879f45 Merge remote-tracking branch 'upstream/main' into codex/csv-put-formula-pr
# Conflicts:
#	CHANGELOG.md
2026-08-06 16:24:03 +08:00
john 1920552ab0 Merge pull request #895 from DingTalk-Real-AI/codex/changelog-v1.0.57-beta.4
docs: seal v1.0.57-beta.4 changelog
2026-08-06 16:21:44 +08:00
chichuan f1b5330a4e docs: seal v1.0.57-beta.4 changelog 2026-08-06 16:18:22 +08:00
长真 0d5c6d92e3 feat(chat): add toolbar command contracts to #85129657 2026-08-06 16:07:50 +08:00
github-actions[bot] f9ccff963f Merge pull request #887 from DingTalk-Real-AI/feat/multi-skill-framework-align
feat(skills+schema): multi-skill fold, ding ParamDecl, retire discovery cache
2026-08-06 16:06:54 +08:00
玉澜andCursor 5cbc11d58f Merge branch 'main' into feat/multi-skill-framework-align
Resolve CHANGELOG conflict: keep Unreleased recovery deprecation and
main's v1.0.57 stable release section.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 15:48:39 +08:00
xiatian 49a17b4375 Merge remote-tracking branch 'upstream/main' into codex/csv-put-formula-pr
# Conflicts:
#	CHANGELOG.md
2026-08-06 15:46:53 +08:00
玉澜andCursor a0a995cfee ci: retrigger full CI after missed pull_request run
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 15:45:28 +08:00
玉澜andCursor 354c4546d8 docs(mail): align share-to-chat help with --yes hard gate
Update Long and --yes flag text to match the upfront confirmation gate
and automatic sign retry after --yes, consistent with thread trash.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 15:39:25 +08:00
玉澜andCursor 3ab22071fb fix(mail): hard-gate share-to-chat with --yes before MCP
Add explicit confirmation_required gate before any share_message_to_chat
call so piped stdin or direct server success cannot bypass --yes. Keep
sign retry after confirmation and add regression tests for zero-call deny,
sign retry, and direct-success paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 15:37:09 +08:00
玉澜andCursor 85cb41423b revert(schema-compat): drop residual property-remap assertion from ding backfill
Keep schema-compat tests aligned with main after removing the ding
property-correction allowlist; the hard-fail case is already covered elsewhere.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 15:26:45 +08:00
xiatian b9e7ff8c55 Merge remote-tracking branch 'upstream/main' into codex/csv-put-formula-pr
# Conflicts:
#	CHANGELOG.md
2026-08-06 15:12:29 +08:00
玉澜andCursor 02ccd9d134 Merge branch 'main' into feat/multi-skill-framework-align
Resolve PR #887 base drift so CI merge-revision check matches event.base.sha.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 15:10:10 +08:00
玉澜 0353215b1d Revert "declare(ding): semantic ParamDecl backfill for schema inference residuals"
This reverts commit 969292a8d7.
2026-08-06 14:52:34 +08:00
克谨 b94e21331d chore(param): refresh aliases after doc shortcuts 2026-08-06 14:46:11 +08:00
xiatian 637a6f2f68 Merge remote-tracking branch 'upstream/main' into codex/csv-put-formula-pr 2026-08-06 14:45:05 +08:00
chichuan ae9ba333a0 Merge branch 'main' into codex/ci-pr-release-compatibility 2026-08-06 14:44:32 +08:00
克谨 6dbc8399df Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-06 14:42:33 +08:00
xiatian 24cdb85d71 docs(changelog): record formula verify fix 2026-08-06 14:22:42 +08:00
玉澜andCursor a1f8ecb7f0 test(coverage): cover empty allowlist path in filterBlocksByFiles
Hit the nil-allowlist early return so the platform changed-statement gate
reaches 100% after shared-file overall baseline filtering.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 13:33:36 +08:00
玉澜andCursor a34ca5a137 test(ci): allow 0.1pp overall coverage tolerance in workflow contract
Align the Code Admission workflow contract with the shared-file overall
non-regression tolerance needed after deleting fully covered packages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 13:18:48 +08:00
玉澜andCursor 7887b9473f chore(cli): extend interface baseline for visible cache shim
Merge of the Deprecated cache refresh surface reintroduced a public root
command; refresh the CLI interface baseline so cli-smoke stays green.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 13:12:10 +08:00
玉澜andCursor fd3c82aa91 fix(coverage): compare overall on shared files and cover compact edges
Deleting 100%-covered packages such as recovery falsely regressed overall
percent against merge-base. Baseline overall now uses only files still
present in the candidate profile, with a 0.1pp CI tolerance. Also exercise
stripSchemaValueCompact nested map/slice branches.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 13:06:52 +08:00
玉澜andCursor 9266632694 Merge branch 'fix/skill-doc-quickwins' (#869)
Absorb skill-doc quickwins intent while keeping the multi-skill fold:
standalone hrbrain/markdown/pat/profile/skill packages stay in misc,
and markdown routing remains misc-targeted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 13:04:58 +08:00
玉澜 b0cbcd7a04 Merge branch 'fix/cli-missing-surfaces-from-eval' (#868)
Bring missing CLI surfaces from wukong cli_to_mcp eval into multi-skill align.
2026-08-06 13:02:48 +08:00
玉澜andCursor 6251117bd0 fix(cli): keep visible Deprecated cache refresh shim
Restore dws cache {refresh,status,clean} as a successful no-op
compat surface so historical scripts/agents keep working after
static-endpoint delivery. Deprecated leaves stay out of Schema via
IsAvailableCommand without schema exclusions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 13:02:39 +08:00
玉澜andCursor 566803c431 fix(skills): drop invalid dws command prose in misc refs
Rewrite incomplete `dws devapp +` and non-product `dws finance` mentions
so skill-command-integrity no longer treats them as executable paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:40:17 +08:00
玉澜andCursor d6848da60b chore(cli): extend interface baseline for recovery root
Visible Deprecated recovery stubs are part of the public root command
tree; refresh the CLI interface baseline so cli-smoke stays green.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:33:47 +08:00
玉澜andCursor 53169a41af fix(policy): split schema projection --jq=/--fields= cases
Cover each equals-form flag in its own switch case so the platform
changed-statement coverage gate reliably hits both branches.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:26:47 +08:00
玉澜andCursor e90d5bac68 test: close platform coverage gaps for recovery and schema edges
Hit Encode/Fprint failure paths, compact projection branches, authActions,
reviewed property corrections, and schemaProjectionIssue --jq=/--fields=
so the changed-statement coverage gate reaches 100%.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:22:04 +08:00
玉澜andCursor d725bdbaa3 fix(schema): drop stale recovery exclusions
Deprecated recovery leaves are not public schema leaves
(IsAvailableCommand=false), so listing them as exclusions fails
completeness with stale exclusions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:00:33 +08:00
chichuan d75b744a35 ci: classify core command changes as interface-sensitive 2026-08-06 11:58:49 +08:00
玉澜 9aea8c0b5c Merge chore/retire-mcp-schema-candidates (#882).
Bring MCP pin candidate retirement into the multi-skill align PR.
2026-08-06 11:54:12 +08:00
xiatian 7134f33e1d fix(sheet): route formula verify to registered tool 2026-08-06 11:53:06 +08:00
玉澜andCursor f54b964d62 fix(cli): keep visible Deprecated recovery stub
Drop Hidden so authoritative interface integrity still passes, restore
the CI historical command gate, and keep the unsupported notice without
Skill guidance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 11:48:31 +08:00
长真 88f7ea5679 Merge remote-tracking branch 'upstream/main' into feat/chat-toolbar-commands 2026-08-06 11:43:56 +08:00
玉澜andCursor 11fbeb4851 fix(cli): hide dws recovery with unsupported notice
Keep a Hidden compatibility shim that returns 不再支持 for plan/
execute/finalize, so this release stops supporting the surface while a
later release can delete the shim entirely.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 11:41:56 +08:00
玉澜andCursor 2b1f38edae ci: drop historical interface command gate
Allow intentional removal of public commands (e.g. dws recovery)
without compatibility stubs. Keep Schema and skill-command checks
in the Interface Integrity job.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 11:36:29 +08:00
xlb1130 c0b562cc07 fix(chat): add package-level MCP call seam for toolbar remove-custom
- introduce removeChatToolbarCustomShortcutFn in toolbar_remove_custom.go
  as a package-level injection seam; default impl routes through
  callMCPToolOnServer against the im server so production behavior is
  unchanged
- update RunE to dispatch via the seam instead of calling
  callMCPToolOnServer inline
- add two TestCrossPlatformCoverage* tests that swap the seam via
  testseam.Swap and verify: (1) without --yes the seam is never called
  and a typed confirmation_required error is returned, (2) with --yes
  the seam is called exactly once with openCid and shortcutId. The
  stub forwards to deps.Caller.CallTool so the user_required contract
  gate (leaf.go) still sees the CallTool channel.
2026-08-06 11:20:52 +08:00
chichuan 37fbb110e3 ci: run release compatibility in PR admission 2026-08-06 11:16:19 +08:00
xiatian 7564496ea0 fix(sheet): clarify csv-put literal text semantics 2026-08-06 11:09:02 +08:00
玉澜andCursor a0b0d98180 drop recipes/shared intermediate dir under multi skills
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 11:01:07 +08:00
玉澜andCursor b742343937 restore per-product conventions dirs; rename _common to recipes/shared only
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 10:56:10 +08:00
玉澜andCursor 657df05d40 Reshape multi shared recipe dirs for agent-friendly paths, content unchanged.
Move dingtalk-shared best_practices/_common into references/recipes/,
drop duplicated product _common/conventions copies, and retarget links.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 10:53:28 +08:00
玉澜andCursor 86f2b14449 docs: drop multi skill experimental banners
Remove EXPERIMENTAL/Preview banners from skill setup, install scripts, README, and misc references so multi mode is no longer framed as unstable preview.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 10:46:42 +08:00
玉澜andCursor 86014c97cf fix(makefile): keep skill content/command checks out of policy
Do not expand the default policy gate with mono-multi or skill-commands;
leave them as optional make targets only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 10:35:20 +08:00
长真 70d58648c8 fix(chat): address CR P1 for toolbar remove-custom
- Drop --yes and shell-comment example lines from the Cobra Example
  field in chat toolbar remove-custom; keep only the single
  non-bypassing command line. Aligns with AGENTS.md "no --yes in
  stored examples" and "No shell comments in examples" rules.
- Mirror the change in skills/mono/references/products/chat.md
  toolbar remove-custom block: remove the duplicated --yes and
  shell-comment lines; keep Flags block and prose note untouched.
- Add two end-to-end confirmation gate tests under
  internal/helpers/toolbar_helpers_test.go using the existing
  toolbarTestCaller seam (extended with a calls []toolbarCall
  slice so the new tests can assert call counts as well as the
  most recent call):
  * TestCrossPlatformCoverageToolbarRemoveCustomRejectsWithoutYes
    asserts confirmation_required and zero MCP calls when --yes
    is omitted.
  * TestCrossPlatformCoverageToolbarRemoveCustomCallsMCPWithExactArgsWhenYes
    asserts exactly one im/remove_chat_toolbar_custom_shortcut
    call with openCid=<cid> and shortcutId=<id> when --yes is
    set.
- No changes to Contract.Selection.Examples (already compliant),
  Long prose, or any other toolbar file. Helper field addition is
  additive: legacy single-call fields stay so all prior tests
  remain green.

Fixes: PR #877 CR P1 (remove-custom confirmation gate).
Risk tier: Standard.
Verification: see PR description.
2026-08-06 10:35:04 +08:00
玉澜andCursor 94f3bba504 Merge declare/semantic-param-backfill into multi-skill align.
Bring ding ParamDecl backfill, schema agent-view bounds, and discovery
cache CLI/doctor retirement onto the multi-skill framework branch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 10:31:40 +08:00
玉澜andCursor 339eaa4b1b Drop discovery-cache skill guidance and doctor cache check.
Prefer schema --compact in agent docs, remove服务发现/cache teaching,
and stop doctor from reporting a no-op cache health item.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 10:28:20 +08:00
玉澜andCursor 96774e6e23 Remove retired discovery cache CLI and doctor cache check.
Drop the no-op dws cache stubs and the doctor cache health item, and
scrub skill/AGENTS guidance that still pointed agents at them.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 10:28:05 +08:00
Anonymity-0 665b79d8da Merge branch 'main' into feat/robot-message-image-file 2026-08-06 09:54:07 +08:00
克谨 32e7a80889 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-06 09:52:52 +08:00
xiatian 5e6b588b5e Merge upstream/main into codex/csv-put-formula-pr 2026-08-06 09:52:28 +08:00
玉澜 c1d90672a8 test(schema): drop compact leaf size ceiling 2026-08-06 00:56:13 +08:00
玉澜 3d2d287723 feat(schema): bound agent views and prevent instruction drift 2026-08-06 00:49:31 +08:00
玉澜andCursor 969292a8d7 declare(ding): semantic ParamDecl backfill for schema inference residuals
Complete ding leaf Property/Required from Execute CallMCP keys and live
help semantics, and allowlist the four inference→declare property remaps
so schema-compat does not freeze wrong camelCase flag names.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 23:19:19 +08:00
玉澜andCursor ac610f2d24 fix(skills): remove stale conference product routing
No conference skill exists; stop linking conference.md / routing to
conference, and teach CLI-unsupported + DingTalk client instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 23:07:32 +08:00
玉澜andCursor 98f45cfe23 Retire dead MCP pin candidates from Schema parameter assembly.
Production already ships an empty pin; remove the leftover mcp_metadata
candidate path so parameter resolution only uses declare/Cobra sources.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:45:33 +08:00
玉澜andCursor fbf97ce402 feat(skills): fold long-tail skills into misc; rename dws-shared
Host hrbrain, markdown, pat, and profile under dingtalk-misc, retire
their standalone packages, and rename dws-shared to dingtalk-shared
across live paths, coverage, installers, and policy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:30:08 +08:00
玉澜andCursor f5b029b1a5 feat(skills): wire misc routing and coverage for folded event
Point coverage, installers, IM skill-chain, and shared routing at
dingtalk-misc references after retiring the standalone event package.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:18:21 +08:00
玉澜andCursor 75f92cf546 feat(skills): fold dingtalk-event into dingtalk-misc
Host personal IM event docs under misc like other long-tail products, and
retire the standalone multi skill package.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:18:18 +08:00
玉澜andCursor 6d485f47eb feat(skills): wire misc routing and coverage for folded dev/skill
Point coverage, shortcut generation, installers, and shared routing at
dingtalk-misc references after retiring the standalone packages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:02:11 +08:00
玉澜andCursor 6651a162c5 feat(skills): fold dingtalk-dev and dingtalk-skill into dingtalk-misc
Host open-platform app docs and skill-market commands under misc like
other long-tail products, and retire the standalone multi skill packages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:01:57 +08:00
玉澜 a9aa39c3e2 Revert "fix(skills): correct deprecated and nonexistent command teaching"
This reverts commit b0cd419f28.
2026-08-05 21:37:37 +08:00
玉澜andCursor b0cd419f28 fix(skills): correct deprecated and nonexistent command teaching
Align mono/multi minutes/doc/conference skill facts with live CLI: prefer
--limit/--cursor, drop false participants claims, replace deprecated doc
search, and mark conference as unsupported without dead conference.md links.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 21:13:39 +08:00
玉澜andCursor aa487002b7 fix(i18n/docs): drop recovery locale strings and close audit nits.
Sync transport post-recovery hints into en/zh locales, refresh skill QA docs for Phase 1–3/4B, and remove the dead internal/recovery CI high-risk path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:26:41 +08:00
玉澜andCursor 941bf01e30 Remove unused dws recovery CLI and continue multi-skill content framework.
Drop the recovery package/commands and related Schema/skill teaching so agents
stop being steered at a dead surface, while keeping mono↔multi content QA work.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:23:37 +08:00
玉澜andCursor b439c5fa09 docs(skill): add mono↔multi content QA track to align plan
Specify coverage/structure/drift gates against mono, inventory existing
skill policy tests, and extend the §7 checklist for the QA track.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:23:01 +08:00
玉澜andCursor 67250a9da5 docs(skill): limit align plan to content framework only
Defer install/upgrade behavior and cherry-picks to a follow-up branch;
keep this branch on multi/mono content layout and content contracts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:22:57 +08:00
玉澜andCursor 70243acb95 docs(skill): narrow wukong align plan to skill framework
Limit scope to skill trees and skill install/setup/upgrade framework;
defer non-skill CLI, client pipelines, and full installer rewrites.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:22:53 +08:00
玉澜andCursor 6cce7fdbd8 docs(skill): add multi-skill vs wukong align plan
Capture inventory, port/adapt/reject decisions, and phased work before any
framework implementation on a main-based branch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:22:48 +08:00
克谨 fae21ec9f2 fix(chat): extend parameter and shortcut fallbacks 2026-08-05 20:17:49 +08:00
克谨 779fd82a88 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-05 20:14:10 +08:00
前津 3e60b83881 Merge remote-tracking branch 'upstream/main' into feat/robot-message-image-file 2026-08-05 20:04:29 +08:00
Anonymity-0 66a676b6aa Merge branch 'main' into feat/robot-message-image-file 2026-08-05 20:02:09 +08:00
前津 23bbbccb7e fix: use DX markdown type for robot direct messages 2026-08-05 19:58:17 +08:00
长真 868d9ff2b0 Merge remote-tracking branch 'upstream/main' into feat/chat-toolbar-commands 2026-08-05 19:56:11 +08:00
长真 cb2f240c0c chore(ci): refresh pr merge ref 2026-08-05 19:53:16 +08:00
长真 d800060e06 test(chat): cover toolbar command edges 2026-08-05 19:47:02 +08:00
长真 8d8206f791 Merge remote-tracking branch 'upstream/main' into feat/chat-toolbar-commands 2026-08-05 19:20:34 +08:00
长真 15c2bd50b8 test(schema): derive chat shortcut counts 2026-08-05 19:16:25 +08:00
长真 711d557b93 fix(chat): resolve schema policy BLOCK in toolbar remove-custom/create-custom
- Remove --yes from Selection.Examples in toolbar_remove_custom.go
  (schema_agent_examples.go forbids --yes in stored examples)
- Fix Confirmation "required" -> "user_required" in toolbar_remove_custom.go
  (schema catalog requires enum value from {not_required, user_required})
- Fix Idempotency "not_idempotent" -> "non_idempotent" in toolbar_create_custom.go
  (schema catalog requires enum value from {idempotent, non_idempotent, unknown})

Fixes: F1 BLOCK from stability-release-engineer round 5 review
2026-08-05 18:43:00 +08:00
长真 6102e9fc68 fix(chat): resolve code review BLOCK and WARNINGs for toolbar commands
- B1: Fix --sort-index 0 silent drop by using cmd.Flags().Changed()
  instead of value comparison in create-custom and update-custom
- W1: Add MarkFlagRequired("shortcut-id") in remove-custom and
  update-custom for consistent error messages
- W2: Extend SYSTEM_BUSY error handling to all write commands
  (add/hide/create-custom/remove-custom/update-custom)
- W3: Add duplicate key detection in parseExtension to prevent
  silent data loss on repeated --extension keys
2026-08-05 18:09:35 +08:00
xiatian fd61868393 test(sheet): use managed seams in csv-put test 2026-08-05 18:02:01 +08:00
长真 ae426f37de feat(chat): add toolbar custom CRUD commands
Add 3 custom shortcut bar CRUD subcommands and update skill docs.

- toolbar_create_custom.go: create custom entry with extension parsing
  and org-id-list support (write/medium, not_idempotent)
- toolbar_remove_custom.go: delete custom entry with --yes confirmation
  gate (write/medium, confirmation required)
- toolbar_update_custom.go: update custom entry with same parameter set
  as create-custom plus shortcut-id (write/medium)
- chat.md: add toolbar command group documentation with all 7 subcommands
2026-08-05 17:58:23 +08:00
长真 4dbfaa4cef feat(chat): add toolbar commands (list/add/hide/sort)
Add `dws chat toolbar` command group with shared helpers and 4 basic
subcommands for managing conversation shortcut bar visibility and order.

- toolbar_helpers.go: shared utilities (hasIntersection, isSystemBusy,
  parseExtension, toolbarConversationID, toolbarNewSystemBusyError)
- toolbar_helpers_test.go: unit tests for shared helpers
- toolbar.go: command group entry assembling 7 subcommands
- toolbar_list.go: list shortcut entries (read/low)
- toolbar_add.go: add entries to visible area (write/low)
- toolbar_hide.go: hide entries from visible area (write/low)
- toolbar_sort.go: sort entries with intersection validation and
  SYSTEM_BUSY error handling (write/low)
- chat.go: mount newChatToolbarCommand() to chat root
2026-08-05 17:58:09 +08:00
玉澜andCursor 2ea5acc2a3 fix(helpers): align PR868 whiteboard coverage with main API
After merging #861, use prepareWhiteboardCard and --yes so coverage
tests compile and match fail-closed confirmation + soft pending verify.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:07:19 +08:00
玉澜andCursor 5e1bac51e5 Merge origin/main into fix/cli-missing-surfaces-from-eval
Keep main/#861 fail-closed whiteboard; retain #868 missing surfaces.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:02:27 +08:00
Anonymity-0 f1e95d763d Merge branch 'main' into feat/robot-message-image-file 2026-08-05 16:59:20 +08:00
前津 9140015c42 test(chat): cover exclusive robot targets 2026-08-05 16:43:51 +08:00
前津 de418c5696 test: cover robot rich media branches 2026-08-05 16:43:51 +08:00
前津 5ec5b9811c chore(chat): omit rich media tests 2026-08-05 16:43:51 +08:00
前津 378eaa377e test(chat): use managed rich media seams 2026-08-05 16:43:50 +08:00
前津 d4368be1c3 fix(chat): preserve text schema compatibility 2026-08-05 16:43:50 +08:00
前津 9733acfb5a feat(chat): support robot image and file messages 2026-08-05 16:43:50 +08:00
克谨 b1d422dcfd Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-05 16:23:12 +08:00
克谨 1231e500a3 Merge remote-tracking branch 'origin/main' into fix/param-hallucination
# Conflicts:
#	internal/cli/param_concepts.json
2026-08-05 16:23:01 +08:00
xiatian c508968814 feat(sheet): expose csv-put formula semantics 2026-08-05 15:51:43 +08:00
玉澜andCursor 65ce71b8d4 fix(helpers): avoid race on markdown diff compute seam
Capture runMarkdownUnifiedDiff/diffJSONMarshalIndent before spawning the
compute goroutine so testseam restores cannot race a late timeout path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 13:28:43 +08:00
玉澜andCursor 6bfcac4d54 test(helpers): use testseam for PR868 coverage seam swaps
Policy bans manual prev/t.Cleanup restores for package-var injection seams.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 13:08:24 +08:00
玉澜andCursor f819566c63 fix(schema): treat drive download --version as mapping exclusion
Keep the add-only Wukong compat flag, but publish it as a CLI-local
polymorphic dispatch without a download_file property binding.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 12:58:47 +08:00
玉澜andCursor 10d9aa3058 test(helpers): raise PR868 changed-line coverage to 100%
Exercise markdown diff, mail export/share, drive latest/depth, whiteboard,
and diff-engine edges via TestCrossPlatformCoverage*; add small injectable
seams only where defensive branches are otherwise unreachable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 12:50:00 +08:00
玉澜andCursor a0ca1fdb28 feat(helpers): add minutes missing surfaces and add-only flag aliases
Expose minutes hot-word delete, permission apply, and audio-memo list from
live MCP gaps; add hidden/cross-product flag aliases only (never remove),
with TestCrossPlatformCoverage coverage for the new surfaces.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 12:04:27 +08:00
克谨 10943629d6 Merge remote-tracking branch 'origin/main' into fix/param-hallucination
# Conflicts:
#	Makefile
#	internal/cli/gen.go
#	internal/helpers/dev.go
#	scripts/policy/check-generated-drift.sh
2026-08-05 12:03:39 +08:00
玉澜andCursor 5a93f80daa fix(ci): align new-surface dry-run preview kinds with Schema
Whiteboard dry-run now stamps preview_kind=plan; mail export/share
drop [DRY-RUN] tags so plan evidence matches declared DryRunSpec.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 11:21:38 +08:00
玉澜andCursor 8260cf7f53 fix(ci): drop --yes from mail share-to-chat examples
Schema Manual examples forbid confirmation bypass via --yes; also stub
whiteboardSleep in product example coverage so race CI does not hang.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 11:05:54 +08:00
玉澜andCursor 9fd38d9b9d fix(skills): drop stale EXPERIMENTAL banners and dead skill links
Align leftover multi skill banners with the non-experimental wording,
retarget markdown routing off dingtalk-misc, and remove the retired
SAFETY_PREAMBLE_INJECT marker plus the missing extract_media_id.py refs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 10:44:01 +08:00
玉澜andCursor 58dfbc5b6e feat: sync missing open CLI surfaces from wukong cli_to_mcp gaps
Port calendar event instances, markdown diff, drive list --latest,
mail calendar/calendar-event/shared-with-me/export/share-to-chat, and
doc whiteboard insert so open edition matches documented Wukong test
command surfaces.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 10:39:56 +08:00
克谨 9fd9ae7a95 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-04 20:14:15 +08:00
克谨 5b0198b2ec Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-04 17:12:53 +08:00
克谨 dc854acb9b test: cover command fallback edge cases 2026-08-04 14:23:53 +08:00
克谨 d054e3dc01 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-04 13:52:34 +08:00
克谨 95536c3e97 fix(cli): complete shortcut hallucination fallbacks 2026-08-04 13:52:26 +08:00
克谨 1bfedbd4d2 fix(cli): expand hallucination recovery coverage 2026-08-04 11:59:32 +08:00
克谨 cfaf161fc7 fix(cli): add reviewed command path fallbacks 2026-08-04 09:35:48 +08:00
311 changed files with 25680 additions and 6512 deletions
+285
View File
@@ -0,0 +1,285 @@
'use strict';
// 评审归属是受保护分支上的声明式规则;未知路径不猜测,交给工作流负载均衡兜底。
const REVIEWER_POOL = ['wxianfeng', 'typefield', 'haofeng0705', 'hlzjsong'];
const PRODUCT_GROUPS = [
{
primary: 'wxianfeng',
backup: 'typefield',
products: ['chat', 'contact', 'ding', 'event', 'mail', 'live', 'conference', 'dev', 'devapp', 'mcp', 'aiapp'],
},
{
primary: 'typefield',
backup: 'wxianfeng',
products: ['doc', 'drive', 'wiki', 'markdown', 'docparse', 'aidesign', 'devdoc', 'blackboard', 'finance', 'law', 'credit'],
},
{
primary: 'haofeng0705',
backup: 'typefield',
products: ['minutes', 'sheet', 'aitable', 'calendar', 'todo', 'oa', 'attendance', 'report', 'agoal', 'aisearch', 'yida', 'hrbrain'],
},
];
const pathStartsWith = (prefixes) => (path) => prefixes.some((prefix) => path.startsWith(prefix));
const MODULES = [
{
id: 'security',
label: '登录、认证、权限、安全',
primary: 'hlzjsong',
backup: 'typefield',
requiresSecondary: true,
matches: pathStartsWith([
'internal/auth/',
'internal/keychain/',
'internal/audit/',
'internal/pat/',
'internal/security/',
'internal/safety/',
'pkg/edition/',
]),
},
{
id: 'delivery',
label: 'CI、测试、发布、安装',
primary: 'haofeng0705',
backup: 'wxianfeng',
requiresSecondary: true,
matches: (path) =>
path.startsWith('.github/') ||
path.startsWith('scripts/release/') ||
path.startsWith('scripts/policy/') ||
path.startsWith('scripts/dev/') ||
path.startsWith('scripts/install') ||
path.startsWith('Formula/') ||
path.startsWith('build/') ||
path.startsWith('internal/upgrade/') ||
path.startsWith('internal/app/upgrade') ||
path.startsWith('test/') ||
path.startsWith('verify/') ||
path.startsWith('.workflow/') ||
path === 'coverage.txt' ||
path === 'coverage-base.txt' ||
path === '.goreleaser.yaml' ||
path === 'package.json' ||
path === 'package-lock.json' ||
path === 'docs/releasing.md',
},
{
id: 'architecture',
label: 'DWS 架构、公共内核',
primary: 'wxianfeng',
backup: 'typefield',
requiresSecondary: true,
matches: pathStartsWith([
'cmd/',
'internal/apiclient/',
'internal/app/',
'internal/cli/',
'internal/cobracmd/',
'internal/corecmd/',
'internal/errors/',
'internal/executor/',
'internal/generator/',
'internal/i18n/',
'internal/interfacesnapshot/',
'internal/jsonutil/',
'internal/localio/',
'internal/logging/',
'internal/output/',
'internal/pipeline/',
'internal/plugin/',
'internal/profilectx/',
'internal/registry/',
'internal/syncdata/',
'internal/testseam/',
'internal/transport/',
'pkg/',
]),
},
{
id: 'compatibility',
label: '兼容性',
primary: 'wxianfeng',
backup: 'typefield',
requiresSecondary: true,
matches: (path) =>
/(?:^|[/_.-])compat(?:ibility)?(?=$|[/_.-])/.test(path) ||
path.includes('schema_compat'),
},
];
function productMatches(path, product) {
const aliases = product === 'blackboard' ? ['blackboard', 'whiteboard'] : [product];
return aliases.some((alias) => new RegExp(`(?:^|[/_.-])${alias}(?=$|[/_.-])`).test(path));
}
const PRODUCT_MODULES = PRODUCT_GROUPS.flatMap((group) =>
group.products.map((product) => ({
id: `product:${product}`,
label: `产品:${product}`,
primary: group.primary,
backup: group.backup,
requiresSecondary: false,
matches: (path) => productMatches(path, product),
})),
);
const ALL_MODULES = [MODULES[0], MODULES[1], ...PRODUCT_MODULES, MODULES[2], MODULES[3]];
function normalizedPaths(file) {
return [file?.filename, file?.previous_filename]
.filter((path) => typeof path === 'string' && path !== '')
.map((path) => path.toLowerCase());
}
function compareStats(left, right) {
return right.files - left.files || left.module.order - right.module.order || left.module.id.localeCompare(right.module.id);
}
function classifyFiles(files) {
const counts = new Map();
for (const file of files || []) {
const matchingModules = new Set();
for (const path of normalizedPaths(file)) {
const matches = ALL_MODULES.filter((module) => module.matches(path));
const securityOrDelivery = matches.filter(
(module) => module.id === 'security' || module.id === 'delivery',
);
const effectiveMatches = securityOrDelivery.length > 0
? [...securityOrDelivery, ...matches.filter((module) => module.id === 'compatibility')]
: matches;
for (const match of effectiveMatches) {
matchingModules.add(match.id);
}
if (
effectiveMatches.length === 0 &&
(path.startsWith('internal/helpers/') || path.startsWith('internal/shortcut/'))
) {
matchingModules.add('architecture');
}
}
for (const moduleID of matchingModules) {
counts.set(moduleID, (counts.get(moduleID) || 0) + 1);
}
}
return [...counts.entries()]
.map(([id, files]) => {
const index = ALL_MODULES.findIndex((module) => module.id === id);
return {module: {...ALL_MODULES[index], order: index}, files};
})
.sort(compareStats);
}
function chooseModuleReviewer(module, unavailable) {
return [module.primary, module.backup].find(
(reviewer) => REVIEWER_POOL.includes(reviewer) && !unavailable.has(reviewer),
);
}
function addReviewer(reviewers, reviewer) {
if (reviewer && !reviewers.includes(reviewer)) {
reviewers.push(reviewer);
}
}
function reviewerCandidates({preferredReviewers, fallbackReviewers, eligibleReviewers}) {
const eligible = new Set(eligibleReviewers.map((reviewer) => reviewer.toLowerCase()));
const candidates = [];
for (const reviewer of [...preferredReviewers, ...fallbackReviewers]) {
if (
eligible.has(reviewer.toLowerCase()) &&
!candidates.some((candidate) => candidate.toLowerCase() === reviewer.toLowerCase())
) {
candidates.push(reviewer);
}
}
return candidates;
}
async function requestReviewersWithFallback({
candidates,
requiredReviewers,
satisfiedReviewers = [],
requestReviewer,
onFailure = () => {},
}) {
const alreadySatisfied = new Set(
satisfiedReviewers.map((reviewer) => reviewer.toLowerCase()),
);
const satisfied = new Set();
const requested = [];
for (const reviewer of candidates) {
if (satisfied.size >= requiredReviewers) {
break;
}
const normalizedReviewer = reviewer.toLowerCase();
if (alreadySatisfied.has(normalizedReviewer)) {
satisfied.add(normalizedReviewer);
continue;
}
try {
const shouldContinue = await requestReviewer(reviewer);
if (shouldContinue === false) {
return {requested, satisfiedReviewers: [...satisfied], aborted: true};
}
requested.push(reviewer);
satisfied.add(normalizedReviewer);
} catch (error) {
onFailure(reviewer, error);
}
}
return {requested, satisfiedReviewers: [...satisfied], aborted: false};
}
function resolveReviewRouting({files, author, latestPusher, fallbackReviewers = REVIEWER_POOL}) {
const modules = classifyFiles(files);
const unavailable = new Set([author, latestPusher].filter(Boolean).map((login) => login.toLowerCase()));
const reviewers = [];
const primaryModule = modules[0];
if (!primaryModule) {
return {modules: [], reviewers, requiredReviewers: 1, reason: 'unknown_paths'};
}
addReviewer(reviewers, chooseModuleReviewer(primaryModule.module, unavailable));
const requiresSecondary =
modules.length > 1 || modules.some(({module}) => module.requiresSecondary);
const secondaryModule = modules.find(({module}) => module.id !== primaryModule.module.id) || primaryModule;
if (requiresSecondary) {
addReviewer(
reviewers,
chooseModuleReviewer(secondaryModule.module, new Set([...unavailable, ...reviewers])),
);
}
for (const reviewer of fallbackReviewers) {
if (reviewers.length >= (requiresSecondary ? 2 : 1)) {
break;
}
if (REVIEWER_POOL.includes(reviewer) && !unavailable.has(reviewer)) {
addReviewer(reviewers, reviewer);
}
}
return {
modules: modules.map(({module, files}) => ({id: module.id, label: module.label, files})),
reviewers,
requiredReviewers: requiresSecondary ? 2 : 1,
reason: requiresSecondary ? 'cross_or_sensitive' : 'single_module',
};
}
module.exports = {
REVIEWER_POOL,
classifyFiles,
requestReviewersWithFallback,
resolveReviewRouting,
reviewerCandidates,
};
+148
View File
@@ -0,0 +1,148 @@
'use strict';
const assert = require('node:assert/strict');
const {
requestReviewersWithFallback,
resolveReviewRouting,
reviewerCandidates,
} = require('./reviewer-routing');
function route(files, author = 'author', latestPusher = author) {
return resolveReviewRouting({files: files.map((filename) => ({filename})), author, latestPusher});
}
{
const result = route(['internal/helpers/chat_toolbar.go']);
assert.deepEqual(result.reviewers, ['wxianfeng']);
assert.equal(result.requiredReviewers, 1);
assert.deepEqual(result.modules.map((module) => module.id), ['product:chat']);
}
{
const result = route(['internal/helpers/chat_toolbar.go', 'internal/helpers/doc_style.go']);
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['.github/workflows/ci.yml']);
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
assert.equal(result.requiredReviewers, 2);
assert.equal(result.reason, 'cross_or_sensitive');
}
{
const result = route(['internal/auth/login.go'], 'hlzjsong');
assert.deepEqual(result.reviewers, ['typefield', 'wxianfeng']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['internal/upgrade/downloader.go']);
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['internal/app/upgrade.go', 'scripts/dev/test-release.sh']);
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['pkg/edition/edition.go']);
assert.deepEqual(result.reviewers, ['hlzjsong', 'typefield']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['internal/shortcut/chat/compatibility_coverage_test.go']);
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
assert.equal(result.requiredReviewers, 2);
assert.deepEqual(result.modules.map((module) => module.id), ['product:chat', 'compatibility']);
}
{
const result = route(['internal/helpers/leaf_dispatch.go']);
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['docs/unknown-area.md']);
assert.deepEqual(result.reviewers, []);
assert.equal(result.reason, 'unknown_paths');
}
async function testSingleReviewerFallback() {
const candidates = reviewerCandidates({
preferredReviewers: ['wxianfeng'],
fallbackReviewers: ['wxianfeng', 'typefield', 'haofeng0705'],
eligibleReviewers: ['wxianfeng', 'typefield', 'haofeng0705'],
});
const attempts = [];
const result = await requestReviewersWithFallback({
candidates,
requiredReviewers: 1,
requestReviewer: async (reviewer) => {
attempts.push(reviewer);
if (reviewer === 'wxianfeng') {
throw Object.assign(new Error('cannot request primary'), {status: 422});
}
return true;
},
});
assert.deepEqual(attempts, ['wxianfeng', 'typefield']);
assert.deepEqual(result.requested, ['typefield']);
assert.equal(result.satisfiedReviewers.length, 1);
}
async function testTwoReviewerFallback() {
const candidates = reviewerCandidates({
preferredReviewers: ['haofeng0705', 'wxianfeng'],
fallbackReviewers: ['haofeng0705', 'wxianfeng', 'typefield', 'hlzjsong'],
eligibleReviewers: ['haofeng0705', 'wxianfeng', 'typefield', 'hlzjsong'],
});
const attempts = [];
const result = await requestReviewersWithFallback({
candidates,
requiredReviewers: 2,
requestReviewer: async (reviewer) => {
attempts.push(reviewer);
if (reviewer === 'wxianfeng') {
throw Object.assign(new Error('temporary failure'), {status: 503});
}
return true;
},
});
assert.deepEqual(attempts, ['haofeng0705', 'wxianfeng', 'typefield']);
assert.deepEqual(result.requested, ['haofeng0705', 'typefield']);
assert.equal(result.satisfiedReviewers.length, 2);
}
async function testLowerPriorityExistingRequestDoesNotReplaceOwner() {
const attempts = [];
const result = await requestReviewersWithFallback({
candidates: ['wxianfeng', 'typefield'],
requiredReviewers: 1,
satisfiedReviewers: ['typefield'],
requestReviewer: async (reviewer) => {
attempts.push(reviewer);
return true;
},
});
assert.deepEqual(attempts, ['wxianfeng']);
assert.deepEqual(result.requested, ['wxianfeng']);
assert.deepEqual(result.satisfiedReviewers, ['wxianfeng']);
}
Promise.all([
testSingleReviewerFallback(),
testTwoReviewerFallback(),
testLowerPriorityExistingRequestDoesNotReplaceOwner(),
])
.then(() => console.log('reviewer routing policy tests passed'))
.catch((error) => {
console.error(error);
process.exitCode = 1;
});
+13 -2
View File
@@ -111,6 +111,7 @@ jobs:
filename.startsWith('internal/app/') ||
filename.startsWith('internal/cli/') ||
filename.startsWith('internal/cobracmd/') ||
filename.startsWith('internal/corecmd/') ||
filename.startsWith('internal/helpers/') ||
filename.startsWith('internal/i18n/') ||
filename.startsWith('internal/interfacesnapshot/') ||
@@ -152,7 +153,6 @@ jobs:
filename.startsWith('internal/interfacesnapshot/') ||
filename.startsWith('internal/app/upgrade') ||
filename.startsWith('internal/transport/') ||
filename.startsWith('internal/recovery/') ||
filename.startsWith('internal/syncdata/') ||
filename.includes('/testdata/') ||
filename.startsWith('testdata/') ||
@@ -433,6 +433,10 @@ jobs:
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
- name: Test reviewer routing policy
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
run: node .github/reviewer-routing.test.js
test-focused:
name: Test (changed packages)
needs: lint
@@ -1176,7 +1180,7 @@ jobs:
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
COVERAGE_TARGET: "100"
COVERAGE_ENFORCE_OVERALL: "false"
COVERAGE_OVERALL_TOLERANCE: "0"
COVERAGE_OVERALL_TOLERANCE: "0.1"
run: |
policy_profile=coverage-policy.txt
if [ "$FULL_SUITE" != true ]; then
@@ -1386,6 +1390,13 @@ jobs:
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"
- 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"
+87 -44
View File
@@ -21,6 +21,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out trusted routing policy
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- name: Route review and enable auto-merge
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
@@ -29,12 +34,13 @@ jobs:
const repo = context.repo.repo;
const pullNumber = context.payload.pull_request.number;
const eventHeadSha = context.payload.pull_request.head.sha;
const reviewerPool = [
'sczheng189',
'shangguanxuan633-lab',
'audanye-sudo',
'wxianfeng',
];
const {
REVIEWER_POOL,
requestReviewersWithFallback,
resolveReviewRouting,
reviewerCandidates,
} = require('./.github/reviewer-routing.js');
const reviewerPool = REVIEWER_POOL;
async function getReadyEventPull(phase) {
const {data: currentPull} = await github.rest.pulls.get({
@@ -67,6 +73,21 @@ jobs:
: author;
async function routeReview() {
let changedFiles;
try {
changedFiles = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: pullNumber,
per_page: 100,
});
} catch (error) {
core.warning(
`Could not inspect changed files for PR #${pullNumber}; using load-balanced fallback (${error.status || 'unknown status'}).`,
);
changedFiles = [];
}
const eligible = reviewerPool.filter(
reviewer =>
reviewer.toLowerCase() !== author &&
@@ -77,13 +98,9 @@ jobs:
return;
}
const alreadyRequested =
(pullRequest.requested_reviewers || []).length > 0 ||
(pullRequest.requested_teams || []).length > 0;
if (alreadyRequested) {
core.info(`PR #${pullNumber} already has a requested reviewer; leaving it unchanged.`);
return;
}
const existingRequestedReviewers = new Set(
(pullRequest.requested_reviewers || []).map(({login}) => login.toLowerCase()),
);
let reviews;
try {
@@ -116,22 +133,18 @@ jobs:
latestDecisionByLogin.set(login, review);
}
}
const currentHeadDecision = [...latestDecisionByLogin.values()].find(
review =>
review.commit_id === headSha &&
eligible.some(
reviewer =>
reviewer.toLowerCase() ===
review.user.login.toLowerCase(),
) &&
['APPROVED', 'CHANGES_REQUESTED'].includes(review.state),
const currentHeadReviewers = new Set(
[...latestDecisionByLogin.values()]
.filter(
review =>
review.commit_id === headSha &&
eligible.some(
reviewer => reviewer.toLowerCase() === review.user.login.toLowerCase(),
) &&
['APPROVED', 'CHANGES_REQUESTED'].includes(review.state),
)
.map(review => review.user.login.toLowerCase()),
);
if (currentHeadDecision) {
core.info(
`PR #${pullNumber} already has a ${currentHeadDecision.state} review on its current head; leaving review ownership unchanged.`,
);
return;
}
const loads = new Map(eligible.map(reviewer => [reviewer, 0]));
try {
@@ -178,20 +191,42 @@ jobs:
tieOrder.get(left) - tieOrder.get(right),
);
for (const reviewer of ranked) {
try {
const routing = resolveReviewRouting({
files: changedFiles,
author,
latestPusher,
fallbackReviewers: ranked,
});
const candidates = reviewerCandidates({
preferredReviewers: routing.reviewers,
fallbackReviewers: ranked,
eligibleReviewers: eligible,
});
const desiredReviewers = candidates.slice(0, routing.requiredReviewers);
if (routing.reason === 'unknown_paths' && currentHeadReviewers.size > 0) {
core.info(
`PR #${pullNumber} has a current-head review for unknown paths; leaving manual ownership unchanged.`,
);
return;
}
core.info(
`PR #${pullNumber} routing: ${routing.reason}; modules=${routing.modules.map(module => module.id).join(',') || 'unknown'}; reviewers=${desiredReviewers.join(',') || 'load-balanced fallback'}.`,
);
const satisfiedReviewers = new Set([
...currentHeadReviewers,
...[...existingRequestedReviewers].filter((reviewer) =>
candidates.some((candidate) => candidate.toLowerCase() === reviewer),
),
]);
const requestResult = await requestReviewersWithFallback({
candidates,
requiredReviewers: routing.requiredReviewers,
satisfiedReviewers: [...satisfiedReviewers],
requestReviewer: async (reviewer) => {
const currentPull = await getReadyEventPull('review request');
if (!currentPull) {
return;
}
if (
(currentPull.requested_reviewers || []).length > 0 ||
(currentPull.requested_teams || []).length > 0
) {
core.info(
`PR #${pullNumber} received a reviewer while routing; leaving it unchanged.`,
);
return;
return false;
}
await github.rest.pulls.requestReviewers({
owner,
@@ -202,15 +237,23 @@ jobs:
core.info(
`Requested @${reviewer} for PR #${pullNumber} (open request load: ${loads.get(reviewer)}).`,
);
return;
} catch (error) {
return true;
},
onFailure: (reviewer, error) => {
core.warning(
`Could not request @${reviewer} for PR #${pullNumber}; trying the next candidate (${error.status || 'unknown status'}).`,
);
}
},
});
if (requestResult.aborted) {
return;
}
core.warning(`No reviewer request could be created for PR #${pullNumber}.`);
if (requestResult.satisfiedReviewers.length < routing.requiredReviewers) {
core.warning(
`Only ${requestResult.satisfiedReviewers.length} of ${routing.requiredReviewers} required reviewers could be satisfied for PR #${pullNumber}.`,
);
}
}
async function enableAutoMerge() {
+15 -12
View File
@@ -302,9 +302,8 @@ on the leaf:
```bash
dws auth status # token_valid should be true
dws cache refresh # deprecated no-op: prints a retirement notice (discovery cache is gone; refreshes nothing)
dws schema <mcp-canonical> -f json
# or CLI path: dws schema --cli-path "drive copy" -f json
dws schema <mcp-canonical> --jq '{canonical_path,interface_ref,parameters}' -f json
# or CLI path: dws schema --cli-path "drive copy" --jq '{canonical_path,interface_ref,parameters}' -f json
```
Resolve MCP identity via declared `interface_ref` when CLI canonical ≠ MCP path
@@ -321,8 +320,10 @@ Skill (evidence only)**.
Split work by product groups. Each agent must:
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live `dws schema`
for its tools.
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live
`dws schema <leaf> --compact` for its tools. Mapping/interface/provenance
audits may query the full leaf only through a narrow `--jq` / `--fields`
projection; do not load an entire full leaf into Agent context.
- Hand-write selection prose and leaf Contract / ProductDecl declarations;
forbid wholesale JSON merges from review dumps.
- Edit only its product’s leaf declarations (and `ProductDecl` when needed).
@@ -471,13 +472,15 @@ path; a generator unit test or JSON count alone is insufficient.
`parameters` object for commands without flags. Keep it suitable for the #602
compatibility baseline and fail rather than silently emitting a partial
export.
- `schema --all` is not normal command discovery. Use overview -> product/group
-> leaf for routine Agent work. `--compact` is supported for context-saving
projections, but a compact full export is not a complete compatibility
baseline.
- `schema --all` is not normal command discovery. Use overview -> compact
product/group -> compact leaf for routine Agent work. `--compact` is the
reviewed positive-field allowlist for Agent context: new full/audit fields
must not appear there until explicitly reviewed. A compact full export is not
a complete compatibility baseline.
- `dws <path> --help` defines whether Cobra exposes a path and which flags the
executable accepts. A leaf Schema defines Agent selection, parameter mapping
and constraints, and safety/confirmation semantics. A conflict is contract
drift, not permission to guess.
executable accepts. A compact leaf defines Agent selection, CLI parameters,
constraints, and safety/confirmation semantics. Full leaf fields such as
`property`, `interface_ref`, and provenance are audit facts. A conflict is
contract drift, not permission to guess.
- Schema and Help describe commands; neither returns DingTalk business data.
After discovery, execute the real read/search/list command to obtain data.
+51 -6
View File
@@ -6,15 +6,59 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
### Changed
- **Sheet CSV formula writes** — `dws sheet csv-put` and batch `csv-put` now expose the service contract that CSV fields beginning with `=` are written as formulas. Prefix the field with an apostrophe to write literal text beginning with `=`; CSV content continues to pass through unchanged.
### Fixed
- **Sheet formula verification** (#873) — `dws sheet formula-verify` now calls
the registered remote tool name `verify_formula`; the previous
`formula_verify` name failed at gateway dispatch.
## [1.0.57-beta.4] - 2026-08-06
### Added
- **Expanded open CLI workflows** (#887) — adds calendar event-instance
queries, Drive latest-file selection, Markdown diff, Mail calendar/export/
share-to-chat workflows, and Minutes hot-word, permission, and audio-memo
operations, with matching Schema and cross-platform coverage.
### Changed
- **Multi-skill framework alignment** (#887) — folds long-tail skills into
`dingtalk-misc`, renames the shared package to `dingtalk-shared`, removes
stale Preview guidance, and reorganizes shared recipes and routing for more
predictable Agent selection.
- **Bounded Agent Schema delivery** (#887) — keeps compact and wire projections
focused on executable contract facts, retires stale MCP metadata candidates,
and teaches Agents to prefer `dws schema --compact` for bounded context.
### Deprecated
- **Recovery and discovery-cache compatibility surfaces** (#887) — keeps
visible Deprecated `dws recovery` and `dws cache` compatibility stubs while
retiring their former recovery engine and dynamic discovery-cache behavior.
Recovery plan/execute/finalize now return an explicit “不再支持” notice, and
Skills no longer teach either retired workflow.
### Fixed
- **Mail share-to-chat confirmation** (#887) — requires explicit confirmation
before the first remote write, while preserving the confirmed sign-retry
flow and covering both direct-success and retry responses.
## [1.0.57] - 2026-08-06
This stable release promotes the fully delivered `v1.0.57-beta.3` baseline.
It includes reviewed document shortcuts and chat reply mentions, together with
the v1.0.57 beta-line command-contract, document, chat, OA, Wiki, compatibility,
and CI reliability improvements validated through the prerelease channel.
This stable release promotes the fully delivered `v1.0.57-beta.4` baseline.
It includes the v1.0.57 beta-line command-contract, document, chat, OA, Wiki,
and compatibility improvements, plus the multi-skill framework alignment and
expanded calendar, Drive, Markdown, Mail, and Minutes workflows validated in
the final prerelease.
- **Promote v1.0.57-beta.3** — publishes the validated prerelease baseline as
the stable `v1.0.57` release without adding post-beta product changes.
- **Promote v1.0.57-beta.4** — publishes the final validated prerelease
baseline as stable `v1.0.57` without adding post-beta product changes.
## [1.0.57-beta.3] - 2026-08-06
@@ -92,6 +136,7 @@ and compatibility and CI reliability fixes.
### Added
- **Document-embedded whiteboard workflows** — adds `doc whiteboard insert` for confirmed creation and part-ID verification, `whiteboard query/update` for structured OpenNodes reads and confirmed writes, and `doc media upload` for preparing node-bound Vector/SVG resources. The public adapter uses an explicit helper-only whiteboard endpoint, validates update envelopes locally, decodes `resultJson`, and publishes the full command, Schema, Skill, and safety contract migrated from `dws-wukong@e2da8ab947c6`.
- **Robot image and file messages** — extends `chat message send-by-bot` with public image URL delivery through `--msg-type image --image-url` and local-file upload/send through `--msg-type file --file-path`, while preserving Markdown as the default message type.
### Changed
+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.57-beta.3"
version "1.0.57-beta.4"
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.57-beta.3/dws-darwin-arm64.tar.gz"
sha256 "b1ea300a76654751ea33540d8a244b0c81b5df3947786980f95a5a19362a097a"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-darwin-arm64.tar.gz"
sha256 "ff363e258d463732e4dc02aa71ac1b5b1f05c25107c32ddcc5ae11d14931a782"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-darwin-amd64.tar.gz"
sha256 "68b5f6e38bec994fa4db4bef5db1629c7bce799bb9837c447008c3325fc886e3"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-darwin-amd64.tar.gz"
sha256 "3eafcc4c27931b8457f3611a12ce4ae727f7bd65356fbef80958699defa09acf"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-linux-arm64.tar.gz"
sha256 "350e74f1a2611975e476e113e50264975a98185c11ee889a83d9480c0f10181b"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-linux-arm64.tar.gz"
sha256 "49eabb4d2c419d8d1fb0fcf71d9c318ef49cf3d198431df1bcbaa71589e11383"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-linux-amd64.tar.gz"
sha256 "2c27a9a884650a7a60545d9447f1b966667216e94c333ebf9d69f9b2ead96e04"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-linux-amd64.tar.gz"
sha256 "fb3903485fe494e1fadb67ec87b2fef19cf575c72b7df25f0b93f80f8b36273f"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-skills.zip"
sha256 "abaa8feaa3c61fff048cfd1139e1fc5b91c329eb666d2eb852797a3f5c4c0cac"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-skills.zip"
sha256 "6bd11363dbd2ce79627f49bc80b8a92f9c79fca083a6bd3ff9bc5c4833594fb9"
end
def install
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCli < Formula
desc "Automate DingTalk workspace tasks from the terminal"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.56"
version "1.0.57"
license "Apache-2.0"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-darwin-arm64.tar.gz"
sha256 "5c6003fe484aa36cc00820a574186652467b9d075f19c159cf807e57590256ba"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-arm64.tar.gz"
sha256 "c01c28dc13948a70fca905207073dc8dbd22f7ba7fc90e68b3316eb9a9c98e88"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-darwin-amd64.tar.gz"
sha256 "969b005a10682c2a1a828fa112165b5b0cd8ceeed8d22110ef7f39402cc36804"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-amd64.tar.gz"
sha256 "d7baa218beefc851c6a933b456055195f8272984ce008d7e0122bdfc5dad94ea"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-linux-arm64.tar.gz"
sha256 "530c5ea7ddc7de320d9c2471fbd33752a723d00c9665f49321c7580e8392c756"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-arm64.tar.gz"
sha256 "0bbe9c233a3ff585077bae1ac5000937c32d967846d14cc44c46f98d49b95ae2"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-linux-amd64.tar.gz"
sha256 "675fa42727ac9a549c6710b82e1980cd0f795363d71d5116a4e69771b7c5470e"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-amd64.tar.gz"
sha256 "f113ce3654f21d1f9ecc7c196f815aeafbca54d377a347b244a15116c5cba698"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-skills.zip"
sha256 "3d57794e4660a089209ce3962571d16ca0d46141e973c9993257a301cce0e097"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-skills.zip"
sha256 "0c9667209cf30761427a8f9348149cbbf1e397aa3c25587e99f205bc7525e101"
end
def install
+16 -1
View File
@@ -131,6 +131,9 @@ skill-context-budget:
multi-im-skill-chain-integrity:
@./scripts/policy/check-multi-im-skill-chain.sh
skill-mono-multi-content:
@./scripts/policy/check-mono-multi-skill-content.sh
cli-smoke:
@./scripts/policy/check-cli-smoke.sh
@@ -148,9 +151,13 @@ generate-schema:
@set -e; \
concepts_guard=$$(mktemp); \
concepts_schema_guard=$$(mktemp); \
trap 'rm -rf "$$concepts_guard" "$$concepts_schema_guard"' EXIT HUP INT TERM; \
command_fallbacks_guard=$$(mktemp); \
command_fallbacks_schema_guard=$$(mktemp); \
trap 'rm -f "$$concepts_guard" "$$concepts_schema_guard" "$$command_fallbacks_guard" "$$command_fallbacks_schema_guard"' EXIT HUP INT TERM; \
cp internal/cli/param_concepts.json "$$concepts_guard"; \
cp internal/cli/param_concepts.schema.json "$$concepts_schema_guard"; \
cp internal/cli/command_path_fallbacks.json "$$command_fallbacks_guard"; \
cp internal/cli/command_path_fallbacks.schema.json "$$command_fallbacks_schema_guard"; \
$(GO) generate ./internal/cli; \
rm -rf internal/cli/schema_agent_metadata internal/cli/schema_agent_metadata_audit.json; \
rm -f internal/cli/schema_meta_index.json; \
@@ -166,6 +173,14 @@ generate-schema:
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2; \
exit 1; \
}; \
cmp -s internal/cli/command_path_fallbacks.json "$$command_fallbacks_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.json' >&2; \
exit 1; \
}; \
cmp -s internal/cli/command_path_fallbacks.schema.json "$$command_fallbacks_schema_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.schema.json' >&2; \
exit 1; \
}; \
if [ -e internal/cli/schema_hints ]; then \
printf '%s\n' 'retired schema_hints/ must not reappear after generation' >&2; \
exit 1; \
+11 -14
View File
@@ -71,9 +71,7 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
| Mode | What gets installed | Best for |
|------|----------------------|----------|
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
| **multi** 🧪 **EXPERIMENTAL** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** All product-scoped skills pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
| **multi** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
How to pick:
@@ -371,7 +369,7 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
Use Cobra help and Schema for different parts of the command contract:
- `dws <path> --help` is the source of truth for whether a command exists and which flags the binary accepts.
- `dws schema "<path>"` is the Agent contract for command selection, parameter mappings and constraints, risk, and confirmation semantics.
- `dws schema "<path>" --compact` is the normative Agent view for command selection, CLI parameters and constraints, risk, and confirmation; use a full leaf with a narrow `--jq` projection for mapping or provenance audits.
- If Help and Schema disagree, treat it as contract drift: pass only flags accepted by Cobra and use the more conservative safety semantics.
- Schema describes commands; it does not read or search DingTalk business data. Execute the real product command after discovery.
@@ -380,21 +378,21 @@ Use Cobra help and Schema for different parts of the command contract:
dws aitable record query --help
# Discover within a product, then inspect the selected leaf contract
dws schema aitable
dws schema "aitable record query"
dws schema aitable --compact
dws schema "aitable record query" --compact
# Execute the real business query
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
```
`dws schema --all` exports the complete contract for tooling, CI, audits, and compatibility baselines. Agents should prefer product/group discovery followed by a leaf query to avoid loading the full Catalog into context.
`dws schema --all` exports the complete contract for tooling, CI, audits, and compatibility baselines. Agents should query progressively with `--compact`; its positive field allowlist prevents new full/audit fields from silently expanding Agent context.
### Agent Skills
The repo ships a complete Agent Skill system under `skills/`, organized into two layouts:
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`.
Leaf safety/parameters/selection prose for Schema generation come from ProductDecl / ContractFinal declarations in Go. The former `internal/cli/schema_hints/` HintFile tree is fully retired and must not reappear.
@@ -443,7 +441,6 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
| Intent guide | `skills/mono/references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) |
| Global reference | `skills/mono/references/global-reference.md` | Auth, output formats, global flags |
| Error codes | `skills/mono/references/error-codes.md` | Error codes + debugging workflows |
| Recovery guide | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling |
| Ready-made scripts | `skills/mono/scripts/*.py` | 13 batch operation scripts (see below) |
<details>
@@ -539,7 +536,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-event/SKILL.md` for the Agent workflow and supported event parameters.
See `skills/multi/dingtalk-misc/references/event.md` for the Agent workflow and supported event parameters.
</details>
@@ -624,7 +621,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
```bash
# Built-in jq expressions
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
dws schema "dev app create" --jq '.tool.required'
dws schema "dev app create" --jq '.parameters'
# Return only specific fields
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
@@ -636,9 +633,9 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
<summary><strong>Schema Introspection</strong> — Agent command discovery and execution contracts</summary>
```bash
dws schema aitable # discover product commands
dws schema "aitable record query" # view the selected leaf contract
dws schema "aitable record query" --jq '.tool.required' # view required fields
dws schema aitable --compact # discover product commands
dws schema "aitable record query" --compact # view the selected Agent leaf contract
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # view required fields
dws schema --all # full export for CI/audit/baselines
```
+11 -14
View File
@@ -71,9 +71,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
| 模式 | 安装内容 | 适合场景 |
|------|----------|----------|
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
| **multi** 🧪 **试验版 / Preview** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。全部独立 skill 均通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
| **multi** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
怎么选:
@@ -365,7 +363,7 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
命令帮助和 Schema 分别负责命令契约的不同部分:
- `dws <path> --help` 是命令是否存在、当前二进制接受哪些 flags 的事实源。
- `dws schema "<path>"` 是 Agent 选命令、参数映射与约束、风险和确认语义的契约。
- `dws schema "<path>" --compact` 是 Agent 选命令、CLI 参数与约束、风险和确认语义的规范视图;映射或 provenance 审计使用 full leaf 配合 `--jq` 精确投影。
- Help 与 Schema 冲突时视为契约漂移:执行只传 Cobra 接受的参数,安全语义取更保守值。
- Schema 只描述命令,不读取或搜索钉钉业务数据;发现命令后仍需执行真实产品命令。
@@ -374,21 +372,21 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
dws aitable record query --help
# 先在产品内发现命令,再查看选中 leaf 的契约
dws schema aitable
dws schema "aitable record query"
dws schema aitable --compact
dws schema "aitable record query" --compact
# 执行真实业务查询
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
```
`dws schema --all` 会完整导出命令契约,供工具、CI、审计和兼容性基线使用。Agent 应优先按产品/分组发现后查询 leaf,避免把整个 Catalog 加载进上下文。
`dws schema --all` 会完整导出命令契约,供工具、CI、审计和兼容性基线使用。Agent 应使用 `--compact` 渐进查询;该视图采用正向字段白名单,full 新增的审计字段不会自动进入 Agent 上下文。
### Agent Skills
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。
Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / ContractFinal 声明驱动。原 `internal/cli/schema_hints/` HintFile 目录已完全退役,不得重新引入。
@@ -437,7 +435,6 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
| 意图指南 | `skills/mono/references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
| 全局参考 | `skills/mono/references/global-reference.md` | 认证、输出格式、全局 flag |
| 错误码 | `skills/mono/references/error-codes.md` | 错误码 + 调试流程 |
| Recovery 指南 | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 |
| 现成脚本 | `skills/mono/scripts/*.py` | 13 个批量操作脚本(见下方) |
<details>
@@ -533,7 +530,7 @@ dws event stop <subscribe_id>
| 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers |
| 跨平台 | macOS/Linux 使用 Unix Socket,Windows 使用 Named Pipe |
Agent 工作流和事件参数详见 `skills/multi/dingtalk-event/SKILL.md`。
Agent 工作流和事件参数详见 `skills/multi/dingtalk-misc/references/event.md`。
</details>
@@ -618,7 +615,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
```bash
# 内置 jq 表达式
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
dws schema "dev app create" --jq '.tool.required'
dws schema "dev app create" --jq '.parameters'
# 只返回指定字段
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
@@ -630,9 +627,9 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
<summary><strong>Schema 自省</strong> — Agent 命令发现与执行契约</summary>
```bash
dws schema aitable # 发现产品命令
dws schema "aitable record query" # 查看选中 leaf 契约
dws schema "aitable record query" --jq '.tool.required' # 查看必填字段
dws schema aitable --compact # 发现产品命令
dws schema "aitable record query" --compact # 查看 Agent leaf 契约
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # 定向查看必填字段
dws schema --all # CI/审计/基线的全量导出
```
-1
View File
@@ -40,7 +40,6 @@
- `internal/output`: response formatting (json, table, raw, pretty)
- `internal/logging`: structured logging and argument sanitization
- `internal/tui`: terminal UI helpers
- `internal/recovery`: panic recovery and graceful degradation
- `pkg/configmeta`: environment variable registry and documentation
- `pkg/config`: configuration constants and paths
- `pkg/edition`: edition detection (oss vs enterprise)
+1 -1
View File
@@ -43,7 +43,7 @@ repository root while preserving repo-local guidance for automation.
- Error message or category issues: inspect `internal/errors`
- Audit log issues: inspect `internal/audit`
- Plugin loading or command surface: inspect `internal/plugin`
- Failure or degraded mode: inspect `internal/errors`, `internal/recovery`
- Failure or degraded mode: inspect `internal/errors`
## Policy Checks
+1 -1
View File
@@ -1,7 +1,7 @@
# dws dev 命令集 · Agent 人肉手工评测集(10 条复合用例)
> 性质:**人肉手工评测集**——由测评人逐条手工跑、肉眼核对、人工判分,不是自动化脚本。
> 用途:评测 agent(加载 `dingtalk-dev` 技能后)能否正确处理开放平台 dev 任务。
> 用途:评测 agent(加载 `dingtalk-misc` 的 `references/devapp.md` 后)能否正确处理开放平台 dev 任务。
> 特点:10 条**复合用例**,每条串多个子任务,一条覆盖一类完整场景;10 条合起来覆盖全部 34 个子命令 + 8 类横切行为。
> 约定:所有命令应带 `--format json`;写操作应先 `--dry-run` 预览、用户确认后再 `--yes`;应用定位只用 `--unified-app-id`。
+3 -3
View File
@@ -6,7 +6,7 @@
## 一键安装
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-dev` skill,**只需要 curl + tar,不需要 git / go / make**。
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-misc` skill(开放平台应用文档落在 misc),**只需要 curl + tar,不需要 git / go / make**。
### macOS / Linux
@@ -24,7 +24,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
1. 从 `DingTalk-Real-AI/dingtalk-workspace-cli` 的最新 Release 下载对应平台的预编译二进制。
2. 安装 `dws` 到默认目录 `~/.local/bin`。
3. 从 Release 的 skills 包里安装 `dingtalk-dev` skill 到本机已检测到的 Agent 目录。
3. 从 Release 的 skills 包里安装 `dingtalk-misc` skill 到本机已检测到的 Agent 目录。
支持这些环境变量(全部可选):
@@ -33,7 +33,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
| `DEVAPP_REPO` | 覆盖发布仓库,默认 `DingTalk-Real-AI/dingtalk-workspace-cli` |
| `DEVAPP_VERSION` | 钉某个 release tag,默认取最新 release |
| `DWS_INSTALL_DIR` | 二进制安装目录,默认 `~/.local/bin` |
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-dev` skill 安装 |
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-misc` skill 安装 |
> `dws dev` 已在正式版里,所以你也可以直接用标准安装脚本 `install.sh`,二者都会带上 `dws dev`。
+11 -10
View File
@@ -94,7 +94,7 @@ With `-f json`, error responses include structured payloads: `category`, `reason
dws contact user search --query "Alice" -f table # Table (default, human-friendly / 表格,默认)
dws contact user search --query "Alice" -f json # JSON (for agents and piping / 适合 agent)
dws contact user search --query "Alice" -f raw # Raw API response / 原始响应
dws schema -f pretty "calendar event create" # Pretty Agent schema view / Agent Schema 彩色查看
dws schema -f pretty "calendar event create" --compact # Pretty Agent schema view / Agent Schema 彩色查看
```
## Dry Run / 试运行
@@ -119,27 +119,28 @@ Schema 的稳定 `canonical_path`、主 CLI 路径和 aliases 收集自命令树
```bash
dws schema # 当前公开产品面的紧凑概览
dws schema calendar # 展开一个产品
dws schema "calendar event" # 展开一个命令分组
dws schema "calendar event create" # 按 CLI 空格路径查询工具
dws schema calendar.create_calendar_event # 按 canonical path 查询工具
dws schema --cli-path "calendar event create" # 显式 CLI path
dws schema "calendar event create" --compact # 支持:省略 provenance/debug 字段
dws schema calendar --compact # Agent 产品视图
dws schema "calendar event" --compact # Agent 分组视图
dws schema "calendar event create" --compact # Agent leaf(CLI 空格路径)
dws schema calendar.create_calendar_event --compact # Agent leaf(canonical path)
dws schema --cli-path "calendar event create" --compact # Agent leaf(显式 CLI path)
dws schema "calendar event create" # full leaf,仅用于映射/provenance 审计
dws schema --all # 全部工具的完整 leaf Schema,用于审计/CI/baseline
```
兼容入口 `dws schema list` 等价于根概览。`schema --all` 是完整导出:每个工具都包含完整 leaf 参数、约束和安全语义。它输出很大,只用于明确要求的全量导出、审计、CI 或参数 baseline;普通 Agent 任务应按概览、产品/分组、leaf 渐进查询,不要把 `--all` 直接注入上下文。`schema --all --compact` 虽受支持,但会裁掉 provenance 和接口映射字段,不能作为完整 baseline。
Leaf 查询、`--all` 中对应工具和 Catalog full tool 均由同一个 resolved `ToolSpec` 投影,内容必须一致;概览、产品/分组和 Catalog summary 也由该 `ToolSpec` 的统一 summary 投影生成。通过 alias 查询时,只允许 `cli_path` 和 `is_alias` 发生视图变化,参数、安全和接口契约不得变化。
省略 `--compact` 的 full leaf、`--all` 中对应工具和 Catalog full tool 均由同一个 resolved `ToolSpec` 投影,内容必须一致;compact leaf 仅做字段白名单投影,不重新解析语义。概览、产品/分组和 Catalog summary 也来自同一 `ToolSpec`。通过 alias 查询时,只允许路径视图发生变化,参数、安全和接口契约不得变化。
`--compact` 是 Schema 的展示选项。当前版本支持该 flag;若兼容旧二进制时收到 `unknown_flag: --compact`,用同一个 Schema 查询去掉 `--compact` 重试。这只降低输出裁剪能力,不表示 leaf 不存在,也不能改用 Schema 查询业务数据。
`--compact` 是 Schema 的稳定 Agent 字段白名单,也是普通 Agent 查询的规范选项。它保留 CLI 参数、组合约束、选择和安全语义,但有意省略 `interface_ref`、参数 `property/interface_type` 与 provenance。检查这些映射/审计字段时,使用 full leaf 并通过 `--jq` / `--fields` 精确投影。若兼容旧二进制时收到 `unknown_flag: --compact`,用同一个 Schema 查询去掉 `--compact` 重试;这只降低输出裁剪能力,不表示 leaf 缺失。
### Schema、Help 与业务数据的边界
| 问题 | 事实源 |
|------|--------|
| 命令是否由当前二进制暴露、Cobra 接受哪些 flags | `dws <path> --help` |
| Agent 选哪个命令、参数映射与组合约束、risk/confirmation | 对应的 leaf `dws schema "<path>"` |
| Agent 选哪个命令、CLI 参数与组合约束、risk/confirmation | 对应的 Agent leaf `dws schema "<path>" --compact` |
| CLI↔RPC 参数映射、接口绑定与 provenance | full leaf 配合 `--jq` / `--fields` 精确投影 |
| 当前钉钉中的文档、文件、日程、消息等业务数据 | 实际执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
Schema 与 Help 冲突表示发布契约漂移,不能静默猜测。执行参数必须以 Cobra 实际接受的 flag 为准;安全语义冲突时采用更保守的处理(例如先确认)或停止执行并报告漂移。完成命令发现后,仍必须执行真实业务命令;`dws schema` 本身不会读取或搜索业务内容。
+1 -1
View File
@@ -6,7 +6,7 @@
## 第一步:安装 dws
一键脚本会自动下载最新版二进制 + `dingtalk-dev` skill,只需要 curl(无需 go / git)。
一键脚本会自动下载最新版二进制 + `dingtalk-misc` skill(开放平台应用文档落在 misc),只需要 curl(无需 go / git)。
### macOS / Linux
+7 -6
View File
@@ -231,7 +231,8 @@ Cobra hard-required 是独立的 executable fact,并通过 `cli_required`/prov
| 问题 | 事实源 |
|---|---|
| 当前二进制是否暴露命令、Cobra 接受哪些 flags | `dws <path> --help` |
| Agent 选哪个命令、参数映射/required/约束、risk/confirmation | 对应 leaf `dws schema "<path>"` |
| Agent 选哪个命令、CLI 参数/required/约束、risk/confirmation | Agent leaf `dws schema "<path>" --compact` |
| CLI↔RPC 参数映射、接口绑定、provenance | full leaf 配合 `--jq` / `--fields` 精确投影 |
| 钉钉中的文档、文件、日程、消息等实际数据 | 真正执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
Schema 和 Help 冲突是契约漂移,不能静默猜测:
@@ -246,10 +247,10 @@ Schema 和 Help 冲突是契约漂移,不能静默猜测:
```bash
dws schema # 产品紧凑概览
dws schema calendar # 产品摘要
dws schema "calendar event" # 分组摘要
dws schema "calendar event create" # 完整 leaf
dws schema "calendar event create" --compact # 支持:裁掉 provenance/debug 字段
dws schema calendar --compact # Agent 产品摘要
dws schema "calendar event" --compact # Agent 分组摘要
dws schema "calendar event create" --compact # Agent leaf
dws schema "calendar event create" # full leaf,仅用于映射/provenance 审计
dws schema --all # 所有工具的完整 leaf 导出
```
@@ -257,7 +258,7 @@ dws schema --all # 所有工具的完整 leaf 导
`schema --all` 必须包含最终 `SchemaIndex` 中每个 tool 的完整 leaf 参数、约束和安全语义;无业务参数的命令也要包含空 `parameters` 对象。它用于审计、CI 和参数防丢 baseline,但输出很大,普通 Agent 命令发现不得使用,应按 overview -> product/group -> leaf 渐进查询。
`--compact` 当前受支持,适合减少常规 leaf 查询上下文。`schema --all --compact` 也可执行,但会移除 provenance/debug 和接口映射字段,不能作为完整兼容性 baseline。
`--compact` 是普通 Agent 查询的规范视图:通过正向字段白名单保留选参、约束与安全语义,full 新增字段不会自动进入 Agent 上下文。省略它的 leaf 包含参数 property、接口绑定和 provenance,只用于定向审计;`schema --all --compact` 也可执行,但不能作为完整兼容性 baseline。
兼容旧二进制时,如果 Schema 查询返回 `unknown_flag: --compact`,只去掉 `--compact` 重试同一个查询。这是展示能力降级,不代表 leaf 缺失,也不能改用 Schema 查询业务数据。
+777 -60
View File
@@ -1,209 +1,926 @@
{
"generated_at": "2026-08-05T22:43:52.497190",
"count": 294,
"generated_at": "2026-08-06T21:08:26.697858",
"count": 357,
"results": [
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+advperm-disable",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "关闭高级权限会使自定义角色失效,显式发布高风险事实。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+advperm-enable",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "开启 Base 高级权限总开关的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+attachment-put",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "完整执行 prepare、HTTP PUT、attachment cell 覆盖和读回验证;空写回包只有最终附件可证明时成功。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+attachment-remove",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "清空或按文件名移除附件;若读回缺剩余 fileToken,会在写前明确停止而不破坏其他附件。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+attachment-upload",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "申请附件 OSS 直传地址和 fileToken;只负责上传准备,不伪造文件已上传。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+base-bootstrap",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "声明式创建 Base、表和字段,字段自动分片,逐层读回验证并报告部分副作用。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+base-copy",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一完整复制与仅结构复制意图,并把目标目录映射为可执行参数。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+base-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "不可逆删除整个 Base 的兼容入口,显式发布高风险确认事实。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+base-get",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "保留 Base 目录读取的 Shortcut 兼容入口;底层 leaf 已提供同等读取能力。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+base-get-primary-doc-id",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "已知 base/table/record 时读取主键文档 dentryUuid 的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+base-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "把最近访问 Base 响应稳定投影为 baseId/baseName 列表,作为未知 baseId 时的浏览入口。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+base-schema-snapshot",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "完整读取 Base、tables、fields、views,逐层区分合法空集合与缺失响应契约。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+base-search",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按名称关键词定位 Base,并稳定投影可继续操作的 baseId/baseName。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+base-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "更新 Base 名称和备注的一对一写入,复用统一确认门。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+chart-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "删除图表及布局项,显式发布不可逆高风险事实。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+chart-get",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取指定图表完整配置的一对一入口。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+chart-share-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取图表分享配置的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+chart-share-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一开启、关闭和设置图表分享类型。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+chart-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一更新图表配置或布局,并要求显式 config 输入。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+chart-widgets-example",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "返回所有图表类型的 widget config 示例,供构造合法图表配置。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+dashboard-arrange",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "触发服务端智能布局重排并保留统一写入确认。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+dashboard-config-example",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "返回 dashboard config 的服务端结构示例,供后续写入构造合法参数。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+dashboard-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "级联删除仪表盘及图表,显式发布不可逆高风险事实。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+dashboard-get",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "读取仪表盘详情及 charts summary,作为图表操作导航入口。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+dashboard-share-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取仪表盘分享配置的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+dashboard-share-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一开启、关闭和设置仪表盘分享类型。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+dashboard-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "更新仪表盘配置的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+export-data",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "统一创建导出任务与按 taskId 续等,直到得到可下载的导出结果或明确失败。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+field-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "不可逆删除字段的一对一入口,显式发布数据丢失风险。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+field-get",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "批量获取字段完整类型配置,避免仅返回名称的损失投影。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+field-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一字段名称、类型配置和 AI 配置的增量更新输入。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+find-record",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "将关键词查询投影为稳定的 recordId/cells 结果,适合先定位再写入。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+form-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "不可逆删除表单视图的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+form-field-hide",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "用隐藏/显示布尔语义切换表单字段可见性。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+form-field-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "列出表单当前字段顺序、可见性、必填和描述配置。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+form-field-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一更新表单字段必填状态与描述。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+form-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "从表的视图中稳定筛出表单视图并返回表单目录。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+form-share-get",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取当前视图分享表单配置的一对一入口。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+form-share-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一开启或关闭指定视图的分享表单。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+form-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一更新表单标题和描述。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+import-data",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一将已上传文件导入新表或追加到已有表的目标语义。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+import-upload",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "申请导入 OSS 直传地址和 importId;只负责上传准备,不伪造导入完成。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+list-tables",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "把 Base 目录稳定投影为 tableId/tableName 列表,隐藏非表节点。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+record-bulk-patch",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "先完整解析显式选择范围和 max-matches,再将同一 cells patch 自动分片写入并逐条读回验证。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+record-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "自动按 100 条分片删除;每批仅在所有目标 recordId 读回均不存在时成功,失败提供 nextOffset。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+record-history-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "按 recordId 查询变更历史的一对一只读入口。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+record-primary-doc-create",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "以幂等意图为 primaryDoc 字段创建关联文档。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+record-primary-doc-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取记录主键文档 nodeId 的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+record-query",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一 ID、筛选、关键词和分页查询,并投影稳定的 records/count/cursor 结果。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+record-query-empty",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "扫描记录并按用户字段为空的语义过滤空行,避免调用方手工判断 cells。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+record-share-links",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "对超过 20 条 recordId 去重分片调用并合并分享链接及逐批结果。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+record-share-url",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "将最多 20 个 recordId 统一映射为可分享链接列表。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+record-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "自动按 100 条分片更新并逐条读回验证;失败返回可续跑的 nextOffset。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+record-upsert",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按 recordId 拆分 create/update,自动按 100 条分片;创建必须返回新 recordId,全部结果读回验证。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+record-upsert-by-key",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "先按唯一 fieldId/value 完整查询,0 条创建、1 条更新、2 条以上停止,并读回验证最终 cells。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+resolve-base",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按名称搜索并要求唯一匹配,0 或多结果时显式失败而不猜 baseId。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+resolve-table",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "在 Base 内按名称要求唯一匹配,0 或多结果时显式失败而不猜 tableId。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+role-create",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一角色名称、成员与权限配置的创建输入。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+role-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "不可逆删除自定义角色的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+role-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取单个角色完整权限配置的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+role-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "列出 Base 全部自定义角色的一对一入口。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+role-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按 PATCH 语义增量更新角色配置,未提供字段保持不变。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+section-create",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "在 Base 目录树创建用于组织表和仪表盘的文件夹。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+section-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "不可逆删除目录文件夹的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+section-list-empty",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "扫描目录树并只返回没有子节点的空文件夹。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+section-list-nodes",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "列出当前版本全部 nsheet 节点,提供表、仪表盘和文件夹统一目录。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+section-move-node",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一移动任意 nsheet 节点到文件夹或 Base 根目录,并可指定位置。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+section-rename",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "重命名目录文件夹的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+section-reorder",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按当前父目录语义调整文件夹展示顺序。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+table-copy",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "在没有服务端 table-copy task 的情况下,本地重建安全字段、映射 fieldId,并可分片复制及读回验证全部记录。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+table-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "不可逆删除数据表的一对一入口,显式发布高风险确认事实。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+table-get",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "批量读取表级信息、字段和视图目录,为后续 Schema 与记录操作提供 ID。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+table-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "统一表名、备注和行命名规则的 PATCH 风格更新。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+template-search",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按名称关键词搜索 AI 表格模板并返回可用于创建的 templateId。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+url-resolve",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "严格解析 alidocs AI 表格 URL 的 base/table/view/record ID,并可通过最深层只读接口验证目标存在。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+view-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "不可逆删除视图的一对一入口,显式发布高风险确认事实。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+view-duplicate",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "复制现有视图配置并生成新视图的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+view-get",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "读取视图完整列顺序、筛选、排序和分组配置。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+view-get-frozen-cols",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取 Grid 视图冻结列数的一对一入口。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+view-get-lock",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取视图锁定状态的一对一入口。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+view-get-row-height",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取视图行高像素值的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+view-lock",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "用默认锁定与 --off 解锁统一表达视图锁状态切换。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+view-preset-apply",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按精确名称幂等创建或更新视图预设,拒绝同名歧义和类型漂移,并读回校验 config。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+view-set-fill-color-rule",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "以全量覆盖语义设置条件填色规则,并用空数组明确清空。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+view-set-frozen-cols",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "设置冻结列数并以 0 明确表达取消冻结。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+view-set-row-height",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "把行高限制为服务支持的 32/56/88/128 像素档位后写入。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+view-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一视图名称、描述、可见列、筛选、排序和分组配置更新。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+workflow-deploy",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "创建或更新完整 workflow-dsl/v1,强制 valid=true/flowId,并可启用后从 list 验证 RUNNING。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+workflow-disable",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "禁用工作流会影响业务自动化,显式发布高风险确认事实。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+workflow-enable",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "启用指定自动化工作流的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+workflow-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取单个自动化工作流详情的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+workflow-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "分页列出 Base 自动化工作流的一对一入口。",
"availability": "available"
},
{
"suite": "read",
+105
View File
@@ -0,0 +1,105 @@
# DWS Skill 内容框架合同
> 本分支权威合同:`skills/mono` / `skills/multi` 的**内容组织**与 zip 内容树形状。
> 不做安装/升级行为约定。质检见 [skill-mono-multi-qa.md](skill-mono-multi-qa.md)。
> 对齐调研:[skill-wukong-align-plan.md](skill-wukong-align-plan.md)。
## 1. 两棵内容树
| 树 | 路径 | 角色 |
|---|---|---|
| **mono**(单 skill) | `skills/mono/` | 单一 `SKILL.md` 入口 + `references/products/*` 产品面 + 全局协议 |
| **multi**(多 skill) | `skills/multi/` | 平铺 `dingtalk-*` 产品 skill + 必选 `dingtalk-shared` |
Agent / 安装面选哪棵树由**行为分支**决定;本文件只规定树内合同。
## 2. Multi 目录合同(如何新增一个产品 skill)
新建 `skills/multi/<name>/` 时必须满足:
1. **命名**
- 产品 skill:`dingtalk-<product>`(小写、连字符)
- 共享 skill:仅允许 `dingtalk-shared`
2. **根文件**
- 必有 `SKILL.md`(YAML frontmatter + 正文)
- `references/` 推荐;无 reference 的 skill(如极简 profile)须在质检 omit 表登记
- `scripts/` 可选;脚本须被本 skill 树内某 `.md` 引用,或进入 orphan allowlist
3. **Frontmatter 最小集**(产品 / shared)
- `name`:与目录名一致
- `description`:非空,含触发意图与边界
- `metadata.category`:`product` 或 `shared`(允许历史写法把 `cli_version` 放在 frontmatter 顶层)
- `metadata.requires.bins`:含 `dws`
4. **契约块**
- 产品 skill 推荐内嵌 `<!-- DWS_RUNTIME_CONTRACT_START -->…END -->` **或** 明确 PREREQUISITE 指向 `dingtalk-shared`
- `dingtalk-shared` 承载跨产品路由与全局协议落点
5. **与 mono 映射**
- 每个 mono `references/products/<stem>`(文件或目录)必须在
`skills/content-qa/mono-multi-coverage.yaml` 有 `coverage` 或 `omit_coverage` 行
### 2.1 推荐骨架
```text
skills/multi/dingtalk-example/
├── SKILL.md
├── references/
│ ├── example.md # 主产品面
│ └── … # 子章节 / 意图表
└── scripts/ # 可选;须被 md 引用
└── example_helper.py
```
## 3. Mono 目录合同(质检对照基准)
```text
skills/mono/
├── SKILL.md
├── references/
│ ├── products/ # 覆盖质检主源
│ ├── error-codes.md # 全局协议示例
│ ├── error-codes.md
│ └── …
└── scripts/
```
- `references/products/` 下每个顶层 stem(`.md` 去后缀或子目录名)计入覆盖索引。
- 同 stem 的 `.md` + 子目录视为同一产品面(如 `doc.md` + `doc/`)。
## 4. 共享内容(`dingtalk-shared`)
| 职责 | 落点 |
|---|---|
| 跨产品路由 / 工作流 | `references/routing.md`、`workflow-routing.md`、`intent-guide.md` |
| 运行时最小契约长文 | `references/runtime-contract.md`(受 context-budget 约束) |
| 全局协议(确认门禁 / Schema 教学等) | `references/`;见质检基线 |
| 与 mono 全局文同名迁移 | `error-codes`、`url-patterns`、`capability-limits`、`channel-login`、`global-reference`、`recipes/`(`conventions.md`、`meta.md`、`lite-catalog.md`) |
产品专属规则(如 AI 表格 `field-rules`)允许下沉到对应 `dingtalk-*`,须在覆盖表注明。
## 5. Zip 内容布局合同(形状,非安装默认)
发布物 `dws-skills.zip`(及 embed 同源)内容树形状:
| Zip 路径 | 含义 |
|---|---|
| `<root>/` | mono 内容副本(兼容旧面) |
| `<root>/mono/` | 与 `skills/mono/` 同构 |
| `<root>/multi/` | 与 `skills/multi/` 同构 |
质检可断言源树形状;**不**断言安装器默认解压哪棵。
## 6. 与悟空 `dingtalk-skills/` 对照(组织概念 only)
| 维度 | DWS `skills/multi` | 悟空 `dingtalk-skills/`(develop) |
|---|---|---|
| 布局 | flat `dingtalk-*` + `dingtalk-shared` | 同构 flat |
| 集合 | 产品 skill + shared(含 event/profile/…;dev/skill 等长尾落在 misc) | 更小产品集(如 attendance/report 独立目录) |
| 质检权威 | **mono 单 skill 树** | 不作为 DWS 覆盖基准 |
| 不移植 | `_install.sh` / bundle / dual / Qwen overlay | — |
悟空独有命名(如 `dingtalk-attendance`)在 DWS 中由 `dingtalk-misc` 承接对应 mono `attendance*` / `report` / `oa` / `sheet` / `dev` 等面——见覆盖表。
## 7. 变更流程
1. 改 / 增内容 → 更新 `skills/content-qa/mono-multi-coverage.yaml`(coverage 或 omit)
2. 跑 `make skill-mono-multi-content`(或 `make policy`)
3. 失败则修内容或更新 reviewed omit(disposition + 原因),**禁止**用安装默认值绕过
+65
View File
@@ -0,0 +1,65 @@
# Mono↔Multi Skill 内容质检规格
> 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。
> 执行:`make skill-mono-multi-content`(已挂入 `make policy`)。
## 1. 质检矩阵
| ID | 类型 | 输入 | 通过准则 |
|---|---|---|---|
| **G1 形状** | 结构 | `skills/multi/*` | 仅 `dingtalk-*`(含必选 `dingtalk-shared`);每目录有 `SKILL.md` |
| **G2 结构** | 结构 | 各 `SKILL.md` frontmatter | `name`==目录名;非空 `description`;`category`∈{product,shared};`requires.bins` 含 `dws` |
| **G3 覆盖** | 覆盖 | mono `references/products/*` 顶层 stem | 每 stem ∈ `coverage` 或 `omit_coverage`;coverage 目标 skill/refs 存在 |
| **G4 漂移** | 漂移 | scripts、成对文件、全局协议 | orphan 脚本 ∈ allowlist;paired 一致;全局协议存在或 ∈ `omit_global` |
已有门禁(继续复用,不替代本矩阵):`check-skill-commands`、`check-skill-context-budget`、`check-multi-im-skill-chain`、`skill_docs_policy`、whiteboard 成对测试。
## 2. 有意省略 / 延期登记格式
YAML(见 coverage 文件):
```yaml
omit_coverage:
- mono: simple
disposition: covered_by # covered_by | defer | wontfix
via: dingtalk-misc # optional
reason: "拆入 oa/devdoc…"
omit_global:
- id: field-rules-global
mono_path: references/field-rules.md
expected_multi: dingtalk-aitable/references/field-rules.md
disposition: covered_by
reason: "AI 表格字段规则已下沉到 dingtalk-aitable;G3/coverage 不强制全局同名"
orphan_scripts_allowlist:
- path: dingtalk-misc/scripts/report_received_today.py
disposition: defer
reason: "pending report.md reference"
```
**处置原则**:质检失败 → 修**内容**或更新 reviewed omit;**不**改安装/升级默认。
## 3. 缺口基线(相对 mono)
| ID | 项 | disposition | 说明 |
|---|---|---|---|
| M1 | recovery-guide / RECOVERY_EVENT_ID 闭环 | **removed** | 已从 mono/multi skill 文档删除;不做移植 |
| M2 | confirmation_required 全局协议 | **done** | `dingtalk-shared/references/confirmation.md` + SKILL 导航 |
| M3 | Schema 渐进查询教学 | **done** | `dingtalk-shared/references/schema-usage.md` |
| M4 | `report_inbox_today.py` | `defer` / orphan 侧 | 验证后迁 misc 或删 |
| M5 | multi LICENSE/NOTICE | `defer` | 内容或打包注入 |
| M6 | aiapp 路由 vs orphan 脚本 | **done(标明未产品化)** | mono 死链移除;`unsupported-scripts.md` |
| X1 | yida/finance/aiapp orphan scripts | **done(登记)** | 由 unsupported-scripts 具名引用 |
| X2 | chat 死链 `extract_media_id.py` | n/a | 现仅为反模式提及 |
| X3 | routing → markdown 错路径 | **done** | 已指 `dingtalk-misc/references/markdown.md`;drive 尾链已修 |
| X4 | event 缺 metadata | **done** | |
| X5 | multi skill 横幅 /「优先 mono」文案 | **done** | 横幅已全部移除 |
| X6 | SAFETY_PREAMBLE_INJECT 无注入器 | **done** | 标记已移除 |
产品面覆盖:见 YAML `coverage`——mono products 均有 multi 承接(misc 聚合 attendance/oa/sheet/…)。
## 4. 与悟空
借鉴 frontmatter / 断链 / requires 等**检查维度**;不运行悟空 bundle zip 校验脚本。覆盖权威始终是 DWS mono。
+272
View File
@@ -0,0 +1,272 @@
# DWS multi-skill **内容框架**对齐方案(相对 dws-wukong develop)
> 状态:**执行中** — Phase 1–3 已落地;M2/M3 已补;**M1 recovery 闭环已从 skill 删除(不做移植)**。
> 合同短文:[skill-content-framework.md](skill-content-framework.md)
> 质检规格:[skill-mono-multi-qa.md](skill-mono-multi-qa.md)
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`
> 门禁:`make skill-mono-multi-content`(已入 `make policy`)
>
> 撰写 / 收窄 / 质检增补 / 执行:2026-08-05
> 工作树:`/Users/john/GolandProjects/open-source/dws-multi-skill-align`
> 分支:`feat/multi-skill-framework-align`(自 `origin/main` @ `a37e6e68`)
> **本分支范围:只做 skill 内容的这个框架**(目录布局、文档契约、共享内容约定、zip 内容树合同、**相对 mono 的内容质检**)。
> **不做**安装/升级引擎、agent-home、脚本 skill-install 行为翻转。
>
> 对照仓:
>
> | 仓 | 路径 | 基线 |
> |---|---|---|
> | DWS OSS CLI(本工作树) | `dws-multi-skill-align` | `origin/main` |
> | dws-wukong | `~/GolandProjects/open-source/dws-wukong` | `origin/develop` @ `ab76629a`(调研时) |
> | 行为参考(**另一分支**) | `dws-skill-mode-migration` @ `402429ac`/`d5c8982c` | 安装默认 multi / upgrade 强制 multi —— **不在本分支排期** |
> | 内容缺口留档(参考) | 同迁移分支 `docs/skill-capability-completion.md`(M1–M6 / X1 等) | **仅作质检目标线索**,非本分支权威 |
---
## 0. TL;DR
1. **本分支 = skill 内容框架 + 相对 mono 的内容质检**:固化 `skills/multi` 组织合同,并用 **mono 单 skill 布局作对照基准**做覆盖/结构/漂移门禁(文档 + CI 内容护栏)。
2. **对齐悟空**:只取内容树组织概念;质检以 **DWS-native** 设计为主(已有 policy/测试可复用)。悟空 `validate-multiskill-bundle.py` 仅借鉴「frontmatter / 断链 / requires」类检查思路,**不**移植 bundle/安装校验。
3. **安装/升级行为**与 `402429ac`/`d5c8982c` → **单独 follow-up 分支**,本方案只登记。
4. 质检 **不改**默认安装哪棵树;只保证 multi 内容相对 mono **可解释、可覆盖、可回归**。
### 0.1 IN SCOPE
| 类别 | 包含 |
|---|---|
| 内容树结构 | `skills/mono/` 与 `skills/multi/<name>/` 目录合同 |
| 单 skill 约定 | `SKILL.md` frontmatter / 契约块 / Golden Route;`references/`;可选 `scripts/` |
| 共享内容 | `dingtalk-shared` 职责与被引用方式;与 mono 全局文映射(文档级) |
| 命名与集合 | `dingtalk-*` + `dingtalk-shared`;相对悟空的共有/独有清单(文档) |
| Zip **内容布局合同** | `mono/` / `multi/` / 根 mono 副本的内容含义与树形状;不改安装默认 |
| **Mono↔multi 内容质检** | 覆盖、结构、漂移三类门禁;复用/扩展现有 policy 与测试;缺口修复属内容编辑(另批或同分支内容 Phase) |
| 内容架构文档 | 本文件 + 可选短文(架构合同 + 质检矩阵) |
### 0.2 OUT OF SCOPE
| 类别 | 去向 |
|---|---|
| 安装默认 multi、upgrade always-multi | Follow-up 分支(`402429ac`/`d5c8982c`) |
| `LocateSkillsRoot` / `skill_setup` / `paths.go` / `skillhome` / install 脚本行为 | 同上 |
| 安装/运行时 manifest、state.json、mode 切换、telemetry header | 拒绝或行为分支 |
| 悟空 `_install.sh` / dual / Qwen / RewindDesktop / pod | 拒绝 |
| 非 skill 内容的 CLI 功能(schema/shortcut 代码等) | 拒绝 |
| 把质检做成「改安装默认值」的后门 | 拒绝 |
---
## 1. 内容现状盘点
### 1.1 DWS `skills/mono`(质检对照基准 · 单 skill)
```text
skills/mono/
├── SKILL.md
├── references/
│ ├── products/<area>.md|…/ # 产品能力面(质检「覆盖」主源)
│ ├── error-codes.md、… # 全局协议(无 recovery 闭环)
│ └── best_practices/…
└── scripts/
```
### 1.2 DWS `skills/multi`(内容主体)
```text
skills/multi/
├── dingtalk-shared/ # 跨产品契约 / routing / 全局协议应落点
└── dingtalk-*/ # 19 产品 + 各 references、scripts
```
仅 DWS 有(悟空无):dev, event, hrbrain, markdown, pat, profile, skill。
### 1.3 悟空 `dingtalk-skills/`(内容组织对照,非质检权威)
Flat `dingtalk-*` + `dingtalk-shared`;单 skill 骨架同构。**不作为 mono 覆盖基准**(集合更小、不同源)。
### 1.4 Zip 内容布局合同
| Zip 路径 | 内容含义 |
|---|---|
| `<root>/` | mono 副本(兼容) |
| `<root>/mono/` | 显式 mono 内容源 |
| `<root>/multi/` | 与 `skills/multi/` 同构 |
质检可断言「源树形状」;**不**断言安装面默认选哪棵。
### 1.5 现有 DWS skill 内容质检资产(复用清单)
| 资产 | 作用 | 与 mono↔multi 质检关系 |
|---|---|---|
| `scripts/policy/check-skill-commands.sh` + `skill-command-check/` | Skill 文内 `dws …` 命令路径存在性 | **复用**(命令真实性);非覆盖映射 |
| `scripts/policy/check-skill-context-budget.sh` | chat/event/mono/`dingtalk-shared` 上下文预算与冷启动约束 | **复用**(结构/预算);可扩展 shared 引用规则 |
| `scripts/policy/check-multi-im-skill-chain.sh` + `multi-im-skill-chain/` | IM 意图单默认路由、retired scripts、handoff | **复用**(chat/event 链);面窄 |
| `test/unit/skill_docs_policy_test.go` | 退役命令、event 扁平输出契约等 | **复用**;可加 mono↔multi 断言 |
| `test/unit/whiteboard_skill_docs_test.go` | mono/multi whiteboard recipes **字节一致** | **样板**:产品面「同源文件」门禁范式 |
| `test/skill_static`(`-tags skill_verify`) | 文内命令 vs Cobra;multi 查 flag | **复用**(opt-in 深度);非 CI 默认全量时可保持 tags |
| `test/skill_e2e` / `test/run_skill_tests.py` | 执行层 / 用例驱动 | **偏行为**;本分支质检默认不依赖 e2e |
| `Makefile` → `policy` 含 context-budget、multi-im-skill-chain;`skill-command-integrity` 独立 | 已有 CI 钩子 | 新门禁优先挂同类 policy / `test/unit` |
**缺口(尚无的门禁)**:系统的「mono `references/products/*` → multi 目录/文」覆盖表;frontmatter 全集完备性;orphan scripts。全局协议中 **确认门禁 / Schema 教学已补**;**recovery 闭环已从 skill 移除(不再作为缺口)**。
### 1.6 悟空侧类比质检
| 悟空 | 说明 | 本分支 |
|---|---|---|
| `scripts/validate-multiskill-bundle.py` | 校验 **已打好的 bundle zip**:frontmatter keys/category、`requires`、markdown 断链、scenario 编排 | **Adapt 思路** → DWS 源树(`skills/multi` + 对照 mono),不跑 zip 安装语义 |
| `sync-monolith-to-multiskill.py` | mono→multi 派生 | **不**作默认质检手段;DWS 直接维护 multi |
结论:**DWS-native mono↔multi 质检**;悟空仅参考检查维度。
---
## 2. Diff(内容组织 + 质检视角)
### 2.1 已同构
Flat `dingtalk-*` + `dingtalk-shared`;`SKILL.md` + `references/`(+ 可选 `scripts/`)。
### 2.2 分叉与已知内容风险(质检要盯的)
| 风险 ID | 现象(线索) | 质检类型 |
|---|---|---|
| **C-cov** | mono `products/*` 能力面在 multi 无对应 skill/reference,或未登记「有意省略」 | 覆盖 |
| **C-struct** | multi 缺 frontmatter 字段、`references/`、`DWS_RUNTIME_CONTRACT`、对 `dingtalk-shared` 引用不一致 | 结构 |
| **C-drift-global** | 曾关注 recovery / 确认 / Schema;现确认与 Schema 已在 `dingtalk-shared`,**recovery skill 文档已删除** | 漂移(协议) |
| **C-drift-orphan** | multi(或 mono)scripts/refs 无文档引用;或 routing 指向无索引产品(留档 X1/M6) | 漂移(孤儿) |
| **C-pair** | 应对齐的成对文件(如 whiteboard recipes)内容不一致 | 漂移(成对) |
### 2.3 Reject
悟空安装包校验整文件照搬、内容集 19→12 砍产品、安装行为门禁冒充内容质检。
---
## 3. Goals / Non-goals
### 3.1 Goals
1. 固化 multi **内容目录合同**与 mono↔multi **映射说明**。
2. 建立 **质检矩阵**(覆盖 / 结构 / 漂移)并以 mono 为对照基准;有意省略必须 reviewed 登记。
3. **复用** §1.5 资产;新增门禁走 `scripts/policy` 或 `test/unit`,内容-only。
4. (可选)纯内容元数据;**禁止**被安装引擎读取改行为。
5. 质检失败 → 修 **内容**或更新「有意省略」表,不改 setup/upgrade。
### 3.2 Non-goals
安装/升级翻转;cherry-pick 行为提交;取消产品;悟空客户端;非 skill CLI 功能;用质检驱动默认 multi 安装。
---
## 4. 分期(内容框架 + 质检 · 均无安装引擎)
> 批准前 **零编码**(含不实现新 gates)。**已执行**:Phase 1–3 见文首状态。
### Phase 0 — 方案冻结(本文)
| | |
|---|---|
| **范围** | 本文件;§7(含质检轨)勾选 |
| **验收** | owner 重新批准 → ✅「现在开始执行」 |
### Phase 1 — Multi 内容目录合同 + 架构短文 ✅
| | |
|---|---|
| **范围** | `skills/multi` 目录合同;与悟空内容树对照表;zip `multi/` 同构合同 |
| **触达** | `docs/skill-content-framework.md` |
| **验收** | 可指导「如何新增 dingtalk-* 内容目录」 |
### Phase 2 — Mono↔multi **内容质检规格**(矩阵 + 缺口基线) ✅
| | |
|---|---|
| **范围** | 质检规格 + 覆盖/omit 机读表 + 缺口 disposition |
| **触达** | `docs/skill-mono-multi-qa.md`、`skills/content-qa/mono-multi-coverage.yaml` |
| **验收** | 矩阵可人工抽查;缺口均有 disposition |
### Phase 3 — 质检落地:CI 内容护栏(复用 + 新 gate) ✅
| | |
|---|---|
| **范围** | G1–G4 自动门禁 |
| **触达** | `test/unit/mono_multi_skill_content_test.go`、`scripts/policy/check-mono-multi-skill-content.sh`、`Makefile` |
| **验收** | `make skill-mono-multi-content` 绿;已知缺口走 reviewed omit |
### Phase 4 — 可选:内容包元数据 + 缺口修复波次
| | |
|---|---|
| **范围 A** | 纯内容 layout/skill 列表元数据(人不读安装器) |
| **范围 B** | 按 Phase 2 disposition **修内容**:确认 / Schema 已补;**recovery skill 文档已删除(wontfix 移植)**;orphan 脚本仍走 allowlist(M4 等) |
| **验收** | 元数据不驱动安装;修复项关闭对应质检失败或转入 omit |
### 延期登记(非本分支)
| 主题 | 载体 |
|---|---|
| 默认 multi + upgrade always-multi | 行为分支 ← `402429ac`/`d5c8982c` |
| skillhome / 安装面 bootstrap | 行为分支 |
---
## 5. Port / Adapt / Reject
| 项 | 决策 | 说明 |
|---|---|---|
| flat + `dingtalk-shared` 内容模型 | **Port** | 已有;合同 + 质检加固 |
| 悟空 bundle frontmatter/断链/requires 检查维度 | **Adapt** | 做成 DWS 源树门禁,不校验 bundle zip/安装 |
| whiteboard 式 mono/multi 成对一致 | **Port(范式)** | 推广到 reviewed 文件对 |
| `validate-multiskill-bundle.py` 整脚本 | **Reject** | 绑定悟空 zip/Qwen 语义 |
| `_install.sh` / dual / overlay | **Reject** | 非内容 |
| 行为 cherry-pick | **Defer** | 另分支 |
---
## 6. 与 `402429ac` / `d5c8982c`
| | |
|---|---|
| 本分支 cherry-pick? | **否** |
| 质检是否替代行为翻转? | **否** |
| 行为分支 | 另开;可与内容/质检并行 |
---
## 7. 批准清单(请重新勾选)
**范围**
- [x] 本分支 = skill **内容**框架 + **mono↔multi 内容质检**(§0.1);无安装/升级引擎
- [x] `402429ac`/`d5c8982c` 及 setup/paths/install 脚本行为 **不在本分支**
- [x] 取消产品与悟空客户端链路仍拒绝
**内容框架 Phase**
- [x] **Phase 1**:multi 目录合同 + 悟空内容树对照短文
**质检轨 Phase**
- [x] **Phase 2**:质检矩阵 + mono↔multi 覆盖/缺口基线规格(先文档,可执行)
- [x] **Phase 3**:CI 内容护栏(G1–G4)—— 本迭代做 / 拆 PR / 只要规格暂不落地
- [x] 质检失败处置原则:修内容或 reviewed omit,**不**改安装默认
**可选**
- [ ] **Phase 4A** 纯内容元数据:做 / 不做 / 以后
- [x] **Phase 4B** recovery skill 文档 **removed/wontfix**;确认/Schema 已补;剩余 orphan(M4 等)仍 defer / allowlist
**Follow-up 知悉**
- [ ] 安装默认 multi + upgrade always-multi → **另一分支**
---
## 8. 下一步
**Phase 1–3 已落地**(合同短文 + 质检规格 + `skills/content-qa` + CI 门禁)。
Phase 4B:recovery 已删除(不做移植);确认/Schema 已补。剩余 defer:orphan scripts(M4 等)、LICENSE/NOTICE(M5)、Phase 4A 元数据。
安装默认 multi 等行为仍走 **另一分支**。
---
*锚点:`skills/mono`、`skills/multi`、§1.5 policy/测试、wukong `dingtalk-skills/`(组织对照 only)。*
+7 -9
View File
@@ -335,12 +335,6 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
edition.Override(&edition.Hooks{ConfigDir: func() string { return "" }})
captureRuntimeFailure(executor.Invocation{}, nil, nil)
captureRuntimeFailure(executor.Invocation{}, errors.New("raw"), nil)
oldArgs := os.Args
os.Args = []string{"dws", "doc", "download", "--node", "n"}
if got := runtimeCommandPath(executor.Invocation{}); len(got) != 2 {
t.Fatalf("runtime command path = %#v", got)
}
os.Args = oldArgs
t.Setenv(authpkg.AgentCodeEnv, "")
if hostControlProviderFromEnv() != "" {
@@ -360,6 +354,10 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
func TestCrossPlatformCoverageConfigAndCacheCommandRemainingCoverage(t *testing.T) {
for _, command := range []*cobra.Command{newConfigCommand(), newCacheCommand()} {
command.SetOut(io.Discard)
rootWrap := &cobra.Command{Use: "dws"}
rootWrap.PersistentFlags().String("format", "json", "")
rootWrap.AddCommand(command)
command.SetOut(io.Discard)
if err := command.RunE(command, nil); err != nil {
t.Fatal(err)
@@ -389,18 +387,18 @@ func TestCrossPlatformCoverageConfigAndCacheCommandRemainingCoverage(t *testing.
for _, format := range []string{"json", "pretty", "table"} {
_ = cacheRoot.PersistentFlags().Set("format", format)
cacheCmd.SetOut(io.Discard)
if err := printCacheCompatNotice(cacheCmd, "status"); err != nil {
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); err != nil {
t.Fatal(err)
}
}
fail := errors.New("write")
cacheCmd.SetOut(appFailWriter{err: fail})
_ = cacheRoot.PersistentFlags().Set("format", "pretty")
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); !errors.Is(err, fail) {
t.Fatalf("pretty write error = %v", err)
}
_ = cacheRoot.PersistentFlags().Set("format", "table")
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); !errors.Is(err, fail) {
t.Fatalf("table write error = %v", err)
}
}
+22 -11
View File
@@ -21,6 +21,10 @@ import (
"github.com/spf13/cobra"
)
const cacheUnsupportedMessage = "dws cache 不再支持:服务发现已下线,当前版本使用编译期静态端点目录;dws cache 仅保留为兼容入口,不会刷新端点。"
const cacheReplacementHint = "如遇 endpoint_not_resolved,请先执行 dws upgrade 获取包含最新 internal/syncdata 端点的版本;仍失败时检查 internal/syncdata.StaticServers() 是否覆盖目标 product/server。"
type cacheCompatNotice struct {
Status string `json:"status"`
Command string `json:"command"`
@@ -28,24 +32,32 @@ type cacheCompatNotice struct {
Replacement string `json:"replacement,omitempty"`
}
// newCacheCommand keeps a visible Deprecated compatibility surface for
// historical argv (refresh/status/clean). Behavior is a successful no-op notice.
// Skills must not teach this path. Deprecated leaves are excluded from Schema
// via cobra.IsAvailableCommand() — do not add schema_command_exclusions entries.
func newCacheCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "cache",
Short: "服务发现缓存兼容入口(静态端点模式已弃用)",
Hidden: true,
Short: "不再支持:服务发现缓存兼容入口",
Long: "此命令组仅为历史 argv 兼容保留。静态端点模式下无需服务发现缓存;Skill / Agent 请勿引导此路径。",
Deprecated: "不再支持;" + cacheUnsupportedMessage,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
return printCacheCompatNotice(cmd, "dws cache")
},
}
for _, name := range []string{"refresh", "status", "clean"} {
subName := name
sub := &cobra.Command{
Use: name,
Short: "已弃用:静态端点模式无需服务发现缓存",
Use: subName,
Short: "不再支持:静态端点模式无需服务发现缓存",
Deprecated: "不再支持;" + cacheUnsupportedMessage,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return printCacheCompatNotice(cmd, name)
return printCacheCompatNotice(cmd, "dws cache "+subName)
},
}
cmd.AddCommand(sub)
@@ -56,9 +68,9 @@ func newCacheCommand() *cobra.Command {
func printCacheCompatNotice(cmd *cobra.Command, command string) error {
notice := cacheCompatNotice{
Status: "deprecated",
Command: "dws cache " + command,
Message: "服务发现已下线,当前版本使用编译期静态端点目录;dws cache 仅保留为兼容入口,不会刷新端点。",
Replacement: "如遇 endpoint_not_resolved,请先执行 dws upgrade 获取包含最新 internal/syncdata 端点的版本;仍失败时检查 internal/syncdata.StaticServers() 是否覆盖目标 product/server。",
Command: command,
Message: cacheUnsupportedMessage,
Replacement: cacheReplacementHint,
}
format, _ := cmd.Root().PersistentFlags().GetString("format")
switch strings.ToLower(strings.TrimSpace(format)) {
@@ -66,8 +78,7 @@ func printCacheCompatNotice(cmd *cobra.Command, command string) error {
return json.NewEncoder(cmd.OutOrStdout()).Encode(notice)
case "pretty":
data, _ := json.MarshalIndent(notice, "", " ")
var err error
_, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
_, err := fmt.Fprintln(cmd.OutOrStdout(), string(data))
return err
default:
_, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n%s\n", notice.Command, notice.Message, notice.Replacement)
+111
View File
@@ -0,0 +1,111 @@
// 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 (
"bytes"
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageCacheDeprecatedCompatShim(t *testing.T) {
root := NewRootCommand()
group := mustFindCommand(t, root, "cache")
if group.Hidden || group.Deprecated == "" || !group.Runnable() {
t.Fatalf("cache group contract: hidden=%v deprecated=%q runnable=%v", group.Hidden, group.Deprecated, group.Runnable())
}
if group.IsAvailableCommand() {
t.Fatal("deprecated cache group must not be IsAvailableCommand")
}
for _, leaf := range []string{"refresh", "status", "clean"} {
cmd := mustFindCommand(t, root, "cache", leaf)
if cmd.Hidden || cmd.Deprecated == "" || !cmd.Runnable() {
t.Fatalf("cache %s contract: hidden=%v deprecated=%q runnable=%v", leaf, cmd.Hidden, cmd.Deprecated, cmd.Runnable())
}
if cmd.IsAvailableCommand() {
t.Fatalf("deprecated cache %s must not be IsAvailableCommand", leaf)
}
}
var out bytes.Buffer
cmd := NewRootCommand()
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"cache", "refresh", "--format", "json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("cache refresh compatibility stub: %v\n%s", err, out.String())
}
got := out.String()
for _, want := range []string{`"status":"deprecated"`, `"command":"dws cache refresh"`, "不再支持", "服务发现已下线"} {
if !strings.Contains(got, want) {
t.Fatalf("cache refresh output missing %q:\n%s", want, got)
}
}
for _, format := range []string{"", "json", "pretty", "table"} {
var buf bytes.Buffer
parent := &cobra.Command{Use: "dws"}
parent.PersistentFlags().String("format", format, "")
parent.SetOut(&buf)
sub := &cobra.Command{Use: "cache"}
parent.AddCommand(sub)
if err := printCacheCompatNotice(sub, "dws cache status"); err != nil {
t.Fatalf("format=%q: %v", format, err)
}
text := buf.String()
if !strings.Contains(text, "不再支持") && !strings.Contains(text, "服务发现已下线") {
t.Fatalf("format=%q missing notice:\n%s", format, text)
}
if format == "" || format == "json" || format == "pretty" {
if !strings.Contains(text, `"status":"deprecated"`) && !strings.Contains(text, `"status": "deprecated"`) {
t.Fatalf("format=%q missing deprecated JSON status:\n%s", format, text)
}
}
}
for _, format := range []string{"pretty", "table"} {
parent := &cobra.Command{Use: "dws"}
parent.PersistentFlags().String("format", format, "")
parent.SetOut(failWriter{})
sub := &cobra.Command{Use: "cache"}
parent.AddCommand(sub)
if err := printCacheCompatNotice(sub, "dws cache clean"); err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("format=%q write failure = %v, want write failed", format, err)
}
}
parent := newCacheCommand()
var parentOut bytes.Buffer
rootWrap := &cobra.Command{Use: "dws"}
rootWrap.PersistentFlags().String("format", "json", "")
rootWrap.SetOut(&parentOut)
rootWrap.AddCommand(parent)
parent.SetOut(&parentOut)
if err := parent.RunE(parent, nil); err != nil {
t.Fatalf("cache parent RunE = %v, want nil success", err)
}
if !strings.Contains(parentOut.String(), `"command":"dws cache"`) {
t.Fatalf("cache parent notice missing command:\n%s", parentOut.String())
}
cache := newCacheCommand()
cache.SetOut(&bytes.Buffer{})
cache.SetArgs([]string{"status"})
if err := cache.Execute(); err != nil {
t.Fatal(err)
}
}
@@ -0,0 +1,301 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
_ "embed"
"encoding/json"
stderrors "errors"
"fmt"
"io"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
//go:embed testdata/shortcut_hallucination_badcases_20260728.json
var shortcutHallucinationBadcases20260728JSON []byte
type shortcutHallucinationReplayCorpus struct {
SourceReport string `json:"source_report"`
SourceFile string `json:"source_file"`
SourceDWSCommit string `json:"source_dws_commit"`
Model string `json:"model"`
ExpectedCount int `json:"expected_count"`
Badcases []shortcutHallucinationReplayBadcase `json:"badcases"`
}
type shortcutHallucinationReplayBadcase struct {
ID string `json:"id"`
CaseID string `json:"case_id"`
Run int `json:"run"`
CommandIndex int `json:"command_index"`
Turn int `json:"turn"`
SourcePath string `json:"source_path"`
ExpectedOutcome string `json:"expected_outcome"`
Raw string `json:"raw"`
OriginalExitCode int `json:"original_exit_code"`
IsHelp bool `json:"is_help"`
}
func TestCrossPlatformCoverageShortcutHallucinationBadcases20260728Replay(t *testing.T) {
var corpus shortcutHallucinationReplayCorpus
if err := json.Unmarshal(shortcutHallucinationBadcases20260728JSON, &corpus); err != nil {
t.Fatalf("decode historical shortcut badcase corpus: %v", err)
}
if corpus.SourceReport != "param_hallucination_20260728_120943" ||
corpus.SourceFile != "raw_dashscope_qwen3_7-max_20260728_120943.json" ||
corpus.SourceDWSCommit != "a8e83e5" || corpus.Model != "dashscope/qwen3.7-max" {
t.Fatalf("unexpected historical corpus provenance: %#v", corpus)
}
if corpus.ExpectedCount != 52 || len(corpus.Badcases) != corpus.ExpectedCount {
t.Fatalf("historical shortcut badcases = %d, expected_count=%d; want 52", len(corpus.Badcases), corpus.ExpectedCount)
}
wantOutcomeCounts := map[string]int{
"rewrite": 8,
"ambiguous": 44,
}
wantSourceCounts := map[string]int{
"chat +group-member-list": 1,
"chat +group-send-text": 1,
"chat +list-group-bots": 1,
"chat +list-robot": 1,
"chat +list-robots": 1,
"chat +members": 3,
"chat +message-list": 1,
"chat +read-single": 6,
"chat +rename-group": 1,
"chat +send": 4,
"chat +send-by-bot": 3,
"chat +send-dm": 2,
"chat +send-file": 15,
"chat +send-image": 3,
"chat +send-media": 2,
"chat +send-message": 3,
"chat +send-single": 1,
"chat +send-text": 2,
"chat +send-to": 1,
}
rewriteTargets := map[string]string{
"chat +members": "chat +group-members",
"chat +group-member-list": "chat +group-members",
"chat +list-group-bots": "chat +chat-bots",
"chat +list-robot": "chat +chat-bots",
"chat +list-robots": "chat +chat-bots",
"chat +rename-group": "chat +chat-update",
}
seenIDs := make(map[string]bool, len(corpus.Badcases))
gotOutcomeCounts := make(map[string]int)
gotSourceCounts := make(map[string]int)
for _, badcase := range corpus.Badcases {
badcase := badcase
t.Run(badcase.ID, func(t *testing.T) {
if badcase.ID == "" || seenIDs[badcase.ID] {
t.Fatalf("missing or duplicate historical badcase id %q", badcase.ID)
}
seenIDs[badcase.ID] = true
gotOutcomeCounts[badcase.ExpectedOutcome]++
gotSourceCounts[badcase.SourcePath]++
args := historicalShortcutBadcaseArgv(t, badcase.Raw)
if len(args) < 2 || strings.Join(args[:2], " ") != badcase.SourcePath {
t.Fatalf("raw argv source = %v, fixture source_path=%q", args, badcase.SourcePath)
}
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
switch badcase.ExpectedOutcome {
case "rewrite":
assertHistoricalShortcutRewrite(t, badcase, rewriteTargets[badcase.SourcePath], ctx, err)
case "ambiguous":
assertHistoricalShortcutReason(t, badcase, ctx, err, "ambiguous_command_fallback")
entry, ok := cli.LookupCommandPathFallback(badcase.SourcePath)
if !ok || entry.Mode != cli.CommandPathFallbackAmbiguous || len(entry.Candidates) < 2 {
t.Fatalf("ambiguous fallback table entry = %#v, %v", entry, ok)
}
case "unknown_shortcut":
assertHistoricalShortcutReason(t, badcase, ctx, err, "unknown_shortcut")
if entry, ok := cli.LookupCommandPathFallback(badcase.SourcePath); ok {
t.Fatalf("contract-incomplete path unexpectedly entered fallback table: %#v", entry)
}
default:
t.Fatalf("unsupported expected_outcome %q", badcase.ExpectedOutcome)
}
})
}
if !reflect.DeepEqual(gotOutcomeCounts, wantOutcomeCounts) {
t.Errorf("historical outcome counts = %v, want %v", gotOutcomeCounts, wantOutcomeCounts)
}
if !reflect.DeepEqual(gotSourceCounts, wantSourceCounts) {
t.Errorf("historical source counts = %v, want %v", gotSourceCounts, wantSourceCounts)
}
}
func TestCrossPlatformCoverageShortcutHallucinationMerged20260720Replay(t *testing.T) {
tests := []struct {
id string
raw string
occurrences int
outcome string
target string
}{
{
id: "dws_im_v2_0013-search-group",
raw: `dws chat +search-group --name "dws测试群02" --format json`,
occurrences: 1,
outcome: "official_alias",
target: "chat +chat-search",
},
{
id: "dws_oa_0017-list-processes",
raw: `dws oa +list-processes --format json --limit 50`,
occurrences: 2,
outcome: "ambiguous",
},
}
totalOccurrences := 0
for _, test := range tests {
test := test
t.Run(test.id, func(t *testing.T) {
totalOccurrences += test.occurrences
args := historicalShortcutBadcaseArgv(t, test.raw)
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
badcase := shortcutHallucinationReplayBadcase{
ID: test.id,
SourcePath: strings.Join(args[:2], " "),
ExpectedOutcome: test.outcome,
Raw: test.raw,
}
switch test.outcome {
case "rewrite":
assertHistoricalShortcutRewrite(t, badcase, test.target, ctx, err)
case "official_alias":
assertHistoricalOfficialAlias(t, badcase, test.target, ctx, err)
case "ambiguous":
assertHistoricalShortcutReason(t, badcase, ctx, err, "ambiguous_command_fallback")
default:
t.Fatalf("unsupported merged expected outcome %q", test.outcome)
}
})
}
if totalOccurrences != 3 {
t.Fatalf("merged shortcut hallucination occurrences = %d, want 3", totalOccurrences)
}
}
func assertHistoricalOfficialAlias(
t *testing.T,
badcase shortcutHallucinationReplayBadcase,
wantTarget string,
ctx *pipeline.Context,
err error,
) {
t.Helper()
if err != nil {
t.Fatalf("historical official alias %q returned error: %v", badcase.SourcePath, err)
}
if wantTarget == "" || ctx == nil || ctx.Command != "dws "+wantTarget {
t.Fatalf("historical official alias context = %#v; want command dws %s", ctx, wantTarget)
}
for _, correction := range ctx.Corrections {
if correction.Handler == "command-path-fallback" {
t.Fatalf("historical official alias received fallback correction: %#v", ctx.Corrections)
}
}
if entry, ok := cli.LookupCommandPathFallback(badcase.SourcePath); ok {
t.Fatalf("official alias unexpectedly remains in fallback table: %#v", entry)
}
}
func historicalShortcutBadcaseArgv(t *testing.T, raw string) []string {
t.Helper()
command := strings.TrimSpace(raw)
for _, suffix := range []string{" 2>&1 | head -50", " 2>&1"} {
command = strings.TrimSuffix(command, suffix)
}
argv, err := cli.ParseAgentExampleArgv(command)
if err != nil {
t.Fatalf("parse historical raw command %q without a shell: %v", raw, err)
}
if len(argv) < 3 || argv[0] != "dws" {
t.Fatalf("historical raw command did not produce dws argv: %q => %v", raw, argv)
}
return append([]string(nil), argv[1:]...)
}
func assertHistoricalShortcutRewrite(
t *testing.T,
badcase shortcutHallucinationReplayBadcase,
wantTarget string,
ctx *pipeline.Context,
err error,
) {
t.Helper()
if wantTarget == "" {
t.Fatalf("historical rewrite %q has no reviewed target", badcase.SourcePath)
}
if ctx == nil || ctx.Command != "dws "+wantTarget {
t.Fatalf("historical rewrite context = %#v, error=%v; want command dws %s", ctx, err, wantTarget)
}
found := false
for _, correction := range ctx.Corrections {
if correction.Handler == "command-path-fallback" && correction.Original == badcase.SourcePath && correction.Corrected == wantTarget {
found = true
break
}
}
if !found {
t.Fatalf("historical rewrite corrections = %#v; want %q -> %q", ctx.Corrections, badcase.SourcePath, wantTarget)
}
wantPrefix := strings.Fields(wantTarget)
if len(ctx.Args) < len(wantPrefix) || !reflect.DeepEqual(ctx.Args[:len(wantPrefix)], wantPrefix) {
t.Fatalf("historical rewrite argv = %v, want prefix %v", ctx.Args, wantPrefix)
}
if reason := structuredShortcutReplayReason(err); reason == "unknown_shortcut" || reason == "ambiguous_command_fallback" {
t.Fatalf("historical rewrite returned command-resolution reason %q: %v", reason, err)
}
}
func assertHistoricalShortcutReason(
t *testing.T,
badcase shortcutHallucinationReplayBadcase,
ctx *pipeline.Context,
err error,
wantReason string,
) {
t.Helper()
if ctx == nil {
t.Fatalf("historical %s badcase returned nil context: %v", wantReason, err)
}
if got := structuredShortcutReplayReason(err); got != wantReason {
t.Fatalf("historical badcase %q reason = %q, error=%v; want %q (original exit=%d help=%v)", badcase.Raw, got, err, wantReason, badcase.OriginalExitCode, badcase.IsHelp)
}
if strings.Contains(strings.ToLower(fmt.Sprint(err)), "unknown flag") {
t.Fatalf("historical badcase %q regressed to unknown flag: %v", badcase.Raw, err)
}
}
func structuredShortcutReplayReason(err error) string {
if err == nil {
return ""
}
var structured *apperrors.Error
if stderrors.As(err, &structured) {
return structured.Reason
}
return ""
}
@@ -0,0 +1,603 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"bytes"
"context"
stderrors "errors"
"io"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
const (
runtimeShortcutFallbackChildEnv = "DWS_RUNTIME_SHORTCUT_FALLBACK_CHILD"
runtimeShortcutFallbackConfigEnv = "DWS_RUNTIME_SHORTCUT_FALLBACK_CONFIG_DIR"
)
func TestCrossPlatformCoverageRuntimeUserShortcutTakesPrecedenceOverReviewedFallback(t *testing.T) {
if os.Getenv(runtimeShortcutFallbackChildEnv) == "1" {
t.Setenv("DWS_CONFIG_DIR", os.Getenv(runtimeShortcutFallbackConfigEnv))
engine := newPipelineEngine()
root := NewRootCommandWithEngine(context.Background(), engine)
runtimeCommand := exactAppCommand(root, "chat +members")
if runtimeCommand == nil || !runtimeCommand.Runnable() || cmdutil.IsHintOnlyCommand(runtimeCommand) {
var userDefined []string
for _, candidate := range shortcut.All() {
if candidate.UserDefined {
userDefined = append(userDefined, candidate.Service+" "+candidate.Command)
}
}
t.Fatalf("runtime shortcut was not mounted as an executable command: command=%#v user_defined=%v config=%q", runtimeCommand, userDefined, os.Getenv("DWS_CONFIG_DIR"))
}
root.SetOut(io.Discard)
root.SetErr(io.Discard)
args := []string{"chat", "+members", "--sentinel", "fixture", "--mock", "--format", "json"}
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, engine, args)
if err != nil {
t.Fatal(err)
}
if ctx == nil || ctx.Command != "dws chat +members" {
t.Fatalf("runtime shortcut context = %#v", ctx)
}
assertNoCommandPathFallbackCorrection(t, ctx)
if err := root.Execute(); err != nil {
t.Fatalf("runtime shortcut execute: %v", err)
}
return
}
configDir := t.TempDir()
shortcutDir := filepath.Join(configDir, "shortcuts")
if err := os.MkdirAll(shortcutDir, 0o700); err != nil {
t.Fatal(err)
}
definition := `version: 1
service: chat
command: "+members"
product: chat
description: runtime members fixture
execute:
tool: fixture_user_members
bind:
sentinel: "${sentinel}"
flags:
- name: sentinel
type: string
required: true
desc: fixture sentinel
`
if err := os.WriteFile(filepath.Join(shortcutDir, "chat.members.yaml"), []byte(definition), 0o600); err != nil {
t.Fatal(err)
}
command := exec.Command(os.Args[0], "-test.run=^TestCrossPlatformCoverageRuntimeUserShortcutTakesPrecedenceOverReviewedFallback$", "-test.count=1")
command.Env = append(os.Environ(), runtimeShortcutFallbackConfigEnv+"="+configDir, runtimeShortcutFallbackChildEnv+"=1")
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("runtime shortcut child failed: %v\n%s", err, strings.TrimSpace(string(output)))
}
if !strings.Contains(string(output), `"_tool": "fixture_user_members"`) {
t.Fatalf("runtime shortcut child used the wrong command:\n%s", strings.TrimSpace(string(output)))
}
}
func TestCrossPlatformCoverageReviewedCommandFallbacksReachCanonicalDryRunPayload(t *testing.T) {
_, canonicalQuery, canonicalQueryAttempts, err := executeParamAliasDryRunE2E(t,
"chat", "+chat-search", "--query", "project", "--dry-run",
)
if err != nil || len(canonicalQueryAttempts) != 0 {
t.Fatalf("canonical query preview = %#v, attempts=%v, error=%v", canonicalQuery, canonicalQueryAttempts, err)
}
tests := []struct {
name string
args []string
wantCommand string
wantPreview paramAliasDryRunPreview
}{
{
name: "group search query",
args: []string{"chat", "+group-search", "--query", "project", "--dry-run"},
wantCommand: "dws chat +chat-search",
wantPreview: canonicalQuery,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctx, preview, attempts, executeErr := executeParamAliasDryRunE2E(t, test.args...)
if executeErr != nil {
t.Fatalf("fallback execute error = %v", executeErr)
}
if len(attempts) != 0 {
t.Fatalf("fallback crossed dry-run dispatch boundary: %#v", attempts)
}
if !reflect.DeepEqual(preview, test.wantPreview) {
t.Fatalf("fallback preview = %#v, want canonical %#v", preview, test.wantPreview)
}
if ctx == nil || ctx.Command != test.wantCommand || len(ctx.Corrections) == 0 {
t.Fatalf("fallback context = %#v, want command %q", ctx, test.wantCommand)
}
correction := ctx.Corrections[0]
if correction.Handler != "command-path-fallback" || correction.Kind != "reviewed-fallback" {
t.Fatalf("fallback correction = %#v", correction)
}
})
}
}
func TestCrossPlatformCoverageOfficialCommandAliasesBypassFallbackAndReachEquivalentPayload(t *testing.T) {
_, canonicalQuery, canonicalQueryAttempts, err := executeParamAliasDryRunE2E(t,
"chat", "+chat-search", "--query", "project", "--dry-run",
)
if err != nil || len(canonicalQueryAttempts) != 0 {
t.Fatalf("canonical query preview = %#v, attempts=%v, error=%v", canonicalQuery, canonicalQueryAttempts, err)
}
_, canonicalKeyword, canonicalKeywordAttempts, err := executeParamAliasDryRunE2E(t,
"chat", "+chat-search", "--keyword", "project", "--dry-run",
)
if err != nil || len(canonicalKeywordAttempts) != 0 {
t.Fatalf("canonical keyword preview = %#v, attempts=%v, error=%v", canonicalKeyword, canonicalKeywordAttempts, err)
}
_, nativeCanonical, nativeCanonicalAttempts, err := executeParamAliasDryRunE2E(t,
"chat", "search", "--query", "project", "--dry-run",
)
if err != nil || len(nativeCanonicalAttempts) != 0 {
t.Fatalf("native canonical preview = %#v, attempts=%v, error=%v", nativeCanonical, nativeCanonicalAttempts, err)
}
tests := []struct {
name string
args []string
wantCommand string
wantPreview paramAliasDryRunPreview
}{
{
name: "search group shortcut alias",
args: []string{"chat", "+search-group", "--keyword", "project", "--dry-run"},
wantCommand: "dws chat +chat-search",
wantPreview: canonicalKeyword,
},
{
name: "chat group search shortcut alias",
args: []string{"chat", "+chat-group-search", "--query", "project", "--dry-run"},
wantCommand: "dws chat +chat-search",
wantPreview: canonicalQuery,
},
{
name: "hidden native compatibility leaf",
args: []string{"chat", "group", "search", "--query", "project", "--dry-run"},
wantCommand: "dws chat group search",
wantPreview: nativeCanonical,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctx, preview, attempts, executeErr := executeParamAliasDryRunE2E(t, test.args...)
if executeErr != nil {
t.Fatalf("official alias execute error = %v", executeErr)
}
if len(attempts) != 0 {
t.Fatalf("official alias crossed dry-run dispatch boundary: %#v", attempts)
}
if !reflect.DeepEqual(preview, test.wantPreview) {
t.Fatalf("official alias preview = %#v, want canonical %#v", preview, test.wantPreview)
}
if ctx == nil || ctx.Command != test.wantCommand {
t.Fatalf("official alias context = %#v, want command %q", ctx, test.wantCommand)
}
assertNoCommandPathFallbackCorrection(t, ctx)
})
}
}
func TestCrossPlatformCoverageReviewedReadFallbacksResolveCanonicalLeafBeforeParameterValidation(t *testing.T) {
tests := []struct {
name string
args []string
target string
}{
{
name: "members",
args: []string{"chat", "+members", "--group", "Fixture Group"},
target: "chat +group-members",
},
{
name: "group member list",
args: []string{"chat", "+group-member-list", "--group-name", "Fixture Group"},
target: "chat +group-members",
},
{
name: "list group bots",
args: []string{"chat", "+list-group-bots", "--group", "cid-fixture"},
target: "chat +chat-bots",
},
{
name: "list robot",
args: []string{"chat", "+list-robot", "--group", "cid-fixture"},
target: "chat +chat-bots",
},
{
name: "list robots",
args: []string{"chat", "+list-robots", "--group", "cid-fixture"},
target: "chat +chat-bots",
},
{
name: "bot list",
args: []string{"chat", "+bot-list", "--group", "cid-fixture"},
target: "chat +chat-bots",
},
{
name: "conversation detail",
args: []string{"chat", "+conversation-detail", "--group", "cid-fixture"},
target: "chat +conversation-info",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(test.args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
if err != nil {
t.Fatal(err)
}
if ctx == nil || ctx.Command != "dws "+test.target || len(ctx.Corrections) == 0 {
t.Fatalf("read fallback context = %#v", ctx)
}
wantPrefix := strings.Fields(test.target)
if len(ctx.Args) < len(wantPrefix) || !reflect.DeepEqual(ctx.Args[:len(wantPrefix)], wantPrefix) {
t.Fatalf("read fallback args = %v, want prefix %v", ctx.Args, wantPrefix)
}
})
}
}
func TestCrossPlatformCoverageOfficialGroupMembersAliasBypassesCommandFallback(t *testing.T) {
args := []string{"chat", "+chat-group-members", "--conversation-id", "cid-fixture", "--member-types", "user"}
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err != nil {
t.Fatal(err)
}
if ctx == nil || ctx.Command != "dws chat +chat-members-list" {
t.Fatalf("official group-members alias context = %#v", ctx)
}
assertNoCommandPathFallbackCorrection(t, ctx)
}
func TestCrossPlatformCoverageReviewedRenameFallbackResolvesCanonicalLeafBeforeParameterValidation(t *testing.T) {
args := []string{"chat", "+rename-group", "--id", "cid-fixture", "--name", "Fixture Group"}
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err != nil {
t.Fatal(err)
}
if ctx == nil || ctx.Command != "dws chat +chat-update" || len(ctx.Corrections) == 0 {
t.Fatalf("rename fallback context = %#v", ctx)
}
wantPrefix := []string{"chat", "+chat-update"}
if len(ctx.Args) < len(wantPrefix) || !reflect.DeepEqual(ctx.Args[:len(wantPrefix)], wantPrefix) {
t.Fatalf("rename fallback args = %v, want prefix %v", ctx.Args, wantPrefix)
}
}
func TestCrossPlatformCoverageReviewedDocHistorySaveFallbacksPreserveArguments(t *testing.T) {
sources := []string{"+create-version", "+save-version", "+snapshot", "+version-create"}
for _, source := range sources {
t.Run(source, func(t *testing.T) {
args := []string{"doc", source, "--node", "node-fixture", "--mock", "--format", "json"}
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err != nil {
t.Fatal(err)
}
if ctx == nil || ctx.Command != "dws doc +history-save" || len(ctx.Corrections) == 0 {
t.Fatalf("history-save fallback context = %#v", ctx)
}
wantArgs := []string{"doc", "+history-save", "--node", "node-fixture", "--mock", "--format", "json"}
if !reflect.DeepEqual(ctx.Args, wantArgs) {
t.Fatalf("history-save fallback args = %v, want %v", ctx.Args, wantArgs)
}
correction := ctx.Corrections[0]
if correction.Handler != "command-path-fallback" || correction.Kind != "reviewed-fallback" {
t.Fatalf("history-save correction = %#v", correction)
}
})
}
}
func TestCrossPlatformCoverageDocExportPDFRemainsUnknownShortcut(t *testing.T) {
if entry, ok := cli.LookupCommandPathFallback("doc +export-pdf"); ok {
t.Fatalf("+export-pdf must not receive a path-only fallback: %#v", entry)
}
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
args := []string{"doc", "+export-pdf", "--node", "node-fixture", "--mock", "--format", "json"}
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if ctx == nil {
t.Fatal("+export-pdf returned nil context")
}
var structured *apperrors.Error
if !stderrors.As(err, &structured) || structured.Reason != "unknown_shortcut" {
t.Fatalf("+export-pdf preparse error = %T %#v", err, err)
}
if len(ctx.Corrections) != 0 {
t.Fatalf("+export-pdf unexpectedly received corrections: %#v", ctx.Corrections)
}
}
func TestCrossPlatformCoverageReviewedAmbiguousCommandFallbackNeverDispatches(t *testing.T) {
tests := []struct {
path string
candidates []string
}{
{path: "chat +group-send-text", candidates: []string{"chat +send-to-group", "chat +messages-send"}},
{path: "chat +message-list", candidates: []string{"chat +chat-messages", "chat +messages-list-direct", "chat +search-msg", "chat +unread-chats"}},
{path: "chat +read-single", candidates: []string{"chat +messages-list-direct", "chat +chat-messages"}},
{path: "chat +send", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
{path: "chat +send-by-bot", candidates: []string{"chat +messages-send", "chat message send-by-bot"}},
{path: "chat +send-dm", candidates: []string{"chat +dm", "chat +messages-send"}},
{path: "chat +send-message", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
{path: "chat +send-single", candidates: []string{"chat +dm", "chat +messages-send"}},
{path: "chat +send-text", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
{path: "chat +send-to", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
{path: "chat +send-file", candidates: []string{"chat +messages-send", "chat message send"}},
{path: "chat +send-image", candidates: []string{"chat +messages-send", "chat message send"}},
{path: "chat +send-media", candidates: []string{"chat +messages-send", "chat message send"}},
{path: "chat +conversation-category-list", candidates: []string{"chat +category-list", "chat +category-list-conversations"}},
{path: "chat +conversation-group-list", candidates: []string{"chat +category-list-conversations", "chat +conversation-list"}},
{path: "chat +list-my-groups", candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"}},
{path: "oa +list-processes", candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}},
}
for _, test := range tests {
t.Run(test.path, func(t *testing.T) {
caller := &paramAliasCaptureCaller{}
args := append(strings.Fields(test.path), "--format", "json")
ctx, err := executeParamAliasE2E(t, caller, args...)
if ctx == nil {
t.Fatal("ambiguous command fallback returned nil context")
}
var structured *apperrors.Error
if !stderrors.As(err, &structured) || structured.Reason != "ambiguous_command_fallback" || structured.ExitCode() != 3 {
t.Fatalf("ambiguous error = %T %#v", err, err)
}
if len(structured.Actions) != len(test.candidates) || len(caller.calls) != 0 {
t.Fatalf("ambiguous actions=%v calls=%#v", structured.Actions, caller.calls)
}
for _, candidate := range test.candidates {
if !strings.Contains(structured.Hint, "dws "+candidate) {
t.Errorf("ambiguous hint %q missing candidate %q", structured.Hint, candidate)
}
}
})
}
}
func TestCrossPlatformCoverageCanonicalShortcutBadFlagDoesNotEnterCommandFallback(t *testing.T) {
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
args := []string{"chat", "+chat-search", "--definitely-not-a-real-flag", "project"}
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err != nil {
t.Fatalf("preparse error = %v", err)
}
if ctx == nil || ctx.Command != "dws chat +chat-search" {
t.Fatalf("canonical context = %#v", ctx)
}
for _, correction := range ctx.Corrections {
if correction.Handler == "command-path-fallback" {
t.Fatalf("canonical command received fallback correction: %#v", ctx.Corrections)
}
}
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "unknown flag") {
t.Fatalf("canonical bad flag error = %v", err)
}
}
func TestCrossPlatformCoverageRewrittenShortcutStillUsesCanonicalParameterErrors(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{
name: "missing required query",
args: []string{"chat", "+group-search", "--dry-run"},
want: "请至少指定 --query、--keyword 之一",
},
{
name: "unknown canonical flag",
args: []string{"chat", "+group-search", "--definitely-not-a-real-flag", "project"},
want: "unknown flag",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(test.args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
if err != nil {
t.Fatalf("preparse error = %v", err)
}
if ctx == nil || ctx.Command != "dws chat +chat-search" || len(ctx.Corrections) == 0 ||
ctx.Corrections[0].Handler != "command-path-fallback" {
t.Fatalf("rewritten context = %#v", ctx)
}
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("canonical parameter error = %v, want containing %q", err, test.want)
}
})
}
}
func TestCrossPlatformCoverageCommandFallbackNamesStayOutOfHelpSchemaAndShortcutCatalog(t *testing.T) {
invalidShortcuts := map[string]bool{
"+bot-list": true,
"+conversation-detail": true,
"+conversation-category-list": true,
"+conversation-group-list": true,
"+list-my-groups": true,
"+group-search": true,
"+members": true,
"+group-member-list": true,
"+list-group-bots": true,
"+list-robot": true,
"+list-robots": true,
"+message-list": true,
"+read-single": true,
"+rename-group": true,
"+send": true,
"+send-by-bot": true,
"+send-dm": true,
"+send-message": true,
"+send-single": true,
"+send-text": true,
"+send-to": true,
"+send-file": true,
"+send-image": true,
"+send-media": true,
"+group-send-text": true,
}
root := NewSchemaSourceRootCommand()
chat := exactAppCommand(root, "chat")
if chat == nil {
t.Fatal("chat command missing")
}
for _, child := range chat.Commands() {
if invalidShortcuts[child.Name()] {
t.Fatalf("fallback name %q became a real command", child.Name())
}
for _, alias := range child.Aliases {
if invalidShortcuts[alias] {
t.Fatalf("fallback name %q became a Cobra alias", alias)
}
}
}
for path := range invalidShortcuts {
if _, ok := cli.ResolveMeta("chat " + path); ok {
t.Fatalf("fallback path %q leaked into embedded Schema", path)
}
}
for _, declared := range shortcut.All() {
if declared.Service == "chat" && invalidShortcuts[declared.Command] {
t.Fatalf("fallback name %q leaked into shortcut catalog", declared.Command)
}
}
if _, ok := cli.ResolveMeta("oa +list-processes"); ok {
t.Fatal("fallback path oa +list-processes leaked into embedded Schema")
}
for _, declared := range shortcut.All() {
if declared.Service == "oa" && declared.Command == "+list-processes" {
t.Fatal("fallback name +list-processes leaked into shortcut catalog")
}
}
group := exactAppCommand(root, "chat group")
searchCompatibility := exactAppCommand(root, "chat group search")
if group == nil || searchCompatibility == nil || !searchCompatibility.Hidden || cmdutil.IsHintOnlyCommand(searchCompatibility) {
t.Fatalf("chat group search must be a hidden executable compatibility leaf: group=%v search=%v", group, searchCompatibility)
}
var help bytes.Buffer
group.SetOut(&help)
if err := group.Help(); err != nil {
t.Fatal(err)
}
if strings.Contains(help.String(), "\n search ") {
t.Fatalf("hidden fallback source leaked into group help:\n%s", help.String())
}
}
func TestCrossPlatformCoverageDocCommandFallbackNamesStayOutOfHelpSchemaAndShortcutCatalog(t *testing.T) {
invalidShortcuts := map[string]bool{
"+create-version": true,
"+save-version": true,
"+snapshot": true,
"+version-create": true,
"+export-pdf": true,
}
root := NewSchemaSourceRootCommand()
doc := exactAppCommand(root, "doc")
if doc == nil {
t.Fatal("doc command missing")
}
for _, child := range doc.Commands() {
if invalidShortcuts[child.Name()] {
t.Fatalf("invalid shortcut %q became a real command", child.Name())
}
for _, alias := range child.Aliases {
if invalidShortcuts[alias] {
t.Fatalf("invalid shortcut %q became a Cobra alias", alias)
}
}
}
for path := range invalidShortcuts {
if _, ok := cli.ResolveMeta("doc " + path); ok {
t.Fatalf("invalid path %q leaked into embedded Schema", path)
}
}
for _, declared := range shortcut.All() {
if declared.Service == "doc" && invalidShortcuts[declared.Command] {
t.Fatalf("invalid shortcut %q leaked into shortcut catalog", declared.Command)
}
}
}
func assertNoCommandPathFallbackCorrection(t *testing.T, ctx *pipeline.Context) {
t.Helper()
for _, correction := range ctx.Corrections {
if correction.Handler == "command-path-fallback" {
t.Fatalf("official command unexpectedly received fallback correction: %#v", ctx.Corrections)
}
}
}
func exactAppCommand(root *cobra.Command, path string) *cobra.Command {
current := root
parts := strings.Fields(path)
if len(parts) > 0 && parts[0] == root.Name() {
parts = parts[1:]
}
for _, part := range parts {
var next *cobra.Command
for _, child := range current.Commands() {
if child.Name() == part {
next = child
break
}
}
if next == nil {
return nil
}
current = next
}
return current
}
+7 -173
View File
@@ -32,7 +32,6 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
@@ -227,115 +226,6 @@ func TestCrossPlatformCoverageDocDownloadPureCoverage(t *testing.T) {
}
}
func TestCrossPlatformCoverageRecoveryPureCoverage(t *testing.T) {
if _, err := decodeRecoveryAttempts(nil, nil, "", ""); err != nil {
t.Fatal(err)
}
if _, err := decodeRecoveryAttempts(json.RawMessage("null"), nil, "", ""); err != nil {
t.Fatal(err)
}
if got, err := decodeRecoveryAttempts(json.RawMessage(`[{"command_summary":"one"}]`), nil, "", ""); err != nil || len(got) != 1 {
t.Fatalf("array attempts = %#v %v", got, err)
}
if _, err := decodeRecoveryAttempts(json.RawMessage("{"), nil, "", ""); err == nil {
t.Fatal("malformed attempts succeeded")
}
if got, err := decodeRecoveryAttempts(json.RawMessage("2"), []string{"a"}, "ok", ""); err != nil || len(got) != 2 {
t.Fatalf("legacy attempts = %#v %v", got, err)
}
if legacyRecoveryAttempts(0, nil, "", "") != nil || len(legacyRecoveryAttempts(1, nil, "", "")) != 1 {
t.Fatal("legacy attempts edge mismatch")
}
for _, args := range [][]string{
{"dws", "--debug", "doc", "get", "--node", "n"},
{"dws", "--format=json", "doc", "--", "ignored"},
{"dws", "--unknown", "value", "doc"},
} {
old := os.Args
os.Args = args
_ = currentCommandPath()
os.Args = old
}
for _, inv := range []executor.Invocation{
{LegacyPath: "legacy path"},
{CanonicalProduct: "doc", Tool: "get"},
{CanonicalProduct: "doc"},
{},
} {
old := os.Args
os.Args = []string{"dws"}
_ = runtimeCommandPath(inv)
os.Args = old
}
if cloneRecoveryArgs(nil) != nil {
t.Fatal("empty recovery args should clone to nil")
}
original := map[string]any{"x": 1}
clone := cloneRecoveryArgs(original)
clone["x"] = 2
if original["x"] != 1 {
t.Fatal("recovery args were not cloned")
}
if got, _ := (*recoveryRuntime)(nil).Search(context.Background(), "query", recovery.RecoveryContext{}); got.DocSearch.Status != "skipped" {
t.Fatalf("nil recovery search = %#v", got)
}
if got, _ := (&recoveryRuntime{}).Search(context.Background(), " ", recovery.RecoveryContext{}); got.DocSearch.Status != "skipped" {
t.Fatalf("blank recovery search = %#v", got)
}
if _, err := (*recoveryRuntime)(nil).CallToolDirect(context.Background(), "x", "y", nil); err == nil {
t.Fatal("nil recovery runtime call succeeded")
}
if _, err := (&recoveryRuntime{}).resolveEndpoint(context.Background(), "missing", "tool"); err == nil || !strings.Contains(err.Error(), `endpoint not resolved for product "missing" (tool "tool")`) {
t.Fatalf("missing recovery endpoint error = %v", err)
} else {
var apiErr *apperrors.Error
if !errors.As(err, &apiErr) || apiErr.Category != apperrors.CategoryAPI || apiErr.Operation != "discovery.resolve" || apiErr.Reason != "endpoint_not_resolved" {
t.Fatalf("missing recovery endpoint classification = %#v", err)
}
}
t.Setenv("DINGTALK_OK_MCP_URL", " https://catalog.test ")
runtime := &recoveryRuntime{}
if got, err := runtime.resolveEndpoint(context.Background(), "ok", "tool"); err != nil || got != "https://catalog.test" {
t.Fatalf("recovery endpoint override = %q %v", got, err)
}
if recoveryRuntimeToken(nil) != "" || recoveryRuntimeToken(&GlobalFlags{Token: " token "}) != "token" {
t.Fatal("recovery token mismatch")
}
if toRecoveryToolResponse(nil) != nil {
t.Fatal("nil recovery response should stay nil")
}
response := toRecoveryToolResponse(&transport.ToolCallResult{IsError: true, Blocks: []transport.ContentBlock{{Type: "text", Text: "body"}}})
if response == nil || !response.IsError || len(response.Content) != 1 {
t.Fatalf("recovery response = %#v", response)
}
items := []any{map[string]any{"title": "A", "url": "u", "desc": "d"}, "skip", map[string]any{}}
for _, payload := range []map[string]any{
nil,
{"items": items},
{"data": map[string]any{"items": items}},
{"result": map[string]any{"items": items}},
} {
_ = parseDocSearchItemsFromMap(payload)
}
if toDocSearchItems("bad") != nil {
t.Fatal("non-list doc items accepted")
}
result := &transport.ToolCallResult{Content: map[string]any{}, Blocks: []transport.ContentBlock{{Text: "{"}, {Text: `{"items":[{"title":"B"}]}`}}}
if got := parseDocSearchItems(result); len(got) != 1 {
t.Fatalf("block doc items = %#v", got)
}
if parseDocSearchItems(nil) != nil {
t.Fatal("nil doc result should be nil")
}
searchItems := []recovery.DocSearchItem{{Title: "query", URL: "u"}, {Title: "other"}, {Title: "third"}, {Title: "fourth"}}
if len(rerankDocSearchHits("query", recovery.RecoveryContext{ToolName: "tool", CommandPath: []string{"doc"}}, searchItems)) != 3 || rerankDocSearchHits("", recovery.RecoveryContext{}, nil) != nil {
t.Fatal("doc search reranking mismatch")
}
}
func TestCrossPlatformCoverageSmallAppRegistryAndRootCoverage(t *testing.T) {
RegisterPluginAuth("coverage-registry", &PluginAuth{Token: "token"})
t.Cleanup(func() {
@@ -511,59 +401,6 @@ func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) {
_ = defaultPATMCPEndpoint()
}
func TestCrossPlatformCoverageRecoveryLoadExecutionCoverage(t *testing.T) {
if _, err := loadRecoveryExecution(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Fatal("missing recovery execution succeeded")
}
path := filepath.Join(t.TempDir(), "execution.json")
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := loadRecoveryExecution(path); err == nil {
t.Fatal("malformed recovery execution succeeded")
}
for name, body := range map[string]string{
"legacy": `{"action":" one ","attempt":2,"result":" ok ","error":" bad "}`,
"modern": `{"actions":["one"],"attempts":[{"command_summary":"one"}],"error_summary":"bad"}`,
} {
t.Run(name, func(t *testing.T) {
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
if got, err := loadRecoveryExecution(path); err != nil || len(got.Actions) != 1 || len(got.Attempts) == 0 {
t.Fatalf("loaded execution = %#v %v", got, err)
}
})
}
}
func TestCrossPlatformCoverageRecoveryRuntimeHTTP(t *testing.T) {
var result map[string]any = map[string]any{"content": []map[string]any{{"type": "text", "text": `{"items":[{"title":"query result","url":"u"}]}`}}}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
ID int `json:"id"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result})
}))
defer server.Close()
SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: server.URL, CLI: mcptypes.CLIOverlay{ID: "devdoc", Tools: []mcptypes.CLITool{{Name: "search_open_platform_docs_rag"}}}}})
t.Cleanup(func() { SetDynamicServers(nil) })
runtime := &recoveryRuntime{transport: transport.NewClient(server.Client()), flags: &GlobalFlags{Token: "token"}}
got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{ToolName: "search"})
if err != nil || got.DocSearch.Status != "success" || len(got.KBHits) == 0 {
t.Fatalf("recovery search = %#v %v", got, err)
}
result = map[string]any{"isError": true, "content": []map[string]any{{"type": "text", "text": "failed"}}}
if _, err := runtime.CallToolDirect(context.Background(), "devdoc", "search_open_platform_docs_rag", nil); err == nil {
t.Fatal("recovery MCP error succeeded")
}
server.Close()
if _, err := runtime.CallToolDirect(context.Background(), "devdoc", "search_open_platform_docs_rag", nil); err == nil {
t.Fatal("recovery network error succeeded")
}
}
func TestCrossPlatformCoverageEventCommandPureCoverage(t *testing.T) {
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
@@ -760,7 +597,7 @@ func TestCrossPlatformCoverageVersionCacheCompletionCoverage(t *testing.T) {
child := &cobra.Command{Use: "child"}
root.AddCommand(child)
child.SetOut(io.Discard)
if err := printCacheCompatNotice(child, "status"); err != nil {
if err := printCacheCompatNotice(child, "dws cache status"); err != nil {
t.Fatalf("cache %s: %v", format, err)
}
root.RemoveCommand(child)
@@ -1747,9 +1584,6 @@ func TestCrossPlatformCoverageDoctorCommandCoverage(t *testing.T) {
}
for _, jsonOut := range []bool{false, true} {
if got := doctorCheckCache(io.Discard, jsonOut); got.Status != statusPass {
t.Fatal("cache check failed")
}
if got := doctorCheckPerf(io.Discard, jsonOut); got.Status != statusPass {
t.Fatalf("perf check = %#v", got)
}
@@ -2168,7 +2002,7 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
}
_ = os.Symlink(filepath.Join(mono, "SKILL.md"), filepath.Join(mono, "linked.md"))
multi := filepath.Join(t.TempDir(), "multi")
for _, name := range []string{"dws-shared", "dingtalk-a", "dingtalk-b"} {
for _, name := range []string{"dingtalk-shared", "dingtalk-a", "dingtalk-b"} {
dir := filepath.Join(multi, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
@@ -2193,10 +2027,10 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")); err != nil {
t.Fatal(err)
}
if output, warnings, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") || warnings == "" {
t.Fatalf("multi setup = %q / %q, %v", output, warnings, err)
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") {
t.Fatalf("multi setup = %q, %v", output, err)
}
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws-shared", "SKILL.md")); err != nil {
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-shared", "SKILL.md")); err != nil {
t.Fatal(err)
}
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--dry-run", "--exclude", "b"); err != nil || !strings.Contains(output, "DRY-RUN") {
@@ -2220,7 +2054,7 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
}
func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
all := []string{"dingtalk-a", "dingtalk-b", "dws-shared"}
all := []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}
for _, tc := range []struct {
include []string
exclude []string
@@ -2238,7 +2072,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
t.Errorf("filter %#v/%#v = %v", tc.include, tc.exclude, err)
}
}
for _, selected := range [][]string{nil, {"dws-shared"}, {"dingtalk-a"}} {
for _, selected := range [][]string{nil, {"dingtalk-shared"}, {"dingtalk-a"}} {
_ = ensureMandatorySharedSkill(selected, all)
}
_ = ensureMandatorySharedSkill([]string{"dingtalk-a"}, []string{"dingtalk-a"})
-1
View File
@@ -302,7 +302,6 @@ func TestCrossPlatformCoverageRootUtilityAndTimingCoverage(t *testing.T) {
_ = newConfigCommand()
_ = newCacheCommand()
_ = newVersionCommand()
_ = newRecoveryCommand(&GlobalFlags{})
_ = newAPICommand(&GlobalFlags{})
_ = NewRootCommand(context.Background())
}
+1 -22
View File
@@ -61,7 +61,7 @@ func newDoctorCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "doctor",
Short: "环境健康检查",
Long: "一键检查登录态、网络连通性、缓存状态和版本更新,快速定位常见问题。",
Long: "一键检查登录态、网络连通性和版本更新,快速定位常见问题。",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: runDoctor,
@@ -92,9 +92,6 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
networkResult := doctorCheckNetwork(cmd.Context(), w, jsonOut, networkTimeout)
checks = append(checks, networkResult)
cacheResult := doctorCheckCache(w, jsonOut)
checks = append(checks, cacheResult)
versionResult := doctorCheckVersion(w, jsonOut, networkTimeout)
checks = append(checks, versionResult)
@@ -297,24 +294,6 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
return r
}
// ── Cache check ─────────────────────────────────────────────────────────
func doctorCheckCache(w io.Writer, jsonOut bool) checkResult {
if !jsonOut {
fmt.Fprint(w, tui.Dim("检查缓存状态... "))
}
r := checkResult{
Name: "cache",
Status: statusPass,
Message: "静态端点模式, 无需缓存",
}
if !jsonOut {
printCheckResult(w, r)
}
return r
}
// ── Version check ───────────────────────────────────────────────────────
func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
-25
View File
@@ -109,31 +109,6 @@ func TestPrintCheckResultNoHint(t *testing.T) {
}
}
func TestDoctorCheckCacheEmpty(t *testing.T) {
t.Setenv("DWS_CACHE_DIR", t.TempDir())
var buf bytes.Buffer
r := doctorCheckCache(&buf, false)
if r.Status != statusPass {
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
}
}
func TestDoctorCheckCacheEmptyJSON(t *testing.T) {
t.Setenv("DWS_CACHE_DIR", t.TempDir())
var buf bytes.Buffer
r := doctorCheckCache(&buf, true)
if r.Status != statusPass {
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
}
if buf.Len() != 0 {
t.Error("expected no output in JSON mode")
}
}
func TestDoctorCheckAuthReportsKeychainUnavailable(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
@@ -10,7 +10,7 @@ import (
// TestMultiSkillSharedContractKeepsAccountSafetyRule pins the multi-account
// safety rule that release run 30437390088 found missing: the MultiSkill e2e
// contract asserts the exact phrase below inside the installed
// dws-shared/SKILL.md, so removing it from the embedded skill source must
// dingtalk-shared/SKILL.md, so removing it from the embedded skill source must
// fail at PR time instead of at release time.
func TestMultiSkillSharedContractKeepsAccountSafetyRule(t *testing.T) {
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
@@ -19,12 +19,12 @@ func TestMultiSkillSharedContractKeepsAccountSafetyRule(t *testing.T) {
}
t.Cleanup(cleanup)
data, err := os.ReadFile(filepath.Join(dir, "dws-shared", "SKILL.md"))
data, err := os.ReadFile(filepath.Join(dir, "dingtalk-shared", "SKILL.md"))
if err != nil {
t.Fatalf("read embedded dws-shared/SKILL.md: %v", err)
t.Fatalf("read embedded dingtalk-shared/SKILL.md: %v", err)
}
const rule = "禁止选择第一项、最近登录或最近使用账号"
if !strings.Contains(string(data), rule) {
t.Fatalf("embedded dws-shared/SKILL.md lost the mandatory account safety rule %q", rule)
t.Fatalf("embedded dingtalk-shared/SKILL.md lost the mandatory account safety rule %q", rule)
}
}
+14 -14
View File
@@ -33,43 +33,43 @@ func contains(ss []string, want string) bool {
return false
}
// dws-shared must ship even when --skill narrows the set to a single product.
// dingtalk-shared must ship even when --skill narrows the set to a single product.
func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
src := writeMultiSkillSrc(t, "dws-shared", "dingtalk-aitable", "dingtalk-calendar")
src := writeMultiSkillSrc(t, "dingtalk-shared", "dingtalk-aitable", "dingtalk-calendar")
all, err := listMultiSkillNames(src)
if err != nil {
t.Fatal(err)
}
if !contains(all, "dws-shared") {
t.Fatalf("listMultiSkillNames did not enumerate dws-shared: %v", all)
if !contains(all, "dingtalk-shared") {
t.Fatalf("listMultiSkillNames did not enumerate dingtalk-shared: %v", all)
}
filtered, err := filterMultiSkillNames(all, []string{"aitable"}, nil)
if err != nil {
t.Fatal(err)
}
if contains(filtered, "dws-shared") {
t.Fatalf("precondition: filter should drop dws-shared for -s aitable: %v", filtered)
if contains(filtered, "dingtalk-shared") {
t.Fatalf("precondition: filter should drop dingtalk-shared for -s aitable: %v", filtered)
}
final := ensureMandatorySharedSkill(filtered, all)
if !contains(final, "dws-shared") {
t.Fatalf("ensureMandatorySharedSkill must re-add dws-shared: %v", final)
if !contains(final, "dingtalk-shared") {
t.Fatalf("ensureMandatorySharedSkill must re-add dingtalk-shared: %v", final)
}
// Actually install with the filtered+mandatory set and assert dws-shared landed.
// Actually install with the filtered+mandatory set and assert dingtalk-shared landed.
dest := t.TempDir()
var out, errOut bytes.Buffer
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
t.Fatalf("install: %v (%s)", err, errOut.String())
}
if _, err := os.Stat(filepath.Join(dest, "dws-shared", "SKILL.md")); err != nil {
t.Fatalf("dws-shared not installed with -s aitable: %v", err)
if _, err := os.Stat(filepath.Join(dest, "dingtalk-shared", "SKILL.md")); err != nil {
t.Fatalf("dingtalk-shared not installed with -s aitable: %v", err)
}
if _, err := os.Stat(filepath.Join(dest, "dingtalk-aitable", "SKILL.md")); err != nil {
t.Fatalf("dingtalk-aitable not installed: %v", err)
}
}
// When the source has no dws-shared (older layout), nothing is forced.
// When the source has no dingtalk-shared (older layout), nothing is forced.
func TestP1SharedNoopWhenAbsent(t *testing.T) {
src := writeMultiSkillSrc(t, "dingtalk-aitable")
all, err := listMultiSkillNames(src)
@@ -77,7 +77,7 @@ func TestP1SharedNoopWhenAbsent(t *testing.T) {
t.Fatal(err)
}
final := ensureMandatorySharedSkill([]string{"dingtalk-aitable"}, all)
if contains(final, "dws-shared") {
t.Fatalf("must not invent dws-shared when source lacks it: %v", final)
if contains(final, "dingtalk-shared") {
t.Fatalf("must not invent dingtalk-shared when source lacks it: %v", final)
}
}
+20 -12
View File
@@ -58,6 +58,14 @@ func paramAliasResponseForTool(tool string) string {
return `{"deptList":[{"deptId":1,"name":"Fixture Dept"}]}`
case "search_groups":
return `{"result":{"items":[{"openConversationId":"fixture-conversation","title":"Fixture Group"}]}}`
case "search_contact_by_key_word":
return `{"result":[{"name":"Fixture User","userId":"fixture-user","openDingTalkId":"D-fixture-user"}]}`
case "list_doc_versions":
return `{"result":{"items":[{"version":3}]}}`
case "search_doc_templates":
return `{"result":[{"templateId":"fixture-template-id"}]}`
case "get_document_content":
return `{"revision":1}`
default:
return `{}`
}
@@ -191,7 +199,7 @@ func executeParamAliasE2E(t *testing.T, caller *paramAliasCaptureCaller, args ..
return ctx, root.Execute()
}
func TestBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
func TestCrossPlatformCoverageBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
tests := []struct {
name string
confirmation []string
@@ -244,7 +252,7 @@ func TestBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
}
}
func TestParamAliasReadCommandFinalPayload(t *testing.T) {
func TestCrossPlatformCoverageParamAliasReadCommandFinalPayload(t *testing.T) {
caller := &paramAliasCaptureCaller{}
start := "2026-03-10T14:00:00+08:00"
end := "2026-03-10T18:00:00+08:00"
@@ -279,7 +287,7 @@ func TestParamAliasReadCommandFinalPayload(t *testing.T) {
}
}
func TestParamAliasWriteCommandFinalPayload(t *testing.T) {
func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
caller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasE2E(t, caller,
"chat", "message", "send",
@@ -311,7 +319,7 @@ func TestParamAliasWriteCommandFinalPayload(t *testing.T) {
}
}
func TestChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
tests := []struct {
name string
command []string
@@ -390,7 +398,7 @@ func TestChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
}
}
func TestAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
func TestCrossPlatformCoverageAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
root := NewRootCommand()
engine := newPipelineEngine()
entries, err := cli.ReduceParamAliases(root)
@@ -478,7 +486,7 @@ func TestAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
t.Logf("verified generated chat parameter routes: entries=%d aliases=%d blocked=%d ambiguous=%d", chatEntries, aliasCases, guardCases[pipeline.FlagProtectionBlocked], guardCases[pipeline.FlagProtectionAmbiguous])
}
func TestIMUserIDHallucinationRoutes(t *testing.T) {
func TestCrossPlatformCoverageIMUserIDHallucinationRoutes(t *testing.T) {
tests := []struct {
command string
want string
@@ -531,7 +539,7 @@ func TestIMUserIDHallucinationRoutes(t *testing.T) {
}
}
func TestHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
func TestCrossPlatformCoverageHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
const command = "chat message list-direct"
if _, ok := cli.LookupParamAlias(command); ok {
t.Fatalf("hidden command %q unexpectedly entered the public generated alias table", command)
@@ -556,7 +564,7 @@ func TestHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
}
}
func TestSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing.T) {
func TestCrossPlatformCoverageSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing.T) {
tests := []struct {
name string
tool string
@@ -653,7 +661,7 @@ func TestSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing
}
}
func TestParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
func TestCrossPlatformCoverageParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
caller := &paramAliasCaptureCaller{}
for _, args := range [][]string{
{"calendar", "event", "list", "--date", "2026-03-10", "--start", "2026-03-11"},
@@ -678,7 +686,7 @@ func TestParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
}
}
func TestAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
func TestCrossPlatformCoverageAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
@@ -770,7 +778,7 @@ func TestAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
}
}
func TestRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testing.T) {
func TestCrossPlatformCoverageRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testing.T) {
for _, test := range []struct {
path string
emitted string
@@ -817,7 +825,7 @@ func TestRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testin
}
}
func TestFlagConflictErrorFormattingIsDeterministic(t *testing.T) {
func TestCrossPlatformCoverageFlagConflictErrorFormattingIsDeterministic(t *testing.T) {
err := (&pipeline.FlagConflictError{Command: "dws demo", Canonical: "start", Spellings: []string{"start", "date"}}).Error()
want := `conflicting parameter spellings for --start on "dws demo": --date, --start; pass exactly one spelling`
if err != want {
+11 -5
View File
@@ -44,7 +44,7 @@ import (
// - expect=<realFlag> : emitted must reduce to that canonical flag.
// - expect=did-you-mean:blocked : block guard hit; never auto-rewritten.
// - expect=did-you-mean:ambiguous: co-occurrence guard hit; never rewritten.
func TestParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
func TestCrossPlatformCoverageParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
@@ -113,13 +113,19 @@ func TestParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
t.Fatalf("reviewed canonical --%s on %q is not a real Cobra flag", c.Expect, c.Command)
}
flagArgs := ctx.Args[len(strings.Fields(c.Command)):]
if len(flagArgs) < 2 || flagArgs[1] != fixtureValue {
t.Fatalf("%q on %q lost its value: args=%v", c.Emitted, c.Command, ctx.Args)
if len(flagArgs) == 0 {
t.Fatalf("%q on %q lost its flag and value: args=%v", c.Emitted, c.Command, ctx.Args)
}
got := flagArgs[0]
gotBare := strings.SplitN(strings.TrimPrefix(got, "--"), "=", 2)[0]
gotBare, inlineValue, hasInlineValue := strings.Cut(strings.TrimPrefix(got, "--"), "=")
switch {
case got == "--"+c.Expect:
case hasInlineValue && inlineValue != fixtureValue:
t.Fatalf("%q on %q changed its inline value: got %q, want %q (args=%v)", c.Emitted, c.Command, inlineValue, fixtureValue, ctx.Args)
case !hasInlineValue && (len(flagArgs) < 2 || flagArgs[1] != fixtureValue):
t.Fatalf("%q on %q lost its value: args=%v", c.Emitted, c.Command, ctx.Args)
}
switch {
case gotBare == c.Expect:
// (1) rewritten; the embedded table must agree.
if !hasEntry {
t.Fatalf("%q on %q was rewritten without an embedded alias entry", c.Emitted, c.Command)
@@ -30,13 +30,34 @@ var paramAliasCompleteCommands = map[string][]string{
"attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
"chat +chat-add-bot": {"chat", "+chat-add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
"chat +chat-audit-join": {"chat", "+chat-audit-join", "--group", "fixture-conversation", "--record-id", "7", "--applicant", "user-1", "--inviter", "user-2", "--status", "AuditApprove", "--yes"},
"chat +chat-members-get": {"chat", "+chat-members-get", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
"chat +chat-members-list": {"chat", "+chat-members-list", "--conversation-id", "fixture-conversation", "--member-types", "user,bot"},
"chat +chat-mute-member": {"chat", "+chat-mute-member", "--group", "fixture-conversation", "--users", "D-user-1,D-user-2", "--mute-time", "3600000", "--yes"},
"chat +chat-remove-bot": {"chat", "+chat-remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
"chat +chat-role-remove-user": {"chat", "+chat-role-remove-user", "--group", "fixture-conversation", "--user", "D-user-1", "--role-ids", "role-1", "--yes"},
"chat +chat-transfer-owner": {"chat", "+chat-transfer-owner", "--group", "fixture-conversation", "--new-owner", "D-user-1", "--yes"},
"chat +chat-update": {"chat", "+chat-update", "--group", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
"chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"},
"chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
"chat +category-create": {"chat", "+category-create", "--title", "Fixture Cat", "--yes"},
"chat +category-rename": {"chat", "+category-rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"},
"chat +group-members": {"chat", "+group-members", "--group", "Fixture Group"},
"chat +conversation-set-top": {"chat", "+conversation-set-top", "--conversation-id", "fixture-conversation", "--yes"},
"chat +feed-group-query-item": {"chat", "+feed-group-query-item", "--category-id", "7", "--conversation-ids", "fixture-conversation"},
"chat +flag-cancel": {"chat", "+flag-cancel", "--conversation-id", "fixture-conversation", "--message-id", "message-1", "--yes"},
"chat +flag-create": {"chat", "+flag-create", "--conversation-id", "fixture-conversation", "--message-id", "message-1", "--yes"},
"chat +flag-list": {"chat", "+flag-list", "--cursor", "0", "--size", "7"},
"chat +messages-combine-forward": {"chat", "+messages-combine-forward", "--src-conversation-id", "fixture-source", "--msg-ids", "message-1,message-2", "--dest-conversation-id", "fixture-destination", "--yes"},
"chat +messages-forward": {"chat", "+messages-forward", "--src-conversation-id", "fixture-source", "--msg-id", "message-1", "--dest-conversation-id", "fixture-destination", "--yes"},
"chat +messages-forward-topic": {"chat", "+messages-forward-topic", "--src-msg-id", "message-1", "--src-conversation-id", "fixture-source", "--src-thread-id", "convThread-fixture", "--dest-conversation-id", "fixture-destination", "--yes"},
"chat +messages-list": {"chat", "+messages-list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
"chat +messages-resource-download": {"chat", "+messages-resource-download", "--resource-id", "resource-1", "--message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--output", "downloads/fixture.bin"},
"chat +messages-set-pin": {"chat", "+messages-set-pin", "--open-conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
"chat +search-msg": {"chat", "+search-msg", "--group", "fixture-conversation", "--query", "fixture", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--no-enrich"},
"chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--text", "hello fixture", "--yes"},
@@ -81,9 +102,36 @@ var paramAliasCompleteCommands = map[string][]string{
"ding +receiver-status": {"ding", "+receiver-status", "--ding-id", "ding-1"},
"ding message receiver-status": {"ding", "message", "receiver-status", "--ding-id", "ding-1"},
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1", "--yes"},
"doc +comment-create": {"doc", "+comment-create", "--node", "node-1", "--content", "fixture comment", "--yes"},
"doc +comment-list": {"doc", "+comment-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
"doc +comment-reply": {"doc", "+comment-reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--yes"},
"doc +access-grant": {"doc", "+access-grant", "--node", "node-1", "--to", "Fixture User", "--role", "READER", "--workspace", "workspace-1", "--yes"},
"doc +copy": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
"doc +create": {"doc", "+create", "--name", "Fixture Document", "--content", "fixture body", "--doc-format", "markdown"},
"doc +create-from-template": {"doc", "+create-from-template", "--query", "fixture template", "--name", "Fixture From Template", "--folder", "folder-1", "--workspace", "workspace-1"},
"doc +doc-append": {"doc", "+doc-append", "--doc", "node-1", "--text", "fixture appendix", "--yes"},
"doc +export-submit": {"doc", "+export-submit", "--node", "node-1", "--export-format", "docx"},
"doc +fetch": {"doc", "+fetch", "--node", "node-1", "--scope", "section", "--start-block-id", "block-1"},
"doc +find-doc": {"doc", "+find-doc", "--query", "fixture", "--limit", "7"},
"doc +history-revert": {"doc", "+history-revert", "--node", "node-1", "--version", "3", "--yes"},
"doc +inspect": {"doc", "+inspect", "--node", "node-1", "--include-history"},
"doc +list": {"doc", "+list", "--folder", "folder-1", "--cursor", "cursor-1"},
"doc +move": {"doc", "+move", "--node", "node-1", "--folder", "folder-1", "--yes"},
"doc +search": {"doc", "+search", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
"doc +template-list": {"doc", "+template-list", "--source", "MY", "--limit", "7", "--cursor", "cursor-1"},
"doc +template-search": {"doc", "+template-search", "--query", "fixture", "--source", "MY", "--limit", "7"},
"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 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"},
"doc comment create-inline": {"doc", "comment", "create-inline", "--node", "node-1", "--block-id", "block-1", "--start", "0", "--end", "7", "--content", "fixture comment", "--yes"},
"doc comment delete": {"doc", "comment", "delete", "--node", "node-1", "--comment-key", "comment-1", "--yes"},
"doc comment reply": {"doc", "comment", "reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--mentioned-open-conversation-id", "cid-1,cid-2", "--yes"},
"doc comment update": {"doc", "comment", "update", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture update", "--yes"},
"doc version revert": {"doc", "version", "revert", "--node", "node-1", "--version", "3", "--yes"},
"drive info": {"drive", "info", "--node", "node-1", "--space-id", "space-1"},
"drive list": {"drive", "list", "--folder", "folder-1", "--limit", "7"},
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"},
@@ -101,6 +149,13 @@ var paramAliasCompleteCommands = map[string][]string{
// that case the shared command template above cannot contain every canonical
// flag at once, so select a fixture-specific complete invocation here.
var paramAliasCompleteCommandVariants = map[string]map[string][]string{
"doc +copy": {
"folder": {"doc", "+copy", "--node", "node-1", "--folder", "folder-1", "--yes"},
"workspace": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
},
"doc block insert": {
"parent-block": {"doc", "block", "insert", "--node", "node-1", "--parent-block", "parent-block-1", "--index", "0", "--text", "fixture paragraph", "--yes"},
},
"chat message list": {
"user": {"chat", "message", "list", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
},
@@ -111,6 +166,9 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--uuid", "param-alias-equivalence-group", "--yes"},
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--uuid", "param-alias-equivalence-file", "--yes"},
},
"chat +conversation-set-top": {
"conversation-ids": {"chat", "+conversation-set-top", "--conversation-ids", "fixture-conversation-1,fixture-conversation-2", "--yes"},
},
}
// paramAliasNewIMCases is the exact set of aliases added by the reviewed IM
@@ -149,6 +207,38 @@ var paramAliasNewIMCases = []struct {
{command: "chat message send", emitted: "file", canonical: "file-path"},
{command: "chat message send-by-bot", emitted: "at-users", canonical: "at-user-ids"},
{command: "chat message send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
{command: "chat +chat-update", emitted: "chat-id", canonical: "group"},
{command: "chat +chat-update", emitted: "conversation-id", canonical: "group"},
{command: "chat +chat-update", emitted: "open-conversation-id", canonical: "group"},
{command: "chat +chat-update", emitted: "title", canonical: "name"},
{command: "chat +chat-update", emitted: "new-title", canonical: "name"},
{command: "chat +flag-list", emitted: "limit", canonical: "size"},
{command: "chat +chat-members-list", emitted: "chat-id", canonical: "conversation-id"},
{command: "chat +chat-members-list", emitted: "id", canonical: "conversation-id"},
{command: "chat +conversation-set-top", emitted: "open-conversation-id", canonical: "conversation-id"},
{command: "chat +conversation-set-top", emitted: "chat-ids", canonical: "conversation-ids"},
{command: "chat +chat-members-get", emitted: "conversation-id", canonical: "id"},
{command: "chat +chat-members-get", emitted: "open-dingtalk-ids", canonical: "users"},
{command: "chat +chat-members-get", emitted: "chat", canonical: "id"},
{command: "chat +messages-list", emitted: "start", canonical: "time"},
{command: "chat +messages-reply", emitted: "msg-id", canonical: "ref-msg-id"},
{command: "chat +messages-reply", emitted: "chat", canonical: "conversation-id"},
{command: "chat +flag-cancel", emitted: "group", canonical: "conversation-id"},
{command: "chat +flag-cancel", emitted: "chat", canonical: "conversation-id"},
{command: "chat +flag-create", emitted: "group", canonical: "conversation-id"},
{command: "chat +chat-add-bot", emitted: "conversation-id", canonical: "id"},
{command: "chat +chat-add-bot", emitted: "robot", canonical: "robot-code"},
{command: "chat +chat-audit-join", emitted: "applicant-user-id", canonical: "applicant"},
{command: "chat +chat-mute-member", emitted: "user-ids", canonical: "users"},
{command: "chat +chat-remove-bot", emitted: "open-bot-id", canonical: "bot-id"},
{command: "chat +chat-role-remove-user", emitted: "open-dingtalk-id", canonical: "user"},
{command: "chat +chat-transfer-owner", emitted: "user-id", canonical: "new-owner"},
{command: "chat +feed-group-query-item", emitted: "chat-ids", canonical: "conversation-ids"},
{command: "chat +messages-combine-forward", emitted: "src-open-cid", canonical: "src-conversation-id"},
{command: "chat +messages-forward", emitted: "source-message-id", canonical: "msg-id"},
{command: "chat +messages-forward-topic", emitted: "src-open-message-id", canonical: "src-msg-id"},
{command: "chat +messages-resource-download", emitted: "conversation-id", canonical: "open-conversation-id"},
{command: "chat +messages-set-pin", emitted: "conversation-id", canonical: "open-conversation-id"},
}
// paramAliasRepresentativePayloadCases keeps final transport coverage across
@@ -163,14 +253,33 @@ var paramAliasNewIMCases = []struct {
// That duplicated command construction was enough to push the pre-existing
// macOS app suite beyond its package-level 10-minute timeout.
var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("aitable +record-query", "base"): true, // concept alias on a shortcut read
paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias
paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias
paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias
paramAliasPayloadCaseKey("contact user profile get", "user-id"): true, // native compatibility flag
paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override
paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
paramAliasPayloadCaseKey("aitable +record-query", "base"): true, // concept alias on a shortcut read
paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias
paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias
paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias
paramAliasPayloadCaseKey("contact user profile get", "user-id"): true, // native compatibility flag
paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override
paramAliasPayloadCaseKey("doc +comment-create", "body"): true, // write shortcut content alias
paramAliasPayloadCaseKey("doc +copy", "parent-folder-id"): true, // Doc folder role on a write shortcut
paramAliasPayloadCaseKey("doc +create", "content-format"): true, // shortcut format alias preserves markdown/jsonml enum
paramAliasPayloadCaseKey("doc +create-from-template", "keyword"): true, // template search alias composes with a write workflow
paramAliasPayloadCaseKey("doc +create-from-template", "workspace-id"): true, // template target workspace identifier
paramAliasPayloadCaseKey("doc +create-from-template", "parent-folder-id"): true, // template target folder identifier
paramAliasPayloadCaseKey("doc +export-submit", "doc-id"): true, // Doc node identifier alias
paramAliasPayloadCaseKey("doc +fetch", "start-block"): true, // section boundary role remains exact
paramAliasPayloadCaseKey("doc +history-revert", "version-number"): true, // destructive history version alias keeps confirmation
paramAliasPayloadCaseKey("doc +inspect", "include-versions"): true, // boolean section alias preserves value
paramAliasPayloadCaseKey("doc +template-list", "next-token"): true, // Doc cursor alias on a read shortcut
paramAliasPayloadCaseKey("doc +update", "mode"): true, // write operation selector alias
paramAliasPayloadCaseKey("doc +update", "revision"): true, // optimistic edit revision alias
paramAliasPayloadCaseKey("doc +access-grant", "doc-id"): true, // permission write keeps document identity
paramAliasPayloadCaseKey("doc +version-revert", "version-number"): true, // high-write version role with canonical confirmation
paramAliasPayloadCaseKey("doc block insert", "content"): true, // block write content alias
paramAliasPayloadCaseKey("doc block insert", "parent-block-id"): true, // scoped block-role alias
paramAliasPayloadCaseKey("doc comment delete", "comment-id"): true, // destructive comment-key alias
paramAliasPayloadCaseKey("doc comment reply", "mentioned-open-conversation-ids"): true, // list-valued group mention role
paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
}
func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
@@ -273,8 +382,9 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
}
canonicalCaller := &paramAliasCaptureCaller{}
if _, err := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...); err != nil {
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", err, canonicalArgs, canonicalCaller.calls)
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
if canonicalErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, canonicalErr) {
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
}
if len(canonicalCaller.calls) == 0 {
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
@@ -290,13 +400,16 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
}
activeAliases++
aliasCaller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
if err != nil {
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", err, aliasArgs, aliasCaller.calls)
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
if aliasErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, aliasErr) {
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
}
if ctx == nil {
t.Fatal("complete alias command skipped PreParse")
}
if (canonicalErr == nil) != (aliasErr == nil) || (canonicalErr != nil && canonicalErr.Error() != aliasErr.Error()) {
t.Fatalf("canonical and alias completion errors differ: canonical=%v alias=%v", canonicalErr, aliasErr)
}
normalizeParamAliasVolatileDefaults(test.command, canonicalCaller, aliasCaller)
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
@@ -308,6 +421,16 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
}
}
// Resource download deliberately continues from the transport call into a
// local HTTPS download. The generic capture caller returns an empty object, so
// this command's stable post-transport validation error is the expected test
// boundary; canonical and alias calls must still produce the same request and
// the same error.
func paramAliasExpectedCaptureBoundaryError(command string, err error) bool {
return command == "chat +messages-resource-download" && err != nil &&
strings.Contains(err.Error(), "资源下载接口未返回合法的 HTTPS 下载地址")
}
// +chat-messages supplies the current wall-clock time when callers omit
// --time. Alias equivalence concerns the resolved target and transport shape;
// a suite crossing a second boundary must not make that default appear
+64 -2
View File
@@ -24,7 +24,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
func TestLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
func TestCrossPlatformCoverageLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
tests := []struct {
name string
args []string
@@ -55,7 +55,7 @@ func TestLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
}
}
func TestPreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
func TestCrossPlatformCoveragePreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
root := NewSchemaSourceRootCommand()
args := []string{
"chat", "message", "send",
@@ -98,3 +98,65 @@ func TestPreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
t.Fatalf("--debug details missing from early error:\n%s", rendered)
}
}
func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTree(t *testing.T) {
tests := []struct {
name string
args []string
wantReason string
wantCommand string
}{
{
name: "unknown shortcut",
args: []string{"chat", "+chat-mesages", "--keyword", "x", "--format", "json"},
wantReason: "unknown_shortcut",
wantCommand: "dws chat",
},
{
name: "unknown dev app subcommand",
args: []string{"dev", "app", "search", "--keyword", "x", "--format", "json"},
wantReason: "unknown_subcommand",
wantCommand: "dws dev app",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := NewSchemaSourceRootCommand()
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
if ctx == nil || ctx.Command != test.wantCommand {
t.Fatalf("Context = %#v, want command %q", ctx, test.wantCommand)
}
err = newPreParseValidationError(err)
var structured *apperrors.Error
if !stderrors.As(err, &structured) {
t.Fatalf("error = %T %v", err, err)
}
if structured.Reason != test.wantReason || structured.ExitCode() != 3 {
t.Fatalf("structured error = %#v", structured)
}
if len(structured.AvailableFlags) != 0 || strings.Contains(structured.Message, "unknown flag") {
t.Fatalf("command error leaked flag classification: %#v", structured)
}
})
}
}
func TestCrossPlatformCoverageResolvedShortcutStillUsesExactLeafFlagError(t *testing.T) {
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
args := []string{"chat", "+chat-messages", "--keyword", "x", "--format", "json"}
if ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args); err != nil {
t.Fatalf("valid shortcut path failed PreParse: %#v, %v", ctx, err)
}
root.SetArgs(args)
err := root.Execute()
var structured *apperrors.Error
if !stderrors.As(err, &structured) {
t.Fatalf("error = %T %v", err, err)
}
if structured.Reason != "unknown_flag" || !strings.Contains(structured.Message, "dws chat +chat-messages --help") {
t.Fatalf("resolved shortcut flag error = %#v", structured)
}
}
+62 -440
View File
@@ -1,496 +1,118 @@
// 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"
"encoding/json"
"fmt"
"net/http"
"os"
"sort"
"strings"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/spf13/cobra"
)
var (
recoverySavePlan = (*recovery.Store).SavePlan
recoverySaveAnalysis = (*recovery.Store).SaveAnalysis
)
const recoveryUnsupportedMessage = "dws recovery 不再支持:失败快照恢复计划/执行/闭环已下线,请改用 doctor / schema / 对应业务命令排查。"
func newRecoveryCommand(flags *GlobalFlags) *cobra.Command {
var (
planUseLast bool
planEventID string
executeUseLast bool
executeEventID string
finalEventID string
finalOutcome string
executionFile string
)
runtime := newRecoveryRuntime(flags)
type recoveryCompatNotice struct {
Status string `json:"status"`
Command string `json:"command"`
Message string `json:"message"`
}
// newRecoveryCommand keeps a visible Deprecated compatibility surface for
// historical argv and Interface Integrity. Behavior is unchanged: every leaf
// returns an explicit unsupported notice. Skills must not teach this path.
func newRecoveryCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "recovery",
Short: "错误恢复辅助命令",
Long: "读取失败快照,生成恢复分析,并回写恢复结果。",
Short: "不再支持:错误恢复辅助命令(兼容入口)",
Long: "此命令组仅为历史 argv 兼容保留,不再读取失败快照或生成恢复计划。Skill / Agent 请勿引导此路径。",
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
return printRecoveryUnsupported(cmd, "dws recovery")
},
}
planCmd := &cobra.Command{
Use: "plan",
Short: "基于失败快照生成恢复计划",
Short: "不再支持:基于失败快照生成恢复计划",
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
store := recovery.NewStore(defaultConfigDir())
last, err := loadRecoverySnapshot(store, planUseLast, planEventID)
if err != nil {
return err
}
planner := recovery.NewPlanner(runtime)
plan := planner.PlanWithOptions(cmd.Context(), last.Context, recovery.PlanOptions{
EventID: last.EventID,
EnableDocSearch: true,
})
recovery.HydratePlanForEvent(last.EventID, last.Context, last.Replay, &plan)
if err := recoverySavePlan(store, last.EventID, plan); err != nil {
return fmt.Errorf("保存恢复计划失败: %w", err)
}
payload := map[string]any{
"event_id": last.EventID,
"context": last.Context,
"plan": plan,
}
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
return printRecoveryUnsupported(cmd, "dws recovery plan")
},
}
planCmd.Flags().BoolVar(&planUseLast, "last", false, "读取最近一次失败快照")
planCmd.Flags().StringVar(&planEventID, "event-id", "", "按 event_id 读取失败快照")
planCmd.Flags().Bool("last", false, "旧版兼容参数;recovery 不再支持")
planCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
executeCmd := &cobra.Command{
Use: "execute",
Short: "生成面向 Agent 的恢复分析包",
Short: "不再支持:生成面向 Agent 的恢复分析包",
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
store := recovery.NewStore(defaultConfigDir())
last, err := loadRecoverySnapshot(store, executeUseLast, executeEventID)
if err != nil {
return err
}
planner := recovery.NewPlanner(runtime)
executor := recovery.NewExecutor(planner, runtime)
bundle := executor.Execute(cmd.Context(), *last)
if err := recoverySaveAnalysis(store, last.EventID, bundle.Plan, bundle); err != nil {
return fmt.Errorf("保存恢复分析失败: %w", err)
}
return output.WriteCommandPayload(cmd, bundle, output.FormatJSON)
return printRecoveryUnsupported(cmd, "dws recovery execute")
},
}
executeCmd.Flags().BoolVar(&executeUseLast, "last", false, "读取最近一次失败快照")
executeCmd.Flags().StringVar(&executeEventID, "event-id", "", "按 event_id 读取失败快照")
executeCmd.Flags().Bool("last", false, "旧版兼容参数;recovery 不再支持")
executeCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
finalizeCmd := &cobra.Command{
Use: "finalize",
Short: "回写恢复闭环结果",
Short: "不再支持:回写恢复闭环结果",
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(finalEventID) == "" {
return fmt.Errorf("必须提供 --event-id")
}
if strings.TrimSpace(finalOutcome) == "" {
return fmt.Errorf("必须提供 --outcome")
}
switch finalOutcome {
case "recovered", "failed", "handoff":
default:
return fmt.Errorf("--outcome 仅支持 recovered|failed|handoff")
}
store := recovery.NewStore(defaultConfigDir())
var execution *recovery.RecoveryExecution
if strings.TrimSpace(executionFile) != "" {
loaded, err := loadRecoveryExecution(executionFile)
if err != nil {
return err
}
execution = &loaded
}
if err := store.Finalize(finalEventID, finalOutcome, execution); err != nil {
return fmt.Errorf("回写恢复结果失败: %w", err)
}
payload := map[string]any{
"event_id": finalEventID,
"outcome": finalOutcome,
"success": true,
}
if execution != nil {
payload["execution_recorded"] = true
}
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
return printRecoveryUnsupported(cmd, "dws recovery finalize")
},
}
finalizeCmd.Flags().StringVar(&finalEventID, "event-id", "", "恢复事件 ID")
finalizeCmd.Flags().StringVar(&finalOutcome, "outcome", "", "恢复结果: recovered|failed|handoff")
finalizeCmd.Flags().StringVar(&executionFile, "execution-file", "", "Agent 执行详情 JSON 文件")
finalizeCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
finalizeCmd.Flags().String("outcome", "", "旧版兼容参数;recovery 不再支持")
finalizeCmd.Flags().String("execution-file", "", "旧版兼容参数;recovery 不再支持")
cmd.AddCommand(planCmd, executeCmd, finalizeCmd)
return cmd
}
func loadRecoverySnapshot(store *recovery.Store, useLast bool, eventID string) (*recovery.LastError, error) {
if useLast && strings.TrimSpace(eventID) != "" {
return nil, fmt.Errorf("--last 和 --event-id 不能同时使用")
func printRecoveryUnsupported(cmd *cobra.Command, command string) error {
notice := recoveryCompatNotice{
Status: "unsupported",
Command: command,
Message: recoveryUnsupportedMessage,
}
switch {
case useLast:
last, err := store.LoadLastError()
if err != nil {
return nil, fmt.Errorf("读取失败快照失败: %w", err)
format, _ := cmd.Root().PersistentFlags().GetString("format")
switch strings.ToLower(strings.TrimSpace(format)) {
case "", "json":
if err := json.NewEncoder(cmd.OutOrStdout()).Encode(notice); err != nil {
return err
}
return last, nil
case strings.TrimSpace(eventID) != "":
last, err := store.LoadErrorByEvent(strings.TrimSpace(eventID))
if err != nil {
return nil, fmt.Errorf("读取失败快照失败: %w", err)
return apperrors.NewValidation(recoveryUnsupportedMessage)
case "pretty":
data, _ := json.MarshalIndent(notice, "", " ")
if _, err := fmt.Fprintln(cmd.OutOrStdout(), string(data)); err != nil {
return err
}
return last, nil
return apperrors.NewValidation(recoveryUnsupportedMessage)
default:
return nil, fmt.Errorf("必须通过 --last 或 --event-id 指定失败快照")
}
}
func loadRecoveryExecution(path string) (recovery.RecoveryExecution, error) {
var execution recovery.RecoveryExecution
data, err := os.ReadFile(path)
if err != nil {
return execution, fmt.Errorf("读取恢复执行详情失败: %w", err)
}
var payload recoveryExecutionPayload
if err := json.Unmarshal(data, &payload); err != nil {
return execution, fmt.Errorf("解析恢复执行详情失败: %w", err)
}
execution.Actions = append([]string(nil), payload.Actions...)
if len(execution.Actions) == 0 && strings.TrimSpace(payload.Action) != "" {
execution.Actions = []string{strings.TrimSpace(payload.Action)}
}
execution.Result = strings.TrimSpace(payload.Result)
execution.ErrorSummary = strings.TrimSpace(payload.ErrorSummary)
if execution.ErrorSummary == "" {
execution.ErrorSummary = strings.TrimSpace(payload.Error)
}
attempts, err := decodeRecoveryAttempts(payload.Attempts, execution.Actions, execution.Result, execution.ErrorSummary)
if err != nil {
return execution, fmt.Errorf("解析恢复执行详情失败: %w", err)
}
if len(attempts) == 0 && payload.Attempt > 0 {
attempts = legacyRecoveryAttempts(payload.Attempt, execution.Actions, execution.Result, execution.ErrorSummary)
}
execution.Attempts = attempts
return execution, nil
}
type recoveryExecutionPayload struct {
Action string `json:"action,omitempty"`
Actions []string `json:"actions,omitempty"`
Attempt int `json:"attempt,omitempty"`
Attempts json.RawMessage `json:"attempts,omitempty"`
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
ErrorSummary string `json:"error_summary,omitempty"`
}
func decodeRecoveryAttempts(raw json.RawMessage, actions []string, result, errorSummary string) ([]recovery.RecoveryAttempt, error) {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" || trimmed == "null" {
return nil, nil
}
if strings.HasPrefix(trimmed, "[") {
var attempts []recovery.RecoveryAttempt
if err := json.Unmarshal(raw, &attempts); err != nil {
return nil, err
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", notice.Command, notice.Message); err != nil {
return err
}
return attempts, nil
}
var count int
if err := json.Unmarshal(raw, &count); err != nil {
return nil, err
}
return legacyRecoveryAttempts(count, actions, result, errorSummary), nil
}
func legacyRecoveryAttempts(count int, actions []string, result, errorSummary string) []recovery.RecoveryAttempt {
if count <= 0 {
return nil
}
summary := strings.TrimSpace(strings.Join(actions, ", "))
if summary == "" {
summary = "legacy execution attempt"
}
attempts := make([]recovery.RecoveryAttempt, 0, count)
for i := 0; i < count; i++ {
attempts = append(attempts, recovery.RecoveryAttempt{
CommandSummary: summary,
Result: result,
ErrorSummary: errorSummary,
Source: "legacy_execution_file",
})
}
return attempts
}
type recoveryRuntime struct {
transport *transport.Client
flags *GlobalFlags
}
func newRecoveryRuntime(flags *GlobalFlags) *recoveryRuntime {
var httpClient *http.Client
if flags != nil && flags.Timeout > 0 {
httpClient = &http.Client{Timeout: time.Duration(flags.Timeout) * time.Second}
}
client := transport.NewClient(httpClient)
client.ExtraHeaders = resolveIdentityHeaders()
return &recoveryRuntime{
transport: client,
flags: flags,
return apperrors.NewValidation(recoveryUnsupportedMessage)
}
}
func (r *recoveryRuntime) Search(ctx context.Context, query string, rc recovery.RecoveryContext) (recovery.KnowledgeRetrieval, error) {
const (
searchPage = 1
searchSize = 5
)
requestArgs := map[string]any{
"keyword": query,
"page": searchPage,
"size": searchSize,
}
retrieval := recovery.KnowledgeRetrieval{
DocSearch: recovery.DocSearch{
Provider: "open_platform_docs",
Query: query,
Page: searchPage,
Size: searchSize,
Status: "empty",
Request: &recovery.ToolCallRecord{
ServerID: "devdoc",
ToolName: "search_open_platform_docs_rag",
Arguments: cloneRecoveryArgs(requestArgs),
},
},
}
if r == nil || strings.TrimSpace(query) == "" {
retrieval.DocSearch.Status = "skipped"
return retrieval, nil
}
result, err := r.CallToolDirect(ctx, "devdoc", "search_open_platform_docs_rag", requestArgs)
if result != nil {
retrieval.DocSearch.Response = toRecoveryToolResponse(result)
}
if err != nil {
retrieval.DocSearch.Status = "error"
retrieval.DocSearch.Error = err.Error()
return retrieval, err
}
retrieval.DocSearch.Items = parseDocSearchItems(result)
if len(retrieval.DocSearch.Items) > 0 {
retrieval.DocSearch.Status = "success"
retrieval.KBHits = rerankDocSearchHits(query, rc, retrieval.DocSearch.Items)
}
return retrieval, nil
}
func (r *recoveryRuntime) CallToolDirect(ctx context.Context, serverID, toolName string, args map[string]any) (*transport.ToolCallResult, error) {
if r == nil || r.transport == nil {
return nil, fmt.Errorf("recovery runtime not initialized")
}
endpoint, err := r.resolveEndpoint(ctx, serverID, toolName)
if err != nil {
return nil, err
}
authToken, err := resolveRuntimeAuthToken(ctx, recoveryRuntimeToken(r.flags))
if err != nil {
return nil, tokenResolutionError(err)
}
tc := r.transport.WithAuth(authToken, resolveIdentityHeaders())
result, err := tc.CallTool(ctx, endpoint, toolName, args)
if err != nil {
return nil, err
}
if result.IsError {
return &result, apperrors.NewAPI(
extractMCPErrorMessage(result),
apperrors.WithOperation("tools/call"),
apperrors.WithReason("mcp_tool_error"),
apperrors.WithServerKey(serverID),
)
}
return &result, nil
}
func (r *recoveryRuntime) resolveEndpoint(_ context.Context, productID, toolName string) (string, error) {
if endpoint, ok := directRuntimeEndpoint(productID, toolName); ok {
return endpoint, nil
}
return "", endpointNotResolvedError(productID, toolName, "no dynamic endpoint registered for product or tool")
}
func recoveryRuntimeToken(flags *GlobalFlags) string {
if flags == nil {
return ""
}
return strings.TrimSpace(flags.Token)
}
func toRecoveryToolResponse(result *transport.ToolCallResult) *recovery.ToolResponse {
if result == nil {
return nil
}
response := &recovery.ToolResponse{IsError: result.IsError}
if len(result.Blocks) > 0 {
response.Content = make([]recovery.ToolResponseBlock, 0, len(result.Blocks))
for _, block := range result.Blocks {
response.Content = append(response.Content, recovery.ToolResponseBlock{
Type: block.Type,
Text: block.Text,
})
}
}
return response
}
func parseDocSearchItems(result *transport.ToolCallResult) []recovery.DocSearchItem {
if result == nil {
return nil
}
if items := parseDocSearchItemsFromMap(result.Content); len(items) > 0 {
return items
}
for _, block := range result.Blocks {
var payload map[string]any
if err := json.Unmarshal([]byte(block.Text), &payload); err == nil {
if items := parseDocSearchItemsFromMap(payload); len(items) > 0 {
return items
}
}
}
return nil
}
func parseDocSearchItemsFromMap(payload map[string]any) []recovery.DocSearchItem {
if len(payload) == 0 {
return nil
}
if items := toDocSearchItems(payload["items"]); len(items) > 0 {
return items
}
if data, ok := payload["data"].(map[string]any); ok {
if items := toDocSearchItems(data["items"]); len(items) > 0 {
return items
}
}
if result, ok := payload["result"].(map[string]any); ok {
if items := toDocSearchItems(result["items"]); len(items) > 0 {
return items
}
}
return nil
}
func toDocSearchItems(raw any) []recovery.DocSearchItem {
list, ok := raw.([]any)
if !ok {
return nil
}
items := make([]recovery.DocSearchItem, 0, len(list))
for _, entry := range list {
object, ok := entry.(map[string]any)
if !ok {
continue
}
item := recovery.DocSearchItem{}
if title, ok := object["title"].(string); ok {
item.Title = title
}
if url, ok := object["url"].(string); ok {
item.URL = url
}
if desc, ok := object["desc"].(string); ok {
item.Desc = desc
}
if item.Title != "" || item.URL != "" || item.Desc != "" {
items = append(items, item)
}
}
return items
}
func rerankDocSearchHits(query string, rc recovery.RecoveryContext, items []recovery.DocSearchItem) []recovery.KBHit {
if len(items) == 0 {
return nil
}
keywords := strings.Fields(strings.ToLower(strings.TrimSpace(query)))
type scoredHit struct {
hit recovery.KBHit
score float64
}
scored := make([]scoredHit, 0, len(items))
for _, item := range items {
text := strings.ToLower(strings.Join(append([]string{
item.Title,
item.URL,
item.Desc,
rc.ToolName,
}, rc.CommandPath...), " "))
score := 0.0
for _, keyword := range keywords {
if strings.Contains(text, keyword) {
score += 1
}
}
scored = append(scored, scoredHit{
hit: recovery.KBHit{
Source: "open_platform_docs",
Title: item.Title,
URL: item.URL,
Snippet: item.Desc,
Score: score,
},
score: score,
})
}
sort.SliceStable(scored, func(i, j int) bool {
return scored[i].score > scored[j].score
})
limit := len(scored)
if limit > 3 {
limit = 3
}
hits := make([]recovery.KBHit, 0, limit)
for _, item := range scored[:limit] {
hits = append(hits, item.hit)
}
return hits
}
@@ -0,0 +1,110 @@
// 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 (
"bytes"
"errors"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageRecoveryDeprecatedUnsupportedShim(t *testing.T) {
root := NewRootCommand()
group := mustFindCommand(t, root, "recovery")
if group.Hidden || group.Deprecated == "" || !group.Runnable() {
t.Fatalf("recovery group contract: hidden=%v deprecated=%q runnable=%v", group.Hidden, group.Deprecated, group.Runnable())
}
for _, leaf := range []string{"plan", "execute", "finalize"} {
cmd := mustFindCommand(t, root, "recovery", leaf)
if cmd.Hidden || cmd.Deprecated == "" || !cmd.Runnable() {
t.Fatalf("recovery %s contract: hidden=%v deprecated=%q runnable=%v", leaf, cmd.Hidden, cmd.Deprecated, cmd.Runnable())
}
wantFlags := []string{"event-id"}
switch leaf {
case "plan", "execute":
wantFlags = append(wantFlags, "last")
case "finalize":
wantFlags = append(wantFlags, "outcome", "execution-file")
}
for _, flag := range wantFlags {
if cmd.Flags().Lookup(flag) == nil {
t.Fatalf("recovery %s missing --%s", leaf, flag)
}
}
for _, child := range newRecoveryCommand().Commands() {
if child.Name() != leaf {
continue
}
if err := child.RunE(child, nil); err == nil || !strings.Contains(err.Error(), "不再支持") {
t.Fatalf("recovery %s RunE = %v, want 不再支持", leaf, err)
}
}
}
for _, format := range []string{"", "json", "pretty", "table"} {
var out bytes.Buffer
cmd := &cobra.Command{Use: "dws"}
cmd.PersistentFlags().String("format", format, "")
cmd.SetOut(&out)
sub := &cobra.Command{Use: "recovery"}
cmd.AddCommand(sub)
err := printRecoveryUnsupported(sub, "dws recovery plan")
if err == nil {
t.Fatalf("format=%q returned nil error", format)
}
typed, ok := err.(*apperrors.Error)
if !ok || typed.Category != apperrors.CategoryValidation {
t.Fatalf("format=%q error = %T/%v, want validation Error", format, err, err)
}
got := out.String() + err.Error()
if !strings.Contains(got, "不再支持") {
t.Fatalf("format=%q missing 不再支持:\n%s", format, got)
}
if format == "" || format == "json" || format == "pretty" {
if !strings.Contains(got, `"status":"unsupported"`) && !strings.Contains(got, `"status": "unsupported"`) {
t.Fatalf("format=%q missing unsupported JSON status:\n%s", format, got)
}
}
}
for _, format := range []string{"json", "pretty", "table"} {
cmd := &cobra.Command{Use: "dws"}
cmd.PersistentFlags().String("format", format, "")
cmd.SetOut(failWriter{})
sub := &cobra.Command{Use: "recovery"}
cmd.AddCommand(sub)
if err := printRecoveryUnsupported(sub, "dws recovery plan"); err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("format=%q write failure = %v, want write failed", format, err)
}
}
if err := newRecoveryCommand().RunE(newRecoveryCommand(), nil); err == nil || !strings.Contains(err.Error(), "不再支持") {
t.Fatalf("recovery parent RunE = %v, want 不再支持", err)
}
captureRuntimeFailure(executor.Invocation{}, nil, nil)
}
type failWriter struct{}
func (failWriter) Write([]byte) (int, error) {
return 0, errWriteFailed
}
var errWriteFailed = errors.New("write failed")
@@ -1,151 +0,0 @@
package app
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func recoveryCoverageRun(cmdArgs ...string) (string, error) {
cmd := newRecoveryCommand(&GlobalFlags{})
out := &strings.Builder{}
cmd.SetOut(out)
cmd.SetErr(io.Discard)
cmd.SetArgs(cmdArgs)
err := cmd.Execute()
return out.String(), err
}
func TestCrossPlatformCoverageRecoveryCommandRemainingCoverage(t *testing.T) {
oldSavePlan, oldSaveAnalysis := recoverySavePlan, recoverySaveAnalysis
t.Cleanup(func() {
recoverySavePlan, recoverySaveAnalysis = oldSavePlan, oldSaveAnalysis
})
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
store := recovery.NewStore(configDir)
last, err := store.Capture(recovery.RecoveryContext{ServerID: "doc", ToolName: "get"})
if err != nil {
t.Fatal(err)
}
recoverySavePlan = func(*recovery.Store, string, recovery.RecoveryPlan) error { return errors.New("save plan") }
if _, err := recoveryCoverageRun("plan", "--last"); err == nil {
t.Fatal("injected plan save failure succeeded")
}
recoverySavePlan = oldSavePlan
recoverySaveAnalysis = func(*recovery.Store, string, recovery.RecoveryPlan, recovery.RecoveryBundle) error {
return errors.New("save analysis")
}
if _, err := recoveryCoverageRun("execute", "--last"); err == nil {
t.Fatal("injected analysis save failure succeeded")
}
recoverySaveAnalysis = oldSaveAnalysis
parent := newRecoveryCommand(nil)
parent.SetOut(io.Discard)
if err := parent.RunE(parent, nil); err != nil {
t.Fatal(err)
}
if out, err := recoveryCoverageRun("plan", "--last"); err != nil || !strings.Contains(out, last.EventID) {
t.Fatalf("recovery plan = %q, %v", out, err)
}
if out, err := recoveryCoverageRun("execute", "--event-id", last.EventID); err != nil || out == "" {
t.Fatalf("recovery execute = %q, %v", out, err)
}
for _, args := range [][]string{
{"finalize"},
{"finalize", "--event-id", last.EventID},
{"finalize", "--event-id", last.EventID, "--outcome", "unknown"},
{"finalize", "--event-id", last.EventID, "--outcome", "recovered", "--execution-file", "missing"},
} {
if _, err := recoveryCoverageRun(args...); err == nil {
t.Fatalf("recovery finalize %#v should fail", args)
}
}
executionPath := filepath.Join(t.TempDir(), "execution.json")
if err := os.WriteFile(executionPath, []byte(`{"action":"retry","attempt":1,"result":"ok"}`), 0o600); err != nil {
t.Fatal(err)
}
if out, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "handoff", "--execution-file", executionPath); err != nil || !strings.Contains(out, "execution_recorded") {
t.Fatalf("recovery finalize = %q, %v", out, err)
}
if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "failed"); err != nil {
t.Fatal(err)
}
if _, err := loadRecoverySnapshot(store, true, last.EventID); err == nil {
t.Fatal("conflicting snapshot selectors should fail")
}
if _, err := loadRecoverySnapshot(store, false, "missing"); err == nil {
t.Fatal("missing event snapshot should fail")
}
if _, err := loadRecoverySnapshot(store, false, ""); err == nil {
t.Fatal("empty snapshot selector should fail")
}
missingStore := recovery.NewStore(t.TempDir())
if _, err := loadRecoverySnapshot(missingStore, true, ""); err == nil {
t.Fatal("missing latest snapshot should fail")
}
eventsPath := filepath.Join(configDir, "recovery", "recovery_events.jsonl")
if err := os.Remove(eventsPath); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(eventsPath, 0o700); err != nil {
t.Fatal(err)
}
if _, err := recoveryCoverageRun("plan", "--last"); err == nil {
t.Fatal("recovery plan save should fail")
}
if _, err := recoveryCoverageRun("execute", "--last"); err == nil {
t.Fatal("recovery analysis save should fail")
}
if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "recovered"); err == nil {
t.Fatal("recovery finalization save should fail")
}
}
func TestCrossPlatformCoverageRecoveryExecutionAndRuntimeRemainingCoverage(t *testing.T) {
t.Setenv("DINGTALK_DEVDOC_MCP_URL", "http://127.0.0.1:1")
path := filepath.Join(t.TempDir(), "execution.json")
if err := os.WriteFile(path, []byte(`{"attempts":{}}`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := loadRecoveryExecution(path); err == nil {
t.Fatal("invalid attempts should fail")
}
if _, err := decodeRecoveryAttempts([]byte(`[{}`), nil, "", ""); err == nil {
t.Fatal("invalid attempt array should fail")
}
SetDynamicServers(nil)
runtime := &recoveryRuntime{
transport: transport.NewClient(nil),
flags: &GlobalFlags{Token: "token"},
}
if _, err := runtime.CallToolDirect(context.Background(), "missing", "tool", nil); err == nil || !strings.Contains(err.Error(), `endpoint not resolved for product "missing" (tool "tool")`) {
t.Fatalf("direct resolution error = %v", err)
}
if got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{}); err == nil || got.DocSearch.Status != "error" {
t.Fatalf("search error = %#v, %v", got, err)
}
if got := parseDocSearchItems(&transport.ToolCallResult{Content: map[string]any{}, Blocks: []transport.ContentBlock{{Text: "not-json"}}}); got != nil {
t.Fatalf("empty doc search items = %#v", got)
}
for _, payload := range []map[string]any{
{"data": map[string]any{}},
{"result": map[string]any{}},
} {
if got := parseDocSearchItemsFromMap(payload); got != nil {
t.Fatalf("empty nested doc search items = %#v", got)
}
}
}
+4 -88
View File
@@ -1,94 +1,10 @@
package app
import (
"os"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
)
func captureRuntimeFailure(invocation executor.Invocation, rawErr, wrappedErr error) {
if rawErr == nil && wrappedErr == nil {
return
}
store := recovery.NewStore(defaultConfigDir())
if store == nil || !store.Enabled() {
return
}
input := recovery.CaptureInput{
CommandPath: runtimeCommandPath(invocation),
ServerID: strings.TrimSpace(invocation.CanonicalProduct),
ToolName: strings.TrimSpace(invocation.Tool),
Args: cloneRecoveryArgs(invocation.Params),
Argv: append([]string(nil), os.Args[1:]...),
RawErr: rawErr,
WrappedErr: wrappedErr,
}
_, _ = store.Capture(recovery.BuildContext(input), recovery.BuildReplay(input))
}
func runtimeCommandPath(invocation executor.Invocation) []string {
if path := currentCommandPath(); len(path) > 0 {
return path
}
if legacy := strings.Fields(strings.TrimSpace(invocation.LegacyPath)); len(legacy) > 0 {
return legacy
}
if product := strings.TrimSpace(invocation.CanonicalProduct); product != "" {
if tool := strings.TrimSpace(invocation.Tool); tool != "" {
return []string{product, tool}
}
return []string{product}
}
return nil
}
func currentCommandPath() []string {
boolFlags := map[string]struct{}{
"--verbose": {},
"-v": {},
"--debug": {},
"--mock": {},
"--dry-run": {},
"--yes": {},
"-y": {},
"--help": {},
"-h": {},
"--json": {},
}
path := make([]string, 0, len(os.Args))
skipNext := false
for _, arg := range os.Args[1:] {
if skipNext {
skipNext = false
continue
}
if arg == "--" {
break
}
if strings.HasPrefix(arg, "-") {
if strings.Contains(arg, "=") {
continue
}
if _, ok := boolFlags[arg]; ok {
continue
}
skipNext = true
continue
}
path = append(path, arg)
}
return path
}
func cloneRecoveryArgs(args map[string]any) map[string]any {
if len(args) == 0 {
return nil
}
out := make(map[string]any, len(args))
for key, value := range args {
out[key] = value
}
return out
}
// captureRuntimeFailure previously persisted a recovery snapshot for
// `dws recovery`. The recovery package is gone; keep a no-op seam so runner
// failure paths stay stable while the visible Deprecated shim remains.
func captureRuntimeFailure(_ executor.Invocation, _, _ error) {}
+18 -12
View File
@@ -39,7 +39,6 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
@@ -51,15 +50,11 @@ import (
type outputFileContextKey struct{}
const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID="
var (
rootNormalizeProcessProfileArgs = normalizeProcessProfileArgs
rootExecuteCommand = (*cobra.Command).ExecuteC
rootNewRootCommandWithEngine = NewRootCommandWithEngine
rootRunPreParse = pipeline.RunPreParse
rootLatestRecoveryCapture = recovery.LatestCapture
rootResetRecoveryState = recovery.ResetRuntimeState
rootStopAllStdioClients = StopAllStdioClients
rootLoadPlugins = loadPlugins
rootMkdirAll = os.MkdirAll
@@ -107,7 +102,6 @@ func Execute() (exitCode int) {
ctx = WithTimingCollector(ctx, timing)
initStart := time.Now()
rootResetRecoveryState()
engine := newPipelineEngine()
root := rootNewRootCommandWithEngine(ctx, engine)
timing.Record("cmd_init", time.Since(initStart))
@@ -133,9 +127,6 @@ func Execute() (exitCode int) {
_, _ = fmt.Fprintln(os.Stderr)
}
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
if last := rootLatestRecoveryCapture(); last != nil && last.EventID != "" {
_, _ = fmt.Fprintf(os.Stderr, "%s%s\n", recoveryEventStderrPrefix, last.EventID)
}
return apperrors.ExitCode(err)
}
return 0
@@ -144,6 +135,9 @@ func Execute() (exitCode int) {
// newPreParseValidationError keeps pipeline handler identity in internal logs
// while exposing only the underlying parameter-domain error to CLI users.
func newPreParseValidationError(err error) error {
if structured, ok := err.(*apperrors.Error); ok {
return structured
}
userErr := err
var handlerErr *pipeline.HandlerError
if stderrors.As(err, &handlerErr) && handlerErr.Unwrap() != nil {
@@ -477,10 +471,10 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
newCatalogCommand(),
newConfigCommand(),
newDoctorCommand(),
newRecoveryCommand(),
newEventCommand(),
newAuditCommand(),
newCompletionCommand(root),
newRecoveryCommand(flags),
newUpgradeCommand(),
newVersionCommand(),
newPluginCommand(),
@@ -724,8 +718,8 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
var builtinCommandNames = map[string]bool{
"auth": true, "api": true, "audit": true, "cache": true, "config": true,
"doctor": true, "event": true, "completion": true, "skill": true,
"plugin": true, "profile": true, "version": true, "help": true,
"recovery": true, "schema": true, "mcp": true, "upgrade": true,
"plugin": true, "profile": true, "recovery": true, "version": true, "help": true,
"schema": true, "mcp": true, "upgrade": true,
}
// commandNameSet returns a new set containing every name in base plus extras.
@@ -1333,6 +1327,18 @@ func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
// - PostResponse: after transport returns, before stdout (canonical RunE)
func newPipelineEngine() *pipeline.Engine {
engine := pipeline.NewEngine()
engine.SetCommandPathFallbackLookup(func(path string) (pipeline.CommandPathFallback, bool) {
entry, ok := cli.LookupCommandPathFallback(path)
if !ok {
return pipeline.CommandPathFallback{}, false
}
return pipeline.CommandPathFallback{
From: entry.From,
Mode: string(entry.Mode),
To: entry.To,
Candidates: append([]string(nil), entry.Candidates...),
}, true
})
engine.RegisterAll(
// Register handler runs during command tree building.
handlers.RegisterHandler{},
-8
View File
@@ -12,7 +12,6 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
@@ -24,8 +23,6 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
oldPreParse := rootRunPreParse
oldLatest := rootLatestRecoveryCapture
oldReset := rootResetRecoveryState
oldStop := rootStopAllStdioClients
oldArgs := os.Args
t.Cleanup(func() {
@@ -33,20 +30,16 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
rootExecuteCommand = oldExecute
rootNewRootCommandWithEngine = oldNewRoot
rootRunPreParse = oldPreParse
rootLatestRecoveryCapture = oldLatest
rootResetRecoveryState = oldReset
rootStopAllStdioClients = oldStop
os.Args = oldArgs
})
os.Args = []string{"dws"}
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
rootResetRecoveryState = func() {}
rootStopAllStdioClients = func() {}
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
}
rootLatestRecoveryCapture = func() *recovery.LastError { return nil }
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil }
if code := Execute(); code != 0 {
t.Fatalf("successful Execute code = %d", code)
@@ -59,7 +52,6 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
wantErr := errors.New("unknown command missing")
rootLatestRecoveryCapture = func() *recovery.LastError { return &recovery.LastError{EventID: "evt-test"} }
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr }
if code := Execute(); code == 0 {
t.Fatal("failed Execute returned zero")
+7 -13
View File
@@ -419,20 +419,14 @@ func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
}
}
func TestCacheRefreshCompatibilityStub(t *testing.T) {
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"cache", "refresh", "--format", "json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("cache refresh compatibility stub: %v\n%s", err, out.String())
func TestCacheCommandDeprecatedCompatStub(t *testing.T) {
root := NewRootCommand()
cmd, _, err := root.Find([]string{"cache", "refresh"})
if err != nil || cmd == nil || cmd == root {
t.Fatalf("dws cache refresh compatibility stub missing: %v", err)
}
got := out.String()
for _, want := range []string{`"status":"deprecated"`, `"command":"dws cache refresh"`, "服务发现已下线"} {
if !strings.Contains(got, want) {
t.Fatalf("cache refresh output missing %q:\n%s", want, got)
}
if cmd.Hidden || cmd.Deprecated == "" {
t.Fatalf("cache refresh must be visible Deprecated: hidden=%v deprecated=%q", cmd.Hidden, cmd.Deprecated)
}
}
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 294
publicShortcutCount = 357
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including hidden leaves such as minutes.shortcut_minutes_search.
schemaPublishedShortcutCount = 295
schemaPublishedShortcutCount = 358
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 294
publiclyDeliveredShortcutCount = 357
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -114,7 +114,7 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
product := executeShortcutSchemaQuery(t, "chat")
productPayload, _ := product["product"].(map[string]any)
if got, want := int(product["count"].(float64)), 180; got != want {
if got, want := int(product["count"].(float64)), 187; got != want {
t.Fatalf("schema chat count = %d, want %d", got, want)
}
summaries := schemaContractObjectSlice(productPayload["tools"])
+7 -20
View File
@@ -75,10 +75,8 @@ func newSkillSetupCommand() *cobra.Command {
Long: `安装 dws 自身 skill 文档到 AI Agent 目录(如 ~/.claude/skills/、~/.cursor/skills/ 等)。
支持两种模式:
mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
multi 🧪 EXPERIMENTAL 多 skill(试验版 / Preview)—— 按产品拆 N 个独立 skill
尚未达到 stable 标准,接口、命名与跨 skill 引用可能变动;
生产前请评估,问题请提 issue 反馈
mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
multi 多 skill—— 按产品拆 N 个独立 skill
multi 模式支持按产品挑选:
-s/--skill 只装指定子 skill(可重复,短名 aitable 或全名 dingtalk-aitable 均可)
@@ -150,7 +148,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
if filterErr != nil {
return filterErr
}
// dws-shared carries the global rules every product skill declares as a
// 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)
}
@@ -177,8 +175,6 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
fmt.Fprintln(out, "已取消。")
return nil
}
} else if mode == skillSetupModeMulti {
fmt.Fprintln(errOut, "🧪 multi 模式当前为 EXPERIMENTAL(试验版 / Preview)—— 接口与布局可能变动,稳定版请用 --mode mono")
}
var installed, skipped int
@@ -205,8 +201,8 @@ const multiSkillPrefix = "dingtalk-"
// multiSharedSkill is the shared, non-product skill that every per-product
// skill declares as a PREREQUISITE. It must always be installed in multi mode
// regardless of --skill / --exclude, otherwise the product skills reference a
// dws-shared that was never installed.
const multiSharedSkill = "dws-shared"
// dingtalk-shared that was never installed.
const multiSharedSkill = "dingtalk-shared"
// ensureMandatorySharedSkill guarantees the shared dependency skill is included
// whenever it exists in the source, even if --skill / --exclude narrowed it out.
@@ -365,10 +361,10 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
huh.NewGroup(
huh.NewSelect[string]().
Title("选择 dws skill 安装模式").
Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分(🧪 EXPERIMENTAL / 试验版,未达 stable,接口可能变动)").
Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分的独立 skill").
Options(
huh.NewOption("mono — 单 skill(稳定 / 推荐)", skillSetupModeMono),
huh.NewOption("multi — 多 skill(🧪 EXPERIMENTAL · 试验版)", skillSetupModeMulti),
huh.NewOption("multi — 多 skill(按产品拆分)", skillSetupModeMulti),
).
Value(&choice),
),
@@ -526,15 +522,6 @@ func detectExistingAgentHomes(home, mode string) []string {
}
func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSkillNames []string) (bool, error) {
if mode == skillSetupModeMulti {
fmt.Fprintln(out, "\n🧪 ─────────────────────────────────────────────────────────────")
fmt.Fprintln(out, " multi 模式当前为 EXPERIMENTAL(试验版 / Preview)")
fmt.Fprintf(out, " · 当前选择的 %d 个独立 skill 均跑过 verifier,可用但未达 stable\n", len(multiSkillNames))
fmt.Fprintln(out, " · 跨 skill 引用、bundle 命名、目录布局后续可能调整")
fmt.Fprintln(out, " · 不建议在生产 / 共享环境直接落地;问题请提 issue 反馈")
fmt.Fprintln(out, " 稳定版请用 --mode mono")
fmt.Fprintln(out, "🧪 ─────────────────────────────────────────────────────────────")
}
fmt.Fprintf(out, "\n📦 将安装 skill:\n mode: %s\n source: %s\n", mode, src)
if mode == skillSetupModeMulti {
fmt.Fprintf(out, " 将装 %d 个独立 skill(按子目录平铺到 <agent-home>/<skill-name>/):\n", len(multiSkillNames))
+4 -4
View File
@@ -58,7 +58,7 @@ func TestMaterializeEmbeddedSkillSourceMono(t *testing.T) {
}
// TestMaterializeEmbeddedSkillSourceMulti verifies that the peer multi bundle
// contains both the shared routing skill and the PAT product skill. Structured
// 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.
func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
@@ -71,9 +71,9 @@ 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("dws-shared", "SKILL.md"),
filepath.Join("dingtalk-pat", "SKILL.md"),
filepath.Join("dingtalk-pat", "references", "pat.md"),
filepath.Join("dingtalk-shared", "SKILL.md"),
filepath.Join("dingtalk-misc", "SKILL.md"),
filepath.Join("dingtalk-misc", "references", "pat.md"),
} {
if _, err := os.Stat(filepath.Join(dir, rel)); err != nil {
t.Errorf("expected embedded multi skill to contain %s: %v", rel, err)
@@ -73,7 +73,7 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("empty multi source should fail")
}
skillSetupListMulti = func(string) ([]string, error) { return []string{"dws-shared", "dingtalk-doc"}, nil }
skillSetupListMulti = func(string) ([]string, error) { return []string{"dingtalk-shared", "dingtalk-doc"}, nil }
skillSetupFilterMulti = func([]string, []string, []string) ([]string, error) { return nil, fail }
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.RunE(cmd, nil); err == nil {
@@ -0,0 +1,633 @@
{
"source_report": "param_hallucination_20260728_120943",
"source_file": "raw_dashscope_qwen3_7-max_20260728_120943.json",
"source_dws_commit": "a8e83e5",
"model": "dashscope/qwen3.7-max",
"expected_count": 52,
"badcases": [
{
"id": "dws_im_v3_0003-run10-command0",
"case_id": "dws_im_v3_0003",
"run": 10,
"command_index": 0,
"turn": 1,
"source_path": "chat +members",
"expected_outcome": "rewrite",
"raw": "dws chat +members --group \"dws测试群01\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0003-run10-command1",
"case_id": "dws_im_v3_0003",
"run": 10,
"command_index": 1,
"turn": 1,
"source_path": "chat +members",
"expected_outcome": "rewrite",
"raw": "dws chat +members --group-name \"dws测试群01\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0003-run10-command2",
"case_id": "dws_im_v3_0003",
"run": 10,
"command_index": 2,
"turn": 1,
"source_path": "chat +members",
"expected_outcome": "rewrite",
"raw": "dws chat +members \"dws测试群01\" --format json",
"original_exit_code": 5,
"is_help": false
},
{
"id": "dws_im_v3_0010-run1-command1",
"case_id": "dws_im_v3_0010",
"run": 1,
"command_index": 1,
"turn": 1,
"source_path": "chat +send-dm",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-dm --open-dingtalk-id DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS --content \"评测任务更新了,稍后同步\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0010-run1-command2",
"case_id": "dws_im_v3_0010",
"run": 1,
"command_index": 2,
"turn": 1,
"source_path": "chat +send-dm",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-dm --dingtalk-id DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS --content \"评测任务更新了,稍后同步\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0010-run8-command4",
"case_id": "dws_im_v3_0010",
"run": 8,
"command_index": 4,
"turn": 1,
"source_path": "chat +send-single",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-single --user-id \"DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS\" --content \"评测任务更新了,稍后同步\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0010-run10-command0",
"case_id": "dws_im_v3_0010",
"run": 10,
"command_index": 0,
"turn": 1,
"source_path": "chat +send-by-bot",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-by-bot --open-dingtalk-id \"段旭彬\" --message \"评测任务更新了,稍后同步\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0010-run10-command1",
"case_id": "dws_im_v3_0010",
"run": 10,
"command_index": 1,
"turn": 1,
"source_path": "chat +send-by-bot",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-by-bot --open-id \"段旭彬\" --msg \"评测任务更新了,稍后同步\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0010-run10-command2",
"case_id": "dws_im_v3_0010",
"run": 10,
"command_index": 2,
"turn": 1,
"source_path": "chat +send-by-bot",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-by-bot \"段旭彬\" \"评测任务更新了,稍后同步\" --format json",
"original_exit_code": 5,
"is_help": false
},
{
"id": "dws_im_v3_0011-run1-command3",
"case_id": "dws_im_v3_0011",
"run": 1,
"command_index": 3,
"turn": 1,
"source_path": "chat +send-media",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-media --user-id \"489149\" --file-path \"建筑.jpeg\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0011-run1-command4",
"case_id": "dws_im_v3_0011",
"run": 1,
"command_index": 4,
"turn": 1,
"source_path": "chat +send-media",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-media --receiver \"489149\" --file-path \"建筑.jpeg\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0011-run9-command1",
"case_id": "dws_im_v3_0011",
"run": 9,
"command_index": 1,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --user-id 489149 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case__ysfhvr3/建筑.jpeg\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0011-run9-command2",
"case_id": "dws_im_v3_0011",
"run": 9,
"command_index": 2,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --dry-run --format json 2>&1 | head -50",
"original_exit_code": 0,
"is_help": false
},
{
"id": "dws_im_v3_0012-run1-command2",
"case_id": "dws_im_v3_0012",
"run": 1,
"command_index": 2,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_bc6azkyi/streamlit.docx\" --to \"符咏畅\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run1-command3",
"case_id": "dws_im_v3_0012",
"run": 1,
"command_index": 3,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_bc6azkyi/streamlit.docx\" --user \"符咏畅\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run2-command3",
"case_id": "dws_im_v3_0012",
"run": 2,
"command_index": 3,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --receiver 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_3er98f5m/streamlit.docx\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run2-command8",
"case_id": "dws_im_v3_0012",
"run": 2,
"command_index": 8,
"turn": 1,
"source_path": "chat +send",
"expected_outcome": "ambiguous",
"raw": "dws chat +send --user 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_3er98f5m/streamlit.docx\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run3-command1",
"case_id": "dws_im_v3_0012",
"run": 3,
"command_index": 1,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --user-id 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_cljycf8p/streamlit.docx\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run3-command2",
"case_id": "dws_im_v3_0012",
"run": 3,
"command_index": 2,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --receiver 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_cljycf8p/streamlit.docx\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run4-command3",
"case_id": "dws_im_v3_0012",
"run": 4,
"command_index": 3,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --help",
"original_exit_code": 64,
"is_help": true
},
{
"id": "dws_im_v3_0012-run6-command4",
"case_id": "dws_im_v3_0012",
"run": 6,
"command_index": 4,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --user 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_8ef9dsey/streamlit.docx\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run6-command5",
"case_id": "dws_im_v3_0012",
"run": 6,
"command_index": 5,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --to \"519483\" --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_8ef9dsey/streamlit.docx\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run6-command6",
"case_id": "dws_im_v3_0012",
"run": 6,
"command_index": 6,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --name \"符咏畅\" --path \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_8ef9dsey/streamlit.docx\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run7-command1",
"case_id": "dws_im_v3_0012",
"run": 7,
"command_index": 1,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --user-id \"519483\" --file-path \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_fjcfinmi/streamlit.docx\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run7-command2",
"case_id": "dws_im_v3_0012",
"run": 7,
"command_index": 2,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --format json --userid \"519483\" --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_fjcfinmi/streamlit.docx\"",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0016-run1-command7",
"case_id": "dws_im_v3_0016",
"run": 1,
"command_index": 7,
"turn": 3,
"source_path": "chat +rename-group",
"expected_outcome": "rewrite",
"raw": "dws chat +rename-group --id \"cideWy3UCPNZ4k8Mr4BL/8KRQ==\" --name \"测试废物群-20260713\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0017-run2-command6",
"case_id": "dws_im_v3_0017",
"run": 2,
"command_index": 6,
"turn": 2,
"source_path": "chat +send-message",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-message --group \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --content \"@陈邦杰 请确认下评测环境\" --mention \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0017-run2-command7",
"case_id": "dws_im_v3_0017",
"run": 2,
"command_index": 7,
"turn": 2,
"source_path": "chat +send-message",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-message --open-conversation-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --content \"@陈邦杰 请确认下评测环境\" --mention \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0017-run2-command9",
"case_id": "dws_im_v3_0017",
"run": 2,
"command_index": 9,
"turn": 2,
"source_path": "chat +send-message",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-message --format json 2>&1",
"original_exit_code": 5,
"is_help": false
},
{
"id": "dws_im_v3_0017-run5-command0",
"case_id": "dws_im_v3_0017",
"run": 5,
"command_index": 0,
"turn": 1,
"source_path": "chat +group-member-list",
"expected_outcome": "rewrite",
"raw": "dws chat +group-member-list --group-name \"dws测试群01\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0017-run6-command6",
"case_id": "dws_im_v3_0017",
"run": 6,
"command_index": 6,
"turn": 2,
"source_path": "chat +send-text",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-text --open-conversation-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --text \"@陈邦杰 请确认下评测环境\" --at-open-dingtalk-ids \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0017-run6-command7",
"case_id": "dws_im_v3_0017",
"run": 6,
"command_index": 7,
"turn": 2,
"source_path": "chat +send-text",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-text --conversation-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --content \"@陈邦杰 请确认下评测环境\" --at-user-ids \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0017-run6-command8",
"case_id": "dws_im_v3_0017",
"run": 6,
"command_index": 8,
"turn": 2,
"source_path": "chat +group-send-text",
"expected_outcome": "ambiguous",
"raw": "dws chat +group-send-text --group-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --text \"@陈邦杰 请确认下评测环境\" --at-open-dingtalk-ids \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0018-run10-command5",
"case_id": "dws_im_v3_0018",
"run": 10,
"command_index": 5,
"turn": 1,
"source_path": "chat +send-image",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-image --group \"cidyfbDkUpmy4ReyI7vfZy+zw==\" --file-path \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_z7y6p6_0/dog.png\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0019-run4-command1",
"case_id": "dws_im_v3_0019",
"run": 4,
"command_index": 1,
"turn": 1,
"source_path": "chat +send-image",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-image --user-id 489149 --image /private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_wsppmjwr/landscape.jpg --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0019-run4-command2",
"case_id": "dws_im_v3_0019",
"run": 4,
"command_index": 2,
"turn": 1,
"source_path": "chat +send-image",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-image --to 489149 --file /private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_wsppmjwr/landscape.jpg --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0019-run6-command3",
"case_id": "dws_im_v3_0019",
"run": 6,
"command_index": 3,
"turn": 1,
"source_path": "chat +send",
"expected_outcome": "ambiguous",
"raw": "dws chat +send --receiver-id 489149 --image \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_09930iej/landscape.jpg\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0019-run8-command2",
"case_id": "dws_im_v3_0019",
"run": 8,
"command_index": 2,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --user-id 489149 --file \"landscape.jpg\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0019-run8-command3",
"case_id": "dws_im_v3_0019",
"run": 8,
"command_index": 3,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --receiver-id 489149 --file \"landscape.jpg\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0019-run8-command6",
"case_id": "dws_im_v3_0019",
"run": 8,
"command_index": 6,
"turn": 1,
"source_path": "chat +send",
"expected_outcome": "ambiguous",
"raw": "dws chat +send --to 489149 --file \"landscape.jpg\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0019-run8-command9",
"case_id": "dws_im_v3_0019",
"run": 8,
"command_index": 9,
"turn": 1,
"source_path": "chat +send-to",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-to --uid 489149 --file \"landscape.jpg\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0019-run9-command0",
"case_id": "dws_im_v3_0019",
"run": 9,
"command_index": 0,
"turn": 1,
"source_path": "chat +send",
"expected_outcome": "ambiguous",
"raw": "dws chat +send --receiver 489149 --file-path \"./landscape.jpg\" --format json 2>&1",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0021-run4-command3",
"case_id": "dws_im_v3_0021",
"run": 4,
"command_index": 3,
"turn": 1,
"source_path": "chat +read-single",
"expected_outcome": "ambiguous",
"raw": "dws chat +read-single --user-id 550582 --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0021-run4-command4",
"case_id": "dws_im_v3_0021",
"run": 4,
"command_index": 4,
"turn": 1,
"source_path": "chat +read-single",
"expected_outcome": "ambiguous",
"raw": "dws chat +read-single --uid 550582 --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0021-run4-command5",
"case_id": "dws_im_v3_0021",
"run": 4,
"command_index": 5,
"turn": 1,
"source_path": "chat +read-single",
"expected_outcome": "ambiguous",
"raw": "dws chat +read-single --staff-id 550582 --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0021-run4-command6",
"case_id": "dws_im_v3_0021",
"run": 4,
"command_index": 6,
"turn": 1,
"source_path": "chat +read-single",
"expected_outcome": "ambiguous",
"raw": "dws chat +read-single --open-dingtalk-id \"DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0021-run4-command7",
"case_id": "dws_im_v3_0021",
"run": 4,
"command_index": 7,
"turn": 1,
"source_path": "chat +read-single",
"expected_outcome": "ambiguous",
"raw": "dws chat +read-single --target 550582 --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0021-run4-command8",
"case_id": "dws_im_v3_0021",
"run": 4,
"command_index": 8,
"turn": 1,
"source_path": "chat +read-single",
"expected_outcome": "ambiguous",
"raw": "dws chat +read-single 550582 --format json",
"original_exit_code": 5,
"is_help": false
},
{
"id": "dws_im_v3_0021-run5-command1",
"case_id": "dws_im_v3_0021",
"run": 5,
"command_index": 1,
"turn": 1,
"source_path": "chat +message-list",
"expected_outcome": "ambiguous",
"raw": "dws chat +message-list --format json 2>&1",
"original_exit_code": 5,
"is_help": false
},
{
"id": "dws_im_v3_0028-run4-command5",
"case_id": "dws_im_v3_0028",
"run": 4,
"command_index": 5,
"turn": 1,
"source_path": "chat +list-robot",
"expected_outcome": "rewrite",
"raw": "dws chat +list-robot \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --format json",
"original_exit_code": 5,
"is_help": false
},
{
"id": "dws_im_v3_0028-run5-command15",
"case_id": "dws_im_v3_0028",
"run": 5,
"command_index": 15,
"turn": 1,
"source_path": "chat +list-group-bots",
"expected_outcome": "rewrite",
"raw": "dws chat +list-group-bots --id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0028-run9-command9",
"case_id": "dws_im_v3_0028",
"run": 9,
"command_index": 9,
"turn": 1,
"source_path": "chat +list-robots",
"expected_outcome": "rewrite",
"raw": "dws chat +list-robots --cid \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --format json",
"original_exit_code": 3,
"is_help": false
}
]
}
+2 -2
View File
@@ -71,7 +71,7 @@ func NewSchemaCommand() *cobra.Command {
Short: "渐进查看命令 Schema (产品 / 分组 / 工具参数)",
Long: `查看当前可运行命令的 Schema 元数据。
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。--all 输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)。--compact 去除 provenance / debug 字段,仅保留 Agent 选参所需信息(适合 Agent 上下文)。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。普通 Agent 查询应使用 --compact:它按稳定字段白名单输出选参、约束和安全语义。省略 --compact 的 full leaf 保留参数映射、接口绑定和 provenance,仅用于定向审计;--all 输出全部工具的完整 leaf Schema,用于审计/CI。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -113,7 +113,7 @@ func NewSchemaCommand() *cobra.Command {
},
}
cmd.Flags().Bool("all", false, "输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)")
cmd.Flags().Bool("compact", false, "去除 provenance/debug 字段,仅保留 Agent 选参所需信息")
cmd.Flags().Bool("compact", false, "按稳定字段白名单输出 Agent 选参、约束和安全语义")
cmd.Flags().String("cli-path", "", "按 CLI 命令路径查询")
return cmd
}
+289
View File
@@ -0,0 +1,289 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package cli
import (
"bytes"
_ "embed"
"encoding/json"
"fmt"
"io"
"regexp"
"sort"
"strings"
"sync"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
const commandPathFallbacksSchemaRef = "./command_path_fallbacks.schema.json"
// command_path_fallbacks.json is a reviewed recovery policy for invalid paths
// observed in evaluation. It is deliberately not a command identity source:
// Help, Skill, Schema, and normal navigation continue to publish canonical
// paths from the CommandRegistry and the real Cobra tree only.
//go:embed command_path_fallbacks.json
var embeddedCommandPathFallbacksJSON []byte
//go:embed command_path_fallbacks.schema.json
var embeddedCommandPathFallbacksSchemaJSON []byte
var commandFallbackPathPattern = regexp.MustCompile(`^[A-Za-z0-9+][A-Za-z0-9._:+-]*(?: [A-Za-z0-9+][A-Za-z0-9._:+-]*)*$`)
// CommandPathFallbackMode determines whether an invalid reviewed path can be
// normalized exactly or must stop and surface reviewed candidates.
type CommandPathFallbackMode string
const (
CommandPathFallbackRewrite CommandPathFallbackMode = "rewrite"
CommandPathFallbackAmbiguous CommandPathFallbackMode = "ambiguous"
)
// CommandPathFallback is one validated, generated runtime recovery record.
// From is never advertised as a stable alias. To and Candidates are canonical
// real Cobra paths validated at generation time.
type CommandPathFallback struct {
From string `json:"from"`
Mode CommandPathFallbackMode `json:"mode"`
To string `json:"to,omitempty"`
Candidates []string `json:"candidates,omitempty"`
Reviewed bool `json:"reviewed"`
ReviewReason string `json:"review_reason"`
}
type commandPathFallbackSnapshot struct {
Schema string `json:"$schema"`
Version int `json:"version"`
Entries []commandPathFallbackEntrySpec `json:"entries"`
}
type commandPathFallbackEntrySpec struct {
From string `json:"from"`
Mode CommandPathFallbackMode `json:"mode"`
To string `json:"to,omitempty"`
Candidates []string `json:"candidates,omitempty"`
Reviewed bool `json:"reviewed"`
ReviewReason string `json:"review_reason"`
}
var (
embeddedCommandPathFallbacksOnce sync.Once
embeddedCommandPathFallbacksData []CommandPathFallback
embeddedCommandPathFallbacksErr error
loadReviewedCommandPathFallbacks = loadEmbeddedCommandPathFallbacks
)
// LoadCommandPathFallbacks decodes and validates the authored recovery table.
// Callers receive a clone so generation and tests cannot mutate shared data.
func LoadCommandPathFallbacks() ([]CommandPathFallback, error) {
return loadReviewedCommandPathFallbacks()
}
func loadEmbeddedCommandPathFallbacks() ([]CommandPathFallback, error) {
embeddedCommandPathFallbacksOnce.Do(func() {
embeddedCommandPathFallbacksData, embeddedCommandPathFallbacksErr = decodeCommandPathFallbacks(embeddedCommandPathFallbacksJSON)
})
return cloneCommandPathFallbacks(embeddedCommandPathFallbacksData), embeddedCommandPathFallbacksErr
}
func decodeCommandPathFallbacks(data []byte) ([]CommandPathFallback, error) {
var snapshot commandPathFallbackSnapshot
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&snapshot); err != nil {
return nil, fmt.Errorf("decode reviewed command path fallbacks: %w", err)
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
err = fmt.Errorf("multiple JSON values")
}
return nil, fmt.Errorf("decode reviewed command path fallbacks: %w", err)
}
if snapshot.Version != 1 {
return nil, fmt.Errorf("unsupported command path fallbacks version %d", snapshot.Version)
}
if strings.TrimSpace(snapshot.Schema) != commandPathFallbacksSchemaRef {
return nil, fmt.Errorf("command path fallbacks must declare $schema=%q", commandPathFallbacksSchemaRef)
}
if len(snapshot.Entries) == 0 {
return nil, fmt.Errorf("command path fallbacks declares no entries")
}
entries := make([]CommandPathFallback, 0, len(snapshot.Entries))
seenFrom := make(map[string]bool, len(snapshot.Entries))
for index, spec := range snapshot.Entries {
from, err := validateAuthoredCommandFallbackPath(spec.From)
if err != nil {
return nil, fmt.Errorf("command path fallback entry %d from: %w", index, err)
}
if seenFrom[from] {
return nil, fmt.Errorf("command path fallbacks contains duplicate from path %q", from)
}
seenFrom[from] = true
reason := strings.TrimSpace(spec.ReviewReason)
if !spec.Reviewed || reason == "" {
return nil, fmt.Errorf("command path fallback %q requires reviewed=true and non-empty review_reason", from)
}
entry := CommandPathFallback{
From: from,
Mode: spec.Mode,
Reviewed: true,
ReviewReason: reason,
}
switch spec.Mode {
case CommandPathFallbackRewrite:
if len(spec.Candidates) > 0 {
return nil, fmt.Errorf("rewrite command path fallback %q must not declare candidates", from)
}
to, pathErr := validateAuthoredCommandFallbackPath(spec.To)
if pathErr != nil {
return nil, fmt.Errorf("rewrite command path fallback %q to: %w", from, pathErr)
}
entry.To = to
case CommandPathFallbackAmbiguous:
if strings.TrimSpace(spec.To) != "" {
return nil, fmt.Errorf("ambiguous command path fallback %q must not declare to", from)
}
if len(spec.Candidates) < 2 {
return nil, fmt.Errorf("ambiguous command path fallback %q requires at least two candidates", from)
}
seenCandidates := make(map[string]bool, len(spec.Candidates))
for _, rawCandidate := range spec.Candidates {
candidate, pathErr := validateAuthoredCommandFallbackPath(rawCandidate)
if pathErr != nil {
return nil, fmt.Errorf("ambiguous command path fallback %q candidate: %w", from, pathErr)
}
if seenCandidates[candidate] {
return nil, fmt.Errorf("ambiguous command path fallback %q repeats candidate %q", from, candidate)
}
seenCandidates[candidate] = true
entry.Candidates = append(entry.Candidates, candidate)
}
default:
return nil, fmt.Errorf("command path fallback %q has invalid mode %q", from, spec.Mode)
}
entries = append(entries, entry)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].From < entries[j].From })
return entries, nil
}
func validateAuthoredCommandFallbackPath(raw string) (string, error) {
path := strings.TrimSpace(raw)
if path == "" {
return "", fmt.Errorf("path is empty")
}
if path != raw || normalizeSchemaCLIPath(path) != path || !commandFallbackPathPattern.MatchString(path) {
return "", fmt.Errorf("path %q is not a normalized command path without leading dws or flags", raw)
}
return path, nil
}
// ReduceCommandPathFallbacks validates the reviewed recovery table against the
// live distribution-owned Cobra tree. It refuses to turn an existing command
// or stable alias into a hidden rewrite. The sole exception is an explicitly
// annotated hint-only compatibility node, which contains no business action.
func ReduceCommandPathFallbacks(root *cobra.Command) ([]CommandPathFallback, error) {
if root == nil {
return nil, fmt.Errorf("command path fallback source root is nil")
}
entries, err := LoadCommandPathFallbacks()
if err != nil {
return nil, err
}
byFrom := make(map[string]CommandPathFallback, len(entries))
for _, entry := range entries {
byFrom[entry.From] = entry
}
var problems []string
for _, entry := range entries {
match, resolveErr := resolveExactCobraPath(root, entry.From)
if resolveErr != nil {
problems = append(problems, fmt.Sprintf("from %q cannot be resolved safely: %v", entry.From, resolveErr))
} else if match.Command != nil && !cmdutil.IsHintOnlyCommand(match.Command) {
problems = append(problems, fmt.Sprintf("from %q collides with a real Cobra command or alias", entry.From))
}
switch entry.Mode {
case CommandPathFallbackRewrite:
if _, chained := byFrom[entry.To]; chained {
problems = append(problems, fmt.Sprintf("rewrite %q targets fallback source %q; chained fallbacks are forbidden", entry.From, entry.To))
}
problems = append(problems, validateCommandFallbackTarget(root, entry.From, entry.To, true)...)
case CommandPathFallbackAmbiguous:
for _, candidate := range entry.Candidates {
if _, chained := byFrom[candidate]; chained {
problems = append(problems, fmt.Sprintf("ambiguous fallback %q candidate %q is another fallback source", entry.From, candidate))
}
// Ambiguous recovery never dispatches. It may therefore point the
// caller at both canonical shortcuts and native leaves, while exact
// rewrites keep the stricter +shortcut identity boundary.
problems = append(problems, validateCommandFallbackTarget(root, entry.From, candidate, false)...)
}
default:
problems = append(problems, fmt.Sprintf("fallback %q has unsupported mode %q", entry.From, entry.Mode))
}
}
if len(problems) > 0 {
sort.Strings(problems)
return nil, fmt.Errorf("command path fallback reduction failed:\n - %s", strings.Join(problems, "\n - "))
}
return cloneCommandPathFallbacks(entries), nil
}
func validateCommandFallbackTarget(root *cobra.Command, from, target string, requireShortcutParity bool) []string {
var problems []string
if commandFallbackService(from) != commandFallbackService(target) {
problems = append(problems, fmt.Sprintf("fallback %q crosses service boundary to %q", from, target))
}
if requireShortcutParity && commandFallbackHasShortcut(from) != commandFallbackHasShortcut(target) {
problems = append(problems, fmt.Sprintf("fallback %q and target %q disagree on +shortcut identity", from, target))
}
match, err := resolveExactCobraPath(root, target)
if err != nil {
return append(problems, fmt.Sprintf("fallback %q target %q cannot be resolved safely: %v", from, target, err))
}
if match.Command == nil {
return append(problems, fmt.Sprintf("fallback %q target %q does not exist", from, target))
}
if match.UsedAlias {
problems = append(problems, fmt.Sprintf("fallback %q target %q must use canonical Cobra names, not an alias", from, target))
}
if !runnableSchemaLeaf(match.Command) || !match.Command.IsAvailableCommand() {
problems = append(problems, fmt.Sprintf("fallback %q target %q is not a public runnable Cobra leaf", from, target))
}
return problems
}
func commandFallbackService(path string) string {
parts := strings.Fields(path)
if len(parts) == 0 {
return ""
}
return parts[0]
}
func commandFallbackHasShortcut(path string) bool {
for _, part := range strings.Fields(path) {
if strings.HasPrefix(part, "+") {
return true
}
}
return false
}
func cloneCommandPathFallbacks(entries []CommandPathFallback) []CommandPathFallback {
cloned := make([]CommandPathFallback, len(entries))
for index, entry := range entries {
cloned[index] = entry
cloned[index].Candidates = append([]string(nil), entry.Candidates...)
}
return cloned
}
+311
View File
@@ -0,0 +1,311 @@
{
"$schema": "./command_path_fallbacks.schema.json",
"version": 1,
"entries": [
{
"from": "chat +group-search",
"mode": "rewrite",
"to": "chat +chat-search",
"reviewed": true,
"review_reason": "0803 evaluation badcase: the model emitted +group-search with --query; +chat-search provides the same group-name search operation."
},
{
"from": "chat +members",
"mode": "rewrite",
"to": "chat +group-members",
"reviewed": true,
"review_reason": "20260728 evaluation badcases emitted +members three times for listing members of a group selected by name; +group-members is the unique reviewed read-only shortcut for that intent."
},
{
"from": "chat +group-member-list",
"mode": "rewrite",
"to": "chat +group-members",
"reviewed": true,
"review_reason": "20260728 evaluation emitted +group-member-list for a group-name member lookup; +group-members is the unique reviewed read-only shortcut and canonical parameter validation remains authoritative."
},
{
"from": "chat +list-group-bots",
"mode": "rewrite",
"to": "chat +chat-bots",
"reviewed": true,
"review_reason": "20260728 evaluation emitted +list-group-bots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs."
},
{
"from": "chat +list-robot",
"mode": "rewrite",
"to": "chat +chat-bots",
"reviewed": true,
"review_reason": "20260728 evaluation emitted singular +list-robot for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs."
},
{
"from": "chat +list-robots",
"mode": "rewrite",
"to": "chat +chat-bots",
"reviewed": true,
"review_reason": "20260728 evaluation emitted +list-robots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs."
},
{
"from": "chat +message-list",
"mode": "ambiguous",
"candidates": [
"chat +chat-messages",
"chat +messages-list-direct",
"chat +search-msg",
"chat +unread-chats"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +message-list without identifying group versus direct history, conversation history versus cross-chat search, or ordinary versus unread conversations; no candidate may be selected automatically."
},
{
"from": "chat +read-single",
"mode": "ambiguous",
"candidates": [
"chat +messages-list-direct",
"chat +chat-messages"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +read-single six times for direct-message history, but the invented name does not choose between the focused direct-history shortcut and the broader group/direct history workflow; command recovery must stop before parameter validation or dispatch."
},
{
"from": "chat +rename-group",
"mode": "rewrite",
"to": "chat +chat-update",
"reviewed": true,
"review_reason": "20260728 evaluation emitted +rename-group for the unique group-name update intent; +chat-update is the reviewed shortcut with that exact command-level operation. Parameter compatibility remains the canonical target's responsibility."
},
{
"from": "chat +send",
"mode": "ambiguous",
"candidates": [
"chat +messages-send",
"chat +dm",
"chat +send-to-group"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow."
},
{
"from": "chat +send-message",
"mode": "ambiguous",
"candidates": [
"chat +messages-send",
"chat +dm",
"chat +send-to-group"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send-message without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow."
},
{
"from": "chat +send-text",
"mode": "ambiguous",
"candidates": [
"chat +messages-send",
"chat +dm",
"chat +send-to-group"
],
"reviewed": true,
"review_reason": "20260728 evaluation identified text content but not a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow."
},
{
"from": "chat +send-to",
"mode": "ambiguous",
"candidates": [
"chat +messages-send",
"chat +dm",
"chat +send-to-group"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send-to without proving whether the recipient denotes a user, group, or low-level identifier; the write operation must not choose a target workflow automatically."
},
{
"from": "chat +send-dm",
"mode": "ambiguous",
"candidates": [
"chat +dm",
"chat +messages-send"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send-dm twice, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically."
},
{
"from": "chat +send-single",
"mode": "ambiguous",
"candidates": [
"chat +dm",
"chat +messages-send"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send-single for a direct message, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically."
},
{
"from": "chat +send-by-bot",
"mode": "ambiguous",
"candidates": [
"chat +messages-send",
"chat message send-by-bot"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send-by-bot three times without a complete sending-identity contract; stop and present the unified identity-aware shortcut and the exact native bot sender instead of selecting a write path."
},
{
"from": "chat +group-send-text",
"mode": "ambiguous",
"candidates": [
"chat +send-to-group",
"chat +messages-send"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +group-send-text for a group text operation, but the invented name does not choose between name-resolved group text and the unified identifier-aware workflow; the write operation must not dispatch automatically."
},
{
"from": "chat +send-file",
"mode": "ambiguous",
"candidates": [
"chat +messages-send",
"chat message send"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send-file fifteen times with incompatible target and file parameter spellings; stop before dispatch and let the caller choose the unified shortcut or native current-user file workflow."
},
{
"from": "chat +send-image",
"mode": "ambiguous",
"candidates": [
"chat +messages-send",
"chat message send"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send-image without proving whether the input is an existing mediaId or a local file; stop before dispatch and let the caller choose the reviewed unified or native workflow."
},
{
"from": "chat +send-media",
"mode": "ambiguous",
"candidates": [
"chat +messages-send",
"chat message send"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send-media without a concrete media type, sending identity, or compatible parameter contract; stop before dispatch and let the caller choose the reviewed unified or native workflow."
},
{
"from": "oa +list-processes",
"mode": "ambiguous",
"candidates": [
"oa +list-forms",
"oa +my-initiated",
"oa approval list-initiated"
],
"reviewed": true,
"review_reason": "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf."
},
{
"from": "chat +conversation-detail",
"mode": "rewrite",
"to": "chat +conversation-info",
"reviewed": true,
"review_reason": "20260804 multi-im badcase requested one conversation's details. +conversation-info is the unique current read-only shortcut for that operation. The rewrite changes only the command path and preserves every flag/value for target validation."
},
{
"from": "chat +bot-list",
"mode": "rewrite",
"to": "chat +chat-bots",
"reviewed": true,
"review_reason": "20260804 multi-im badcase requested the robot list for one group. +chat-bots is the unique current read-only shortcut. The fallback must not reinterpret the accompanying group flag."
},
{
"from": "chat +conversation-category-list",
"mode": "ambiguous",
"candidates": [
"chat +category-list",
"chat +category-list-conversations"
],
"reviewed": true,
"review_reason": "The invented name can mean listing the user's categories or listing conversations inside one category. No candidate may execute before the caller chooses the intended object level."
},
{
"from": "chat +conversation-group-list",
"mode": "ambiguous",
"candidates": [
"chat +category-list-conversations",
"chat +conversation-list"
],
"reviewed": true,
"review_reason": "The invented name can mean conversations in a custom category or the general conversation list. The command name alone does not identify the requested collection."
},
{
"from": "chat +list-my-groups",
"mode": "ambiguous",
"candidates": [
"chat +my-groups",
"chat +chat-list-mine",
"chat +chat-list"
],
"reviewed": true,
"review_reason": "The invented name does not choose between the established resolver shortcut, the legacy personal-group list and the current Schema-complete chat list. Recovery must stop instead of silently changing pagination or output semantics."
},
{
"from": "doc +list-templates",
"mode": "rewrite",
"to": "doc +template-list",
"reviewed": true,
"review_reason": "202608 Doc experiments emitted +list-templates while discovering available templates. +template-list is the unique current public read-only shortcut for that operation; the rewrite changes only the command path and preserves flags for target validation."
},
{
"from": "doc +search-template",
"mode": "rewrite",
"to": "doc +template-search",
"reviewed": true,
"review_reason": "202608 Doc experiments emitted the verb/object inversion +search-template. +template-search is the unique current public read-only template-name search shortcut; the rewrite preserves every flag and value."
},
{
"from": "doc +template",
"mode": "ambiguous",
"candidates": [
"doc +template-list",
"doc +template-search",
"doc +create-from-template"
],
"reviewed": true,
"review_reason": "202608 Doc experiments emitted the family-like +template without selecting browse, search, or create-from-template. Command recovery must stop before parameters or execution and present the three public workflows."
},
{
"from": "doc +version",
"mode": "ambiguous",
"candidates": [
"doc +history-list",
"doc +history-save",
"doc +history-revert"
],
"reviewed": true,
"review_reason": "202608 Doc experiments emitted the family-like +version without choosing list, save, or revert. The write and high-risk revert operations cannot be selected from an umbrella name; present the canonical history shortcuts and stop."
},
{
"from": "doc +create-version",
"mode": "rewrite",
"to": "doc +history-save",
"reviewed": true,
"review_reason": "202608 supplemental Doc badcase emitted +create-version while explicitly trying to save the current document as a recoverable history snapshot. +history-save is the unique current canonical shortcut for that operation and accepts the same --node value without parameter transformation."
},
{
"from": "doc +save-version",
"mode": "rewrite",
"to": "doc +history-save",
"reviewed": true,
"review_reason": "202608 supplemental Doc badcase emitted the verb/object inversion +save-version for a current-version snapshot. +history-save is the unique current canonical shortcut with the same document-node contract; the legacy +version-save path is not selected as the fallback target."
},
{
"from": "doc +snapshot",
"mode": "rewrite",
"to": "doc +history-save",
"reviewed": true,
"review_reason": "202608 supplemental Doc badcase emitted +snapshot during an explicit save-current-version workflow. The current +history-save selection contract names this exact recoverable history-snapshot operation, so the reviewed rewrite preserves the --node value and does not infer an update or export workflow."
},
{
"from": "doc +version-create",
"mode": "rewrite",
"to": "doc +history-save",
"reviewed": true,
"review_reason": "202608 supplemental Doc badcase emitted +version-create while saving the current document version. +history-save is the unique current canonical snapshot shortcut and the path-only rewrite leaves its real execution and safety contract authoritative."
}
]
}
@@ -0,0 +1,122 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/internal/cli/command_path_fallbacks.schema.json",
"title": "DWS Reviewed Command Path Fallbacks",
"description": "Human-reviewed recovery-only normalization for invalid model-emitted command paths. These entries are not stable command identity, navigation, Help, Skill, or Schema aliases.",
"type": "object",
"additionalProperties": false,
"required": [
"$schema",
"version",
"entries"
],
"properties": {
"$schema": {
"const": "./command_path_fallbacks.schema.json"
},
"version": {
"const": 1
},
"entries": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/entry"
}
}
},
"$defs": {
"commandPath": {
"type": "string",
"pattern": "^[A-Za-z0-9+][A-Za-z0-9._:+-]*(?: [A-Za-z0-9+][A-Za-z0-9._:+-]*)*$",
"description": "Exact CLI path without a leading dws or flags."
},
"entry": {
"type": "object",
"additionalProperties": false,
"required": [
"from",
"mode",
"reviewed",
"review_reason"
],
"properties": {
"from": {
"$ref": "#/$defs/commandPath"
},
"mode": {
"type": "string",
"enum": [
"rewrite",
"ambiguous"
]
},
"to": {
"$ref": "#/$defs/commandPath"
},
"candidates": {
"type": "array",
"minItems": 2,
"uniqueItems": true,
"description": "Canonical public runnable paths offered for an ambiguous source. Because ambiguous recovery never dispatches, candidates may mix reviewed shortcuts and native leaves within the same service.",
"items": {
"$ref": "#/$defs/commandPath"
}
},
"reviewed": {
"const": true
},
"review_reason": {
"type": "string",
"minLength": 1
}
},
"allOf": [
{
"if": {
"properties": {
"mode": {
"const": "rewrite"
}
},
"required": [
"mode"
]
},
"then": {
"required": [
"to"
],
"not": {
"required": [
"candidates"
]
}
}
},
{
"if": {
"properties": {
"mode": {
"const": "ambiguous"
}
},
"required": [
"mode"
]
},
"then": {
"required": [
"candidates"
],
"not": {
"required": [
"to"
]
}
}
}
]
}
}
}
@@ -0,0 +1,253 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Code generated by cmd_command_path_fallbacks; DO NOT EDIT.
// Source: internal/cli/command_path_fallbacks.json validated against the live Cobra tree.
// Regenerate with `make generate-schema` (or `go generate ./internal/cli`).
package cli
var generatedCommandPathFallbacks = []CommandPathFallback{
{
From: "chat +bot-list",
Mode: "rewrite",
To: "chat +chat-bots",
Reviewed: true,
ReviewReason: "20260804 multi-im badcase requested the robot list for one group. +chat-bots is the unique current read-only shortcut. The fallback must not reinterpret the accompanying group flag.",
},
{
From: "chat +conversation-category-list",
Mode: "ambiguous",
Candidates: []string{"chat +category-list", "chat +category-list-conversations"},
Reviewed: true,
ReviewReason: "The invented name can mean listing the user's categories or listing conversations inside one category. No candidate may execute before the caller chooses the intended object level.",
},
{
From: "chat +conversation-detail",
Mode: "rewrite",
To: "chat +conversation-info",
Reviewed: true,
ReviewReason: "20260804 multi-im badcase requested one conversation's details. +conversation-info is the unique current read-only shortcut for that operation. The rewrite changes only the command path and preserves every flag/value for target validation.",
},
{
From: "chat +conversation-group-list",
Mode: "ambiguous",
Candidates: []string{"chat +category-list-conversations", "chat +conversation-list"},
Reviewed: true,
ReviewReason: "The invented name can mean conversations in a custom category or the general conversation list. The command name alone does not identify the requested collection.",
},
{
From: "chat +group-member-list",
Mode: "rewrite",
To: "chat +group-members",
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +group-member-list for a group-name member lookup; +group-members is the unique reviewed read-only shortcut and canonical parameter validation remains authoritative.",
},
{
From: "chat +group-search",
Mode: "rewrite",
To: "chat +chat-search",
Reviewed: true,
ReviewReason: "0803 evaluation badcase: the model emitted +group-search with --query; +chat-search provides the same group-name search operation.",
},
{
From: "chat +group-send-text",
Mode: "ambiguous",
Candidates: []string{"chat +send-to-group", "chat +messages-send"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +group-send-text for a group text operation, but the invented name does not choose between name-resolved group text and the unified identifier-aware workflow; the write operation must not dispatch automatically.",
},
{
From: "chat +list-group-bots",
Mode: "rewrite",
To: "chat +chat-bots",
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +list-group-bots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs.",
},
{
From: "chat +list-my-groups",
Mode: "ambiguous",
Candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"},
Reviewed: true,
ReviewReason: "The invented name does not choose between the established resolver shortcut, the legacy personal-group list and the current Schema-complete chat list. Recovery must stop instead of silently changing pagination or output semantics.",
},
{
From: "chat +list-robot",
Mode: "rewrite",
To: "chat +chat-bots",
Reviewed: true,
ReviewReason: "20260728 evaluation emitted singular +list-robot for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs.",
},
{
From: "chat +list-robots",
Mode: "rewrite",
To: "chat +chat-bots",
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +list-robots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs.",
},
{
From: "chat +members",
Mode: "rewrite",
To: "chat +group-members",
Reviewed: true,
ReviewReason: "20260728 evaluation badcases emitted +members three times for listing members of a group selected by name; +group-members is the unique reviewed read-only shortcut for that intent.",
},
{
From: "chat +message-list",
Mode: "ambiguous",
Candidates: []string{"chat +chat-messages", "chat +messages-list-direct", "chat +search-msg", "chat +unread-chats"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +message-list without identifying group versus direct history, conversation history versus cross-chat search, or ordinary versus unread conversations; no candidate may be selected automatically.",
},
{
From: "chat +read-single",
Mode: "ambiguous",
Candidates: []string{"chat +messages-list-direct", "chat +chat-messages"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +read-single six times for direct-message history, but the invented name does not choose between the focused direct-history shortcut and the broader group/direct history workflow; command recovery must stop before parameter validation or dispatch.",
},
{
From: "chat +rename-group",
Mode: "rewrite",
To: "chat +chat-update",
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +rename-group for the unique group-name update intent; +chat-update is the reviewed shortcut with that exact command-level operation. Parameter compatibility remains the canonical target's responsibility.",
},
{
From: "chat +send",
Mode: "ambiguous",
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow.",
},
{
From: "chat +send-by-bot",
Mode: "ambiguous",
Candidates: []string{"chat +messages-send", "chat message send-by-bot"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send-by-bot three times without a complete sending-identity contract; stop and present the unified identity-aware shortcut and the exact native bot sender instead of selecting a write path.",
},
{
From: "chat +send-dm",
Mode: "ambiguous",
Candidates: []string{"chat +dm", "chat +messages-send"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send-dm twice, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically.",
},
{
From: "chat +send-file",
Mode: "ambiguous",
Candidates: []string{"chat +messages-send", "chat message send"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send-file fifteen times with incompatible target and file parameter spellings; stop before dispatch and let the caller choose the unified shortcut or native current-user file workflow.",
},
{
From: "chat +send-image",
Mode: "ambiguous",
Candidates: []string{"chat +messages-send", "chat message send"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send-image without proving whether the input is an existing mediaId or a local file; stop before dispatch and let the caller choose the reviewed unified or native workflow.",
},
{
From: "chat +send-media",
Mode: "ambiguous",
Candidates: []string{"chat +messages-send", "chat message send"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send-media without a concrete media type, sending identity, or compatible parameter contract; stop before dispatch and let the caller choose the reviewed unified or native workflow.",
},
{
From: "chat +send-message",
Mode: "ambiguous",
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send-message without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow.",
},
{
From: "chat +send-single",
Mode: "ambiguous",
Candidates: []string{"chat +dm", "chat +messages-send"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send-single for a direct message, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically.",
},
{
From: "chat +send-text",
Mode: "ambiguous",
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
Reviewed: true,
ReviewReason: "20260728 evaluation identified text content but not a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow.",
},
{
From: "chat +send-to",
Mode: "ambiguous",
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send-to without proving whether the recipient denotes a user, group, or low-level identifier; the write operation must not choose a target workflow automatically.",
},
{
From: "doc +create-version",
Mode: "rewrite",
To: "doc +history-save",
Reviewed: true,
ReviewReason: "202608 supplemental Doc badcase emitted +create-version while explicitly trying to save the current document as a recoverable history snapshot. +history-save is the unique current canonical shortcut for that operation and accepts the same --node value without parameter transformation.",
},
{
From: "doc +list-templates",
Mode: "rewrite",
To: "doc +template-list",
Reviewed: true,
ReviewReason: "202608 Doc experiments emitted +list-templates while discovering available templates. +template-list is the unique current public read-only shortcut for that operation; the rewrite changes only the command path and preserves flags for target validation.",
},
{
From: "doc +save-version",
Mode: "rewrite",
To: "doc +history-save",
Reviewed: true,
ReviewReason: "202608 supplemental Doc badcase emitted the verb/object inversion +save-version for a current-version snapshot. +history-save is the unique current canonical shortcut with the same document-node contract; the legacy +version-save path is not selected as the fallback target.",
},
{
From: "doc +search-template",
Mode: "rewrite",
To: "doc +template-search",
Reviewed: true,
ReviewReason: "202608 Doc experiments emitted the verb/object inversion +search-template. +template-search is the unique current public read-only template-name search shortcut; the rewrite preserves every flag and value.",
},
{
From: "doc +snapshot",
Mode: "rewrite",
To: "doc +history-save",
Reviewed: true,
ReviewReason: "202608 supplemental Doc badcase emitted +snapshot during an explicit save-current-version workflow. The current +history-save selection contract names this exact recoverable history-snapshot operation, so the reviewed rewrite preserves the --node value and does not infer an update or export workflow.",
},
{
From: "doc +template",
Mode: "ambiguous",
Candidates: []string{"doc +template-list", "doc +template-search", "doc +create-from-template"},
Reviewed: true,
ReviewReason: "202608 Doc experiments emitted the family-like +template without selecting browse, search, or create-from-template. Command recovery must stop before parameters or execution and present the three public workflows.",
},
{
From: "doc +version",
Mode: "ambiguous",
Candidates: []string{"doc +history-list", "doc +history-save", "doc +history-revert"},
Reviewed: true,
ReviewReason: "202608 Doc experiments emitted the family-like +version without choosing list, save, or revert. The write and high-risk revert operations cannot be selected from an umbrella name; present the canonical history shortcuts and stop.",
},
{
From: "doc +version-create",
Mode: "rewrite",
To: "doc +history-save",
Reviewed: true,
ReviewReason: "202608 supplemental Doc badcase emitted +version-create while saving the current document version. +history-save is the unique current canonical snapshot shortcut and the path-only rewrite leaves its real execution and safety contract authoritative.",
},
{
From: "oa +list-processes",
Mode: "ambiguous",
Candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
Reviewed: true,
ReviewReason: "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf.",
},
}
func loadGeneratedCommandPathFallbacks() []CommandPathFallback {
return generatedCommandPathFallbacks
}
@@ -0,0 +1,32 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package cli
import "sync"
var (
commandPathFallbackIndexOnce sync.Once
commandPathFallbackIndex map[string]CommandPathFallback
)
func buildCommandPathFallbackIndex() {
entries := loadGeneratedCommandPathFallbacks()
commandPathFallbackIndex = make(map[string]CommandPathFallback, len(entries))
for _, entry := range entries {
commandPathFallbackIndex[entry.From] = entry
}
}
// LookupCommandPathFallback performs an exact O(1) lookup of a reviewed
// recovery-only path. Normalization only removes a leading dws and folds
// whitespace; it does not apply prefix, typo, or semantic matching.
func LookupCommandPathFallback(rawPath string) (CommandPathFallback, bool) {
commandPathFallbackIndexOnce.Do(buildCommandPathFallbackIndex)
entry, ok := commandPathFallbackIndex[normalizeSchemaCLIPath(rawPath)]
if !ok {
return CommandPathFallback{}, false
}
entry.Candidates = append([]string(nil), entry.Candidates...)
return entry, true
}
+372
View File
@@ -0,0 +1,372 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package cli
import (
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
func commandFallbackJSON(entries string) []byte {
return []byte(`{"$schema":"./command_path_fallbacks.schema.json","version":1,"entries":` + entries + `}`)
}
func validCommandFallbackEntry(from, to string) string {
return `{"from":"` + from + `","mode":"rewrite","to":"` + to + `","reviewed":true,"review_reason":"reviewed fixture"}`
}
func TestCrossPlatformCoverageDecodeCommandPathFallbacksStrictContract(t *testing.T) {
valid := commandFallbackJSON(`[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]`)
entries, err := decodeCommandPathFallbacks(valid)
if err != nil || len(entries) != 1 || entries[0].From != "chat +bad" || entries[0].To != "chat +good" {
t.Fatalf("decode valid = %#v, %v", entries, err)
}
entries[0].Candidates = append(entries[0].Candidates, "mutated")
cloned := cloneCommandPathFallbacks(entries)
cloned[0].Candidates[0] = "clone"
if entries[0].Candidates[0] != "mutated" {
t.Fatal("cloneCommandPathFallbacks aliases candidate storage")
}
tests := map[string]struct {
data []byte
want string
}{
"unknown field": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":true,"review_reason":"x","extra":true}]`),
want: "unknown field",
},
"wrong schema": {
data: []byte(`{"$schema":"wrong","version":1,"entries":[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]}`),
want: "$schema",
},
"wrong version": {
data: []byte(`{"$schema":"./command_path_fallbacks.schema.json","version":2,"entries":[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]}`),
want: "unsupported",
},
"empty entries": {data: commandFallbackJSON(`[]`), want: "no entries"},
"unnormalized path": {
data: commandFallbackJSON(`[` + validCommandFallbackEntry(` chat +bad `, `chat +good`) + `]`),
want: "not a normalized",
},
"unreviewed": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":false,"review_reason":"x"}]`),
want: "reviewed=true",
},
"empty reason": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":true,"review_reason":" "}]`),
want: "reviewed=true",
},
"invalid mode": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"guess","reviewed":true,"review_reason":"x"}]`),
want: "invalid mode",
},
"rewrite candidates": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","candidates":["chat +one","chat +two"],"reviewed":true,"review_reason":"x"}]`),
want: "must not declare candidates",
},
"empty rewrite target": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","reviewed":true,"review_reason":"x"}]`),
want: "path is empty",
},
"ambiguous target": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","to":"chat +good","candidates":["chat +one","chat +two"],"reviewed":true,"review_reason":"x"}]`),
want: "must not declare to",
},
"ambiguous too small": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one"],"reviewed":true,"review_reason":"x"}]`),
want: "at least two",
},
"empty ambiguous candidate": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one",""],"reviewed":true,"review_reason":"x"}]`),
want: "path is empty",
},
"duplicate candidate": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one","chat +one"],"reviewed":true,"review_reason":"x"}]`),
want: "repeats candidate",
},
"duplicate from": {
data: commandFallbackJSON(`[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `,` + validCommandFallbackEntry(`chat +bad`, `chat +other`) + `]`),
want: "duplicate from",
},
"multiple documents": {
data: append(valid, []byte(` {}`)...),
want: "multiple JSON values",
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
if _, err := decodeCommandPathFallbacks(test.data); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("decode error = %v, want containing %q", err, test.want)
}
})
}
}
func TestCrossPlatformCoverageCommandPathFallbackSchemaAndEmbeddedSource(t *testing.T) {
var schema map[string]any
if err := json.Unmarshal(embeddedCommandPathFallbacksSchemaJSON, &schema); err != nil {
t.Fatalf("decode embedded schema: %v", err)
}
if schema["$schema"] != "https://json-schema.org/draft/2020-12/schema" || schema["additionalProperties"] != false {
t.Fatalf("schema header = %#v", schema)
}
entries, err := LoadCommandPathFallbacks()
if err != nil || len(entries) != 34 {
t.Fatalf("LoadCommandPathFallbacks() = %#v, %v", entries, err)
}
if got, ok := LookupCommandPathFallback("dws chat +group-search"); !ok || got.To != "chat +chat-search" {
t.Fatalf("generated lookup = %#v, %v", got, ok)
}
if _, ok := LookupCommandPathFallback("chat +missing"); ok {
t.Fatal("generated lookup accepted unknown path")
}
}
func TestCrossPlatformCoverageCommandPathFallbackAuditCoverage(t *testing.T) {
rewrites := map[string]string{
"chat +bot-list": "chat +chat-bots",
"chat +conversation-detail": "chat +conversation-info",
"chat +group-search": "chat +chat-search",
"chat +members": "chat +group-members",
"chat +group-member-list": "chat +group-members",
"chat +list-group-bots": "chat +chat-bots",
"chat +list-robot": "chat +chat-bots",
"chat +list-robots": "chat +chat-bots",
"chat +rename-group": "chat +chat-update",
"doc +create-version": "doc +history-save",
"doc +list-templates": "doc +template-list",
"doc +save-version": "doc +history-save",
"doc +search-template": "doc +template-search",
"doc +snapshot": "doc +history-save",
"doc +version-create": "doc +history-save",
}
for from, to := range rewrites {
entry, ok := LookupCommandPathFallback(from)
if !ok || entry.Mode != CommandPathFallbackRewrite || entry.To != to {
t.Errorf("rewrite %q = %#v, %v; want %q", from, entry, ok, to)
}
}
ambiguous := map[string][]string{
"chat +conversation-category-list": {"chat +category-list", "chat +category-list-conversations"},
"chat +conversation-group-list": {"chat +category-list-conversations", "chat +conversation-list"},
"chat +group-send-text": {"chat +send-to-group", "chat +messages-send"},
"chat +list-my-groups": {"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"},
"chat +message-list": {"chat +chat-messages", "chat +messages-list-direct", "chat +search-msg", "chat +unread-chats"},
"chat +read-single": {"chat +messages-list-direct", "chat +chat-messages"},
"chat +send": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
"chat +send-by-bot": {"chat +messages-send", "chat message send-by-bot"},
"chat +send-dm": {"chat +dm", "chat +messages-send"},
"chat +send-message": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
"chat +send-single": {"chat +dm", "chat +messages-send"},
"chat +send-text": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
"chat +send-to": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
"chat +send-file": {"chat +messages-send", "chat message send"},
"chat +send-image": {"chat +messages-send", "chat message send"},
"chat +send-media": {"chat +messages-send", "chat message send"},
"oa +list-processes": {"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
"doc +template": {"doc +template-list", "doc +template-search", "doc +create-from-template"},
"doc +version": {"doc +history-list", "doc +history-save", "doc +history-revert"},
}
for from, candidates := range ambiguous {
entry, ok := LookupCommandPathFallback(from)
if !ok || entry.Mode != CommandPathFallbackAmbiguous || !reflect.DeepEqual(entry.Candidates, candidates) {
t.Errorf("ambiguous %q = %#v, %v; want %v", from, entry, ok, candidates)
}
}
if entry, ok := LookupCommandPathFallback("chat +definitely-unknown"); ok {
t.Errorf("unreviewed path unexpectedly falls back: %#v", entry)
}
}
func TestCrossPlatformCoverageReduceCommandPathFallbacksValidatesLiveTree(t *testing.T) {
root := commandFallbackTestRoot()
entries := []CommandPathFallback{
{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good", Reviewed: true, ReviewReason: "fixture"},
{From: "chat group search", Mode: CommandPathFallbackRewrite, To: "chat search", Reviewed: true, ReviewReason: "fixture"},
{From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +other"}, Reviewed: true, ReviewReason: "fixture"},
{From: "chat +mixed", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat search"}, Reviewed: true, ReviewReason: "fixture"},
}
withCommandFallbackSource(t, entries)
got, err := ReduceCommandPathFallbacks(root)
if err != nil || len(got) != len(entries) {
t.Fatalf("ReduceCommandPathFallbacks() = %#v, %v", got, err)
}
got[2].Candidates[0] = "mutated"
again, err := ReduceCommandPathFallbacks(root)
if err != nil || again[2].Candidates[0] == "mutated" {
t.Fatalf("reduction did not clone candidates: %#v, %v", again, err)
}
}
func TestCrossPlatformCoverageReduceCommandPathFallbacksRejectsUnsafeMappings(t *testing.T) {
baseRoot := commandFallbackTestRoot
tests := map[string]struct {
entries []CommandPathFallback
mutate func(*cobra.Command)
want string
}{
"missing target": {
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +missing"}},
want: "does not exist",
},
"real source collision": {
entries: []CommandPathFallback{{From: "chat +other", Mode: CommandPathFallbackRewrite, To: "chat +good"}},
want: "collides with a real Cobra command or alias",
},
"cobra alias collision": {
entries: []CommandPathFallback{{From: "chat +official-alias", Mode: CommandPathFallbackRewrite, To: "chat +other"}},
want: "collides with a real Cobra command or alias",
},
"cross service": {
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "mail +good"}},
want: "crosses service boundary",
},
"shortcut mismatch": {
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat search"}},
want: "disagree on +shortcut identity",
},
"chained rewrite": {
entries: []CommandPathFallback{
{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +next"},
{From: "chat +next", Mode: CommandPathFallbackRewrite, To: "chat +good"},
},
want: "chained fallbacks are forbidden",
},
"chained ambiguous candidate": {
entries: []CommandPathFallback{
{From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +next"}},
{From: "chat +next", Mode: CommandPathFallbackRewrite, To: "chat +other"},
},
want: "is another fallback source",
},
"missing ambiguous candidate": {
entries: []CommandPathFallback{{From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +missing"}}},
want: "does not exist",
},
"hidden target": {
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good"}},
mutate: func(root *cobra.Command) {
exactSchemaCommand(root, "chat +good").Hidden = true
},
want: "not a public runnable",
},
"target is alias": {
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +official-alias"}},
want: "must use canonical Cobra names",
},
"ambiguous source command": {
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good"}},
mutate: func(root *cobra.Command) {
chat := exactSchemaCommand(root, "chat")
chat.AddCommand(
&cobra.Command{Use: "+bad", Run: func(*cobra.Command, []string) {}},
&cobra.Command{Use: "+bad", Run: func(*cobra.Command, []string) {}},
)
},
want: "cannot be resolved safely",
},
"ambiguous target command": {
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +duplicate"}},
mutate: func(root *cobra.Command) {
chat := exactSchemaCommand(root, "chat")
chat.AddCommand(
&cobra.Command{Use: "+duplicate", Run: func(*cobra.Command, []string) {}},
&cobra.Command{Use: "+duplicate", Run: func(*cobra.Command, []string) {}},
)
},
want: "target \"chat +duplicate\" cannot be resolved safely",
},
"unsupported reduced mode": {
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackMode("invalid")}},
want: "unsupported mode",
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
root := baseRoot()
if test.mutate != nil {
test.mutate(root)
}
for index := range test.entries {
test.entries[index].Reviewed = true
test.entries[index].ReviewReason = "fixture"
}
withCommandFallbackSource(t, test.entries)
if _, err := ReduceCommandPathFallbacks(root); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("reduction error = %v, want containing %q", err, test.want)
}
})
}
withCommandFallbackLoadError(t, errors.New("fixture load"))
if _, err := ReduceCommandPathFallbacks(baseRoot()); err == nil || !strings.Contains(err.Error(), "fixture load") {
t.Fatalf("load error = %v", err)
}
if _, err := ReduceCommandPathFallbacks(nil); err == nil || !strings.Contains(err.Error(), "root is nil") {
t.Fatalf("nil root error = %v", err)
}
if got := commandFallbackService(""); got != "" {
t.Fatalf("commandFallbackService(empty) = %q", got)
}
}
func commandFallbackTestRoot() *cobra.Command {
root := &cobra.Command{Use: "dws"}
chat := &cobra.Command{Use: "chat"}
good := &cobra.Command{Use: "+good", Aliases: []string{"+official-alias"}, Run: func(*cobra.Command, []string) {}}
chat.AddCommand(
good,
&cobra.Command{Use: "+other", Run: func(*cobra.Command, []string) {}},
&cobra.Command{Use: "search", Run: func(*cobra.Command, []string) {}},
)
group := &cobra.Command{Use: "group"}
group.AddCommand(cmdutil.HintSubCmd("search", "use chat search"))
chat.AddCommand(group)
mail := &cobra.Command{Use: "mail"}
mail.AddCommand(&cobra.Command{Use: "+good", Run: func(*cobra.Command, []string) {}})
root.AddCommand(chat, mail)
return root
}
func withCommandFallbackSource(t *testing.T, entries []CommandPathFallback) {
t.Helper()
old := loadReviewedCommandPathFallbacks
loadReviewedCommandPathFallbacks = func() ([]CommandPathFallback, error) {
return cloneCommandPathFallbacks(entries), nil
}
t.Cleanup(func() { loadReviewedCommandPathFallbacks = old })
}
func withCommandFallbackLoadError(t *testing.T, want error) {
t.Helper()
old := loadReviewedCommandPathFallbacks
loadReviewedCommandPathFallbacks = func() ([]CommandPathFallback, error) { return nil, want }
t.Cleanup(func() { loadReviewedCommandPathFallbacks = old })
}
func TestCrossPlatformCoverageCommandPathFallbackGeneratedTableWellFormed(t *testing.T) {
entries := loadGeneratedCommandPathFallbacks()
if len(entries) == 0 {
t.Fatal("generated command path fallback table is empty")
}
seen := map[string]bool{}
for _, entry := range entries {
if seen[entry.From] || !entry.Reviewed || strings.TrimSpace(entry.ReviewReason) == "" {
t.Fatalf("invalid generated entry: %#v", entry)
}
seen[entry.From] = true
}
if !reflect.DeepEqual(entries, cloneCommandPathFallbacks(entries)) {
t.Fatal("generated table clone changed values")
}
}
+1 -34
View File
@@ -24,39 +24,6 @@ import (
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalMCPMetadataLookup(t *testing.T) {
cmd := &cobra.Command{Use: "reply"}
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
cmd.Flags().String("text", "", "text")
runtimeannotate.AnnotateRuntimeFlag(cmd, "text", "text", "string", false)
contractfinal.RegisterRuntimeContractFinal(cmd, contract.ContractFinalPayload{
Identity: &contract.ToolIdentitySpec{
ProductID: "chat", Name: "reply_personal_message", CanonicalPath: "chat.reply_personal_message",
CLIPath: "chat reply", PrimaryCLIPath: "chat reply",
},
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeMCP,
Availability: contract.InterfaceAvailable,
Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "send_personal_message"},
},
})
entry := runtimeSchemaEntry{
ProductID: "chat", ToolName: "reply_personal_message", Command: cmd,
CLIPath: "chat reply", PrimaryCLIPath: "chat reply",
}
metadata := runtimeSchemaMetadataSources{
MCP: embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{
"chat.send_personal_message": {Parameters: map[string]embeddedMCPParamMeta{
"text": {Type: "string"},
}},
}},
}
if _, err := runtimeToolSpecFromContractFinal(entry, mustFinal(t, cmd), metadata); err != nil {
t.Fatalf("runtimeToolSpecFromContractFinal with MCP metadata = %v", err)
}
}
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *testing.T) {
cmd := &cobra.Command{Use: "create", Short: "s", Long: "l"}
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
@@ -318,7 +285,7 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalSafetyAnnotationFa
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalParameterResolutionError(t *testing.T) {
oldParameters := resolveRuntimeParameters
t.Cleanup(func() { resolveRuntimeParameters = oldParameters })
resolveRuntimeParameters = func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
resolveRuntimeParameters = func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
return nil, errors.New("parameters failed")
}
entry := runtimeSchemaEntry{
+5 -1
View File
@@ -27,7 +27,9 @@
// 3. schema_parameter_mapping_ledger.go mapping exclusions / removals (Go)
// (active bindings retired; ParamDecl.Property owns delivery)
// 4. param_concepts.json + schema reviewed parameter synonym policy
// 5. cobra command tree (Go runtime) flags/usage/required (reflected)
// 5. command_path_fallbacks.json + schema
// reviewed recovery-only invalid paths
// 6. cobra command tree (Go runtime) flags/usage/required (reflected)
//
// schema_hints/, schema_agent_metadata/, schema_command_registry/,
// schema_mcp_metadata.json, and schema_mcp_service_review(.json|/ledger) are
@@ -35,7 +37,9 @@
//
// Remaining generated output from this file:
// - param_aliases_generated.go per-command parameter normalization
// - command_path_fallbacks_generated.go invalid-path recovery normalization
package cli
//go:generate go run ../generator/cmd_param_aliases -root ../.. -output param_aliases_generated.go
//go:generate go run ../generator/cmd_command_path_fallbacks -root ../.. -output command_path_fallbacks_generated.go
@@ -21,7 +21,7 @@ import (
"testing"
)
func TestGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
func TestCrossPlatformCoverageGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
entries, err := os.ReadDir(".")
if err != nil {
t.Fatalf("read internal/cli: %v", err)
@@ -50,6 +50,7 @@ func TestGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
for _, generator := range []string{
"cmd_schema_catalog",
"cmd_param_aliases",
"cmd_command_path_fallbacks",
} {
if !bytes.Contains(content, []byte("//go:generate go run")) || !bytes.Contains(content, []byte(generator)) {
t.Errorf("gen.go does not register %s", generator)
File diff suppressed because it is too large Load Diff
+187 -18
View File
@@ -10,32 +10,37 @@
},
"concepts": {
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +template-search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "mail thread list", "oa +list-executed"], "risk": "green"},
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +create-from-template", "doc +find-doc", "doc +search", "doc +template-search", "doc template search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "doc +comment-list", "doc +find-doc", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list", "mail thread list", "oa +list-executed"], "risk": "green"},
"page_number": {"denotes": "one-based page number", "canonical_hint": "page", "members": ["page", "page-no", "current-page", "page-num"], "excludes": ["cursor", "page-index", "page-size", "page-token"], "commands": ["devdoc article search"], "risk": "green"},
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list"], "risk": "green"},
"content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc block insert", "doc block update"], "risk": "green"},
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list", "doc +comment-list", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list"], "risk": "green"},
"content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc +checkpoint-update", "doc +comment-create", "doc +comment-reply", "doc +comment-update", "doc +create", "doc +doc-append", "doc block insert", "doc block update", "doc comment create", "doc comment create-inline", "doc comment reply", "doc comment update", "doc create"], "risk": "green"},
"time_start": {"denotes": "start time point with unchanged value format and unit", "canonical_hint": "start", "members": ["start", "start-time", "start-date", "from", "from-date", "begin", "since", "time-min", "min-time"], "excludes": ["date", "time", "end"], "commands": ["calendar event list", "chat message list-all", "report list"], "risk": "yellow"},
"time_end": {"denotes": "end time point with unchanged value format and unit", "canonical_hint": "end", "members": ["end", "end-time", "end-date", "time-max", "max-time"], "excludes": ["date", "time", "start"], "commands": ["calendar event list"], "risk": "yellow"},
"base_id": {"denotes": "multi-dimensional table Base id", "canonical_hint": "base-id", "members": ["base", "base-id", "base-token"], "excludes": [], "commands": ["aitable +field-get", "aitable +list-tables", "aitable +record-query", "aitable +record-share-url", "aitable +table-get"], "risk": "green"},
"dept_id": {"denotes": "single department id", "canonical_hint": "dept", "members": ["dept", "dept-id", "department", "department-id", "parent", "parent-id"], "excludes": ["depts", "dept-ids", "department-ids", "name", "query"], "commands": ["contact +list-sub-depts", "contact dept list-children"], "risk": "yellow"},
"dept_ids": {"denotes": "department id list", "canonical_hint": "dept-ids", "members": ["depts", "dept-ids", "department-ids"], "excludes": ["dept", "dept-id", "department-id", "name", "query"], "commands": ["contact +list-dept-members"], "risk": "yellow"},
"group_id": {"denotes": "single DingTalk numeric groupId", "canonical_hint": "group-id", "members": ["group-id"], "excludes": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "commands": ["chat group get-by-group-id"], "risk": "yellow"},
"open_conversation_id": {"denotes": "single DingTalk openConversationId with unchanged value", "canonical_hint": "conversation-id", "members": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id"], "excludes": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids", "group-name", "name", "id", "source", "target", "src-conversation-id", "dest-conversation-id"], "commands": ["chat +chat-bots", "chat +chat-dismiss", "chat +chat-invite-url", "chat +chat-mute", "chat +chat-role-add", "chat +chat-role-list", "chat +chat-role-query-user", "chat +chat-role-set-user", "chat +chat-role-update", "chat +chat-set-admin", "chat +chat-set-history", "chat +chat-update-alias", "chat +chat-update-nick", "chat +conversation-info", "chat +messages-list-pin", "chat +messages-read-status", "chat category add-conv", "chat category remove-conv", "chat chmod", "chat clear-messages", "chat clear-red-point", "chat conversation-info", "chat group audit-join-validation", "chat group bots", "chat group dismiss", "chat group invite-url", "chat group members", "chat group members add", "chat group members add-bot", "chat group members list-by-ids", "chat group members remove", "chat group members remove-bot", "chat group notice create", "chat group notice edit", "chat group notice get", "chat group notice list", "chat group quit", "chat group rename", "chat group set-admin", "chat group set-history", "chat group transfer-owner", "chat group update-alias", "chat group update-icon", "chat group update-nick", "chat group update-settings", "chat group-mute", "chat group-mute-member", "chat group-role add", "chat group-role list", "chat group-role query-user", "chat group-role remove", "chat group-role remove-user", "chat group-role set-user", "chat group-role update", "chat hide", "chat mark-read", "chat mark-unread", "chat message add-favorite", "chat message download-media", "chat message list", "chat message list-mentions", "chat message list-pin-msg", "chat message list-topic-replies", "chat message read-status", "chat message recall", "chat message recall-by-bot", "chat message remove-favorite", "chat message reply", "chat message search", "chat message send", "chat message send-by-bot", "chat message send-card", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg", "chat mute-at-all", "chat mute-red-envelope", "chat set-top"], "risk": "yellow"},
"group_id": {"denotes": "single DingTalk numeric groupId", "canonical_hint": "group-id", "members": ["group-id"], "excludes": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "commands": ["chat +chat-get-by-id", "chat group get-by-group-id"], "risk": "yellow"},
"open_conversation_id": {"denotes": "single DingTalk openConversationId with unchanged value", "canonical_hint": "conversation-id", "members": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id"], "excludes": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids", "group-name", "name", "id", "source", "target", "src-conversation-id", "dest-conversation-id"], "commands": ["chat +category-add-conversation", "chat +category-remove-conversation", "chat +chat-add-bot", "chat +chat-audit-join", "chat +chat-bots", "chat +chat-dismiss", "chat +chat-invite-url", "chat +chat-members-get", "chat +chat-mute", "chat +chat-mute-member", "chat +chat-quit", "chat +chat-remove-bot", "chat +chat-role-add", "chat +chat-role-list", "chat +chat-role-query-user", "chat +chat-role-remove", "chat +chat-role-remove-user", "chat +chat-role-set-user", "chat +chat-role-update", "chat +chat-set-admin", "chat +chat-set-history", "chat +chat-transfer-owner", "chat +chat-update-alias", "chat +chat-update-icon", "chat +chat-update-nick", "chat +chat-update-settings", "chat +conversation-clear-messages", "chat +conversation-clear-red-point", "chat +conversation-hide", "chat +conversation-info", "chat +conversation-mark-read", "chat +conversation-mark-unread", "chat +conversation-mute", "chat +flag-cancel", "chat +flag-create", "chat +messages-add-emoji", "chat +messages-add-text-emotion", "chat +messages-list-pin", "chat +messages-read-status", "chat +messages-recall-by-bot", "chat +messages-remove-emoji", "chat +messages-remove-text-emotion", "chat +messages-reply", "chat +messages-resource-download", "chat +messages-resource-url", "chat +messages-send-by-bot", "chat +messages-set-pin", "chat +messages-set-top", "chat +messages-unset-pin", "chat +messages-unset-top", "chat category add-conv", "chat category remove-conv", "chat chmod", "chat clear-messages", "chat clear-red-point", "chat conversation-info", "chat group audit-join-validation", "chat group bots", "chat group dismiss", "chat group invite-url", "chat group members", "chat group members add", "chat group members add-bot", "chat group members list-by-ids", "chat group members remove", "chat group members remove-bot", "chat group notice create", "chat group notice edit", "chat group notice get", "chat group notice list", "chat group quit", "chat group rename", "chat group set-admin", "chat group set-history", "chat group transfer-owner", "chat group update-alias", "chat group update-icon", "chat group update-nick", "chat group update-settings", "chat group-mute", "chat group-mute-member", "chat group-role add", "chat group-role list", "chat group-role query-user", "chat group-role remove", "chat group-role remove-user", "chat group-role set-user", "chat group-role update", "chat hide", "chat mark-read", "chat mark-unread", "chat message add-favorite", "chat message download-media", "chat message list", "chat message list-mentions", "chat message list-pin-msg", "chat message list-topic-replies", "chat message read-status", "chat message recall", "chat message recall-by-bot", "chat message remove-favorite", "chat message reply", "chat message search", "chat message send", "chat message send-by-bot", "chat message send-card", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg", "chat mute-at-all", "chat mute-red-envelope", "chat set-top"], "risk": "yellow"},
"open_conversation_ids": {"denotes": "DingTalk openConversationId list with unchanged element values", "canonical_hint": "conversation-ids", "members": ["conversation-ids", "open-conversation-ids", "groups"], "excludes": ["group-id", "group-ids", "conversation-id", "open-conversation-id", "chat-id"], "commands": ["chat message search-advanced"], "risk": "yellow"},
"group_name": {"denotes": "group-name search keyword, not a group identifier", "canonical_hint": "group-name", "members": ["group-name"], "excludes": ["group-id", "conversation-id", "open-conversation-id", "chat-id", "id"], "commands": ["chat +group-members", "chat +send-to-group"], "risk": "yellow"},
"open_message_id": {"denotes": "single DingTalk openMessageId with unchanged value", "canonical_hint": "open-message-id", "members": ["msg-id", "message-id", "open-message-id"], "excludes": ["msg-ids", "message-ids", "open-message-ids", "ref-msg-id", "src-msg-id", "open-task-id", "topic-id", "resource-id"], "commands": ["chat +messages-read-status", "chat mark-read", "chat message add-emoji", "chat message add-favorite", "chat message add-text-emotion", "chat message download-media", "chat message forward", "chat message read-status", "chat message recall", "chat message remove-emoji", "chat message remove-favorite", "chat message remove-text-emotion", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg"], "risk": "yellow"},
"open_message_ids": {"denotes": "DingTalk openMessageId list with unchanged element values", "canonical_hint": "msg-ids", "members": ["msg-ids", "message-ids", "open-message-ids"], "excludes": ["msg-id", "message-id", "open-message-id", "ref-msg-id", "src-msg-id"], "commands": ["chat +messages-mget", "chat message combine-forward", "chat message list-by-ids", "chat message list-emotion-replies"], "risk": "yellow"},
"referenced_open_message_id": {"denotes": "referenced DingTalk openMessageId in a reply", "canonical_hint": "ref-msg-id", "members": ["ref-msg-id", "ref-message-id"], "excludes": ["msg-id", "message-id", "open-message-id", "msg-ids", "src-msg-id"], "commands": ["chat message reply"], "risk": "yellow"},
"open_message_id": {"denotes": "single DingTalk openMessageId with unchanged value", "canonical_hint": "open-message-id", "members": ["msg-id", "message-id", "open-message-id"], "excludes": ["msg-ids", "message-ids", "open-message-ids", "ref-msg-id", "src-msg-id", "open-task-id", "topic-id", "resource-id"], "commands": ["chat +conversation-mark-read", "chat +flag-cancel", "chat +flag-create", "chat +messages-add-emoji", "chat +messages-add-text-emotion", "chat +messages-forward", "chat +messages-read-status", "chat +messages-remove-emoji", "chat +messages-remove-text-emotion", "chat +messages-resource-download", "chat +messages-set-pin", "chat +messages-set-top", "chat +messages-unset-pin", "chat +messages-unset-top", "chat mark-read", "chat message add-emoji", "chat message add-favorite", "chat message add-text-emotion", "chat message download-media", "chat message forward", "chat message read-status", "chat message recall", "chat message remove-emoji", "chat message remove-favorite", "chat message remove-text-emotion", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg"], "risk": "yellow"},
"open_message_ids": {"denotes": "DingTalk openMessageId list with unchanged element values", "canonical_hint": "msg-ids", "members": ["msg-ids", "message-ids", "open-message-ids"], "excludes": ["msg-id", "message-id", "open-message-id", "ref-msg-id", "src-msg-id"], "commands": ["chat +flag-cancel", "chat +flag-create", "chat +messages-combine-forward", "chat +messages-mget", "chat message combine-forward", "chat message list-by-ids", "chat message list-emotion-replies"], "risk": "yellow"},
"referenced_open_message_id": {"denotes": "referenced DingTalk openMessageId in a reply", "canonical_hint": "ref-msg-id", "members": ["ref-msg-id", "ref-message-id"], "excludes": ["msg-id", "message-id", "open-message-id", "msg-ids", "src-msg-id"], "commands": ["chat +messages-reply", "chat message reply"], "risk": "yellow"},
"user_id": {"denotes": "single user id", "canonical_hint": "user-id", "members": ["user", "user-id", "userid", "uid", "staff-id"], "excludes": ["at-user-ids", "to-user", "users", "user-ids", "name"], "commands": ["chat +chat-role-query-user", "chat +chat-role-set-user", "chat +messages-list-direct", "chat chmod", "chat conversation-info", "chat group transfer-owner", "chat group-role query-user", "chat group-role remove-user", "chat group-role set-user", "chat message list", "chat message send", "contact user profile get"], "risk": "yellow"},
"user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"},
"open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"},
"user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat +messages-batch-send-by-bot", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"},
"open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat +chat-members-get", "chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"},
"ding_id": {"denotes": "DING id", "canonical_hint": "ding-id", "members": ["ding-id", "open-ding-id"], "excludes": ["id"], "commands": ["ding message receiver-status"], "risk": "yellow"},
"folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive list", "mail folder update"], "risk": "green"},
"space_id": {"denotes": "drive/wiki space id", "canonical_hint": "space-id", "members": ["space-id", "space", "workspace", "workspace-id"], "excludes": ["folder", "node"], "commands": ["drive info"], "risk": "yellow"},
"space_id": {"denotes": "drive/wiki/Doc workspace id with unchanged value", "canonical_hint": "space-id", "members": ["space-id", "space", "workspace", "workspace-id"], "excludes": ["folder", "node"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +copy", "doc +create", "doc +create-from-template", "doc +grant-and-share", "doc +import", "doc +list", "doc +move", "doc create", "doc file create", "doc import", "doc template apply", "drive info"], "risk": "yellow"},
"app_id": {"denotes": "application id", "canonical_hint": "unified-app-id", "members": ["app-id", "unified-app-id", "application-id"], "excludes": ["app-key", "app-secret", "agent-id"], "commands": ["dev app get"], "risk": "yellow"},
"robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id"], "commands": ["chat group members add-bot", "chat message recall-by-bot", "chat message send-by-bot", "ding message send"], "risk": "yellow"},
"open_bot_id": {"denotes": "single DingTalk openBotId with unchanged value", "canonical_hint": "bot-id", "members": ["bot-id", "open-bot-id"], "excludes": ["robot-code", "robot", "robot-id", "bot-code"], "commands": ["chat group members remove-bot"], "risk": "yellow"}
"robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id", "bot-id", "open-bot-id", "bot-code"], "commands": ["chat +chat-add-bot", "chat +messages-batch-recall-by-bot", "chat +messages-batch-send-by-bot", "chat +messages-recall-by-bot", "chat +messages-send-by-bot", "chat group members add-bot", "chat message recall-by-bot", "chat message send-by-bot", "ding message send"], "risk": "yellow"},
"open_bot_id": {"denotes": "single DingTalk openBotId with unchanged value", "canonical_hint": "bot-id", "members": ["bot-id", "open-bot-id"], "excludes": ["robot-code", "robot", "robot-id", "bot-code"], "commands": ["chat +chat-remove-bot", "chat group members remove-bot"], "risk": "yellow"},
"doc_node_id": {"denotes": "single DingTalk document nodeId or accepted document URL/token with unchanged value", "canonical_hint": "node", "members": ["node", "node-id", "doc", "doc-id", "file-id", "document-id", "url"], "excludes": ["id", "folder", "folder-id", "parent-id", "workspace", "workspace-id", "block-id", "comment-id", "comment-key", "job-id", "task-id", "template-id", "version", "revision"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +background-delete", "doc +background-update", "doc +checkpoint-update", "doc +comment-create", "doc +comment-delete", "doc +comment-list", "doc +comment-reply", "doc +comment-update", "doc +copy", "doc +doc-append", "doc +export", "doc +export-submit", "doc +fetch", "doc +history-list", "doc +history-revert", "doc +history-save", "doc +inspect", "doc +move", "doc +review", "doc +version-list", "doc +version-revert", "doc +version-save", "doc block delete", "doc block insert", "doc block list", "doc block update", "doc comment create", "doc comment create-inline", "doc comment delete", "doc comment list", "doc comment reply", "doc comment update", "doc export", "doc info", "doc media download", "doc media insert", "doc media upload", "doc read", "doc style background clear", "doc style background set", "doc style cover clear", "doc style cover set", "doc style get", "doc update", "doc version list", "doc version revert", "doc version save", "doc whiteboard insert"], "risk": "yellow"},
"doc_comment_key": {"denotes": "single DingTalk document commentKey with unchanged value", "canonical_hint": "comment-key", "members": ["comment-key", "comment-id"], "excludes": ["id", "node", "node-id", "doc-id", "block-id"], "commands": ["doc +comment-delete", "doc +comment-reply", "doc +comment-update", "doc comment delete", "doc comment reply", "doc comment update"], "risk": "yellow"},
"doc_version_number": {"denotes": "single DingTalk document historical version number with unchanged integer value", "canonical_hint": "version", "members": ["version", "version-number", "version-no"], "excludes": ["revision", "id", "node", "node-id", "doc-id"], "commands": ["doc +history-revert", "doc +version-revert", "doc version revert"], "risk": "yellow"},
"doc_content_format": {"denotes": "DingTalk document body format with unchanged markdown/jsonml value", "canonical_hint": "content-format", "members": ["content-format", "doc-format"], "excludes": ["format", "export-format", "mime-type"], "commands": ["doc +create", "doc +update", "doc create", "doc update"], "risk": "green"},
"doc_edit_revision": {"denotes": "single optimistic-concurrency revision for a document edit", "canonical_hint": "revision", "members": ["revision", "expected-revision"], "excludes": ["version", "version-number", "version-no"], "commands": ["doc +update", "doc update"], "risk": "yellow"}
},
"command_overrides": {
@@ -91,10 +96,24 @@
"chat group-role set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."},
"chat +messages-send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact shortcut."},
"chat message send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact command."},
"doc block insert": {"block": ["before-block-id"], "note": "requires a two-parameter conversion to --ref-block plus --where before; name-only normalization would silently default to after"},
"doc block insert": {"block": ["before-block-id"], "note": "Parent and reference roles remain distinct. --before-block-id needs both --ref-block and --where before, while role-free --block-id cannot choose parent versus reference.", "scoped_aliases": {"parent-block-id": "parent-block", "ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "ambiguous": ["block-id"], "scope_strict": true},
"chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain."},
"doc +export-get": {"block": ["node"], "note": "node is a document node id, a different entity from job-id"},
"doc +export-get": {"block": ["doc-id", "document-id", "file-id", "node", "node-id", "task-id", "url"], "note": "This command queries one export jobId. Document node identifiers and import taskId spellings are different entities and are rejected.", "scoped_aliases": {"export-job-id": "job-id"}, "scope_strict": true},
"doc block delete": {"block": ["index"], "note": "index (position) vs node (node id) are different"},
"doc +copy": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
"doc +list": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
"doc +move": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
"doc comment create": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
"doc comment reply": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
"doc comment update": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
"doc +comment-create": {"block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids", "mentioned-open-conversation-id", "mentioned-open-conversation-ids", "open-conversation-id", "open-conversation-ids"], "note": "This shortcut has no group-mention flag or payload field. Group-conversation spellings are blocked instead of inventing unsupported capability; route to the stable doc comment command when group mentions are required."},
"doc +comment-reply": {"block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids", "mentioned-open-conversation-id", "mentioned-open-conversation-ids", "open-conversation-id", "open-conversation-ids"], "note": "This shortcut has no group-mention flag or payload field. Group-conversation spellings are blocked instead of inventing unsupported capability; route to the stable doc comment command when group mentions are required."},
"doc media insert": {"scoped_aliases": {"ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "block": ["before-block-id", "parent-block", "parent-block-id"], "ambiguous": ["block-id"], "scope_strict": true, "note": "Media insertion supports a reference block but no parent-block role. --before-block-id additionally needs --where before; role-free --block-id is left ambiguous."},
"doc read": {"block": ["before-block-id", "parent-block-id", "ref-block-id", "reference-block-id"], "ambiguous": ["block-id"], "note": "A section read requires --scope section plus a start/end boundary. A role-free --block-id cannot be reduced to one flag without inventing the missing scope/boundary role."},
"doc export get": {"scoped_aliases": {"export-job-id": "job-id"}, "block": ["doc-id", "document-id", "file-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one export jobId. Document node identifiers are rejected; native hidden --task-id remains the command's reviewed add-only compatibility alias for --job-id."},
"doc import get": {"scoped_aliases": {"import-task-id": "task-id"}, "block": ["doc-id", "document-id", "file-id", "job-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one import taskId. Document node identifiers and export jobId spellings are different entities and are rejected."},
"doc +share-doc": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a document nodeId into a URL, so identifier spellings are rejected with guidance to provide --url."},
"doc update": {"block": ["version", "version-no", "version-number"], "note": "--revision is an optimistic-concurrency revision, not a historical document version number. Version spellings must not reduce to --revision."},
"report outbox list": {"block": ["template-type"], "note": "type vs name are different fields"},
"chat group members add-bot": {"bind": {"id": "open_conversation_id"}},
"chat group members list-by-ids": {"bind": {"id": "open_conversation_id", "users": "open_dingtalk_ids"}, "block": ["user-id", "user-ids"], "note": "This command's --id carries openConversationId, while --users carries an openDingTalkId list."},
@@ -111,7 +130,48 @@
"chat category create-smart": {"bind": {"members": "open_dingtalk_ids"}, "scoped_aliases": {"title": "name"}, "note": "The real --members is an openDingTalkId list; the reviewed title/name alias is exact to the category display name."},
"chat group audit-join-validation": {"ambiguous": ["user", "user-id", "userid", "uid", "staff-id"], "note": "A role-free user identifier cannot choose between the required --applicant and --inviter roles."},
"chat message reply": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The required --ref-sender is a role-specific openDingTalkId and must not accept generic userId spellings."},
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."}
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."},
"chat +category-add-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
"chat +category-list-conversations": {"block": ["category-ids"], "note": "The real --category-id is singular; list cardinality is not reduced automatically."},
"chat +category-remove-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
"chat +chat-add-bot": {"bind": {"id": "open_conversation_id"}, "note": "The real --id is the group's openConversationId; robotCode and openBotId remain different domains."},
"chat +chat-audit-join": {"scoped_aliases": {"applicant-user-id": "applicant", "inviter-user-id": "inviter"}, "ambiguous": ["user", "user-id", "userid", "uid", "staff-id"], "scope_strict": true, "note": "A role-free user identifier cannot choose between applicant and inviter."},
"chat +chat-create": {"block": ["user-id", "open-dingtalk-id"], "note": "The real --users field is a list and may contain mixed identifier domains; a singular value is not promoted."},
"chat +chat-get-by-id": {"block": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "note": "The real --group-id is numeric groupId; no CID or group-name spelling can be value-preservingly converted."},
"chat +chat-members-get": {"bind": {"id": "open_conversation_id", "users": "open_dingtalk_ids"}, "block": ["group", "group-name", "user-id", "user-ids"], "note": "The real --id is openConversationId and --users is an openDingTalkId list. The observed --group spelling carried a natural group name and is blocked; explicit CID spellings and --chat remain value-preserving aliases."},
"chat +chat-members-list": {"scoped_aliases": {"chat-id": "conversation-id", "id": "conversation-id"}, "block": ["query", "keyword", "group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "scope_strict": true, "note": "Native --chat/--open-conversation-id stay native; member filtering by query is unsupported and group-name resolution stays on --group/--chat-query."},
"chat +chat-mute-member": {"scoped_aliases": {"user-ids": "users", "open-dingtalk-ids": "users"}, "block": ["user", "user-id", "open-dingtalk-id"], "scope_strict": true, "note": "The target accepts a mixed identifier list; list spellings preserve values, but singular inputs are not promoted."},
"chat +chat-remove-bot": {"bind": {"id": "open_conversation_id"}, "note": "The real --id is openConversationId; --bot-id is separately governed by open_bot_id."},
"chat +chat-role-remove": {"block": ["role-ids"], "note": "The command removes one role ID; list cardinality is not reduced."},
"chat +chat-role-remove-user": {"scoped_aliases": {"user-id": "user", "open-dingtalk-id": "user"}, "block": ["role-id"], "scope_strict": true, "note": "The single --user accepts either identifier domain; --role-ids remains a list."},
"chat +chat-transfer-owner": {"scoped_aliases": {"user-id": "new-owner", "open-dingtalk-id": "new-owner"}, "scope_strict": true, "note": "The only user role is the new owner, and the target accepts either userId or openDingTalkId without changing the value."},
"chat +chat-update": {"scoped_aliases": {"conversation-id": "group", "open-conversation-id": "group", "chat-id": "group", "title": "name", "new-title": "name"}, "block": ["id", "group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "scope_strict": true, "note": "--group accepts a name or CID, so only explicit CID spellings are mapped; generic --id is blocked."},
"chat +conversation-set-top": {"scoped_aliases": {"open-conversation-id": "conversation-id", "chat-id": "conversation-id", "open-conversation-ids": "conversation-ids", "chat-ids": "conversation-ids"}, "block": ["group", "groups", "group-id", "group-ids", "top", "set-top"], "scope_strict": true, "note": "Singular/list cardinality stays explicit; top/set-top cannot be rewritten to the inverse --off switch."},
"chat +feed-group-query-item": {"scoped_aliases": {"open-conversation-ids": "conversation-ids", "chat-ids": "conversation-ids"}, "block": ["group", "groups", "group-id", "group-ids", "conversation-id", "open-conversation-id"], "scope_strict": true, "note": "The real field is an openConversationId list; group names and singular IDs are not converted."},
"chat +flag-list": {"scoped_aliases": {"limit": "size"}, "block": ["max", "max-results", "max-size", "count", "page", "page-size", "per-page"], "scope_strict": true, "note": "Only limit and size are reviewed as the same page bound; total-count and page-number spellings are not equivalent."},
"chat +messages-batch-recall-by-bot": {"block": ["msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "note": "--keys carries processQueryKey values returned by bot sending; it is not an openMessageId field."},
"chat +messages-combine-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "Source and destination conversation roles remain explicit; role-free CID spellings cannot choose a side."},
"chat +messages-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id", "src-open-message-id": "msg-id", "source-message-id": "msg-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "The message role is uniquely the source message, but source/destination conversation roles cannot be inferred from a generic CID."},
"chat +messages-forward-topic": {"scoped_aliases": {"src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id", "src-open-message-id": "src-msg-id", "source-message-id": "src-msg-id"}, "block": ["group-id", "group-ids", "msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "Source and destination conversation roles and the source-message role remain explicit; role-free message IDs and list cardinality are not inferred."},
"chat +messages-list": {"scoped_aliases": {"start": "time"}, "block": ["before", "before-time", "end", "direction", "page-all", "count", "max", "max-results", "max-size", "page-size"], "scope_strict": true, "note": "start preserves the same boundary value; before/direction require multi-parameter or value transforms and page-all requires iteration. Native --conversation-id/--id/--size remain native."},
"chat +messages-recall-by-bot": {"block": ["msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "note": "--keys carries processQueryKey values, not openMessageId values."},
"chat +messages-reply": {"scoped_aliases": {"msg-id": "ref-msg-id", "open-message-id": "ref-msg-id"}, "block": ["group", "msg-ids", "message-ids", "open-message-ids"], "scope_strict": true, "note": "The observed --group spelling carried a natural group name and is blocked. The only message role is the referenced message; plural IDs are not accepted, while --chat remains a CID alias and native --message-id stays native."},
"chat +messages-resource-download": {"block": ["download-dir"], "note": "--output may be a file or directory under workspace safety rules; a download directory cannot be assumed equivalent."},
"doc +create": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["content-file", "parent-id"], "scope_strict": true, "note": "--content-format is value-preservingly normalized to --doc-format. A raw --content-file path cannot become --content without adding the required @file transform, so it is blocked with guidance to use @relative-path or stable doc create."},
"doc +create-from-template": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
"doc +import": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
"doc +update": {"scoped_aliases": {"mode": "command", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "url": "node"}, "block": ["content-file"], "scope_strict": true, "note": "--mode append/overwrite is the same operation selector subset as --command and preserves its value. --content-file is blocked because +update requires @relative-path or stdin and central aliases cannot read/transform a file value."},
"doc +inspect": {"scoped_aliases": {"include-versions": "include-history"}, "block": ["include", "include-info"], "scope_strict": true, "note": "Historical versions and history are the same optional section on this exact shortcut. Generic --include needs value-dependent flag expansion, while base document info is always returned, so those spellings are rejected with precise guidance."},
"doc +fetch": {"scoped_aliases": {"start-block": "start-block-id", "end-block": "end-block-id"}, "ambiguous": ["block-id"], "scope_strict": true, "note": "Start/end block roles are preserved. A role-free --block-id cannot choose a range/section boundary and must stop before execution."},
"doc +media-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose between document and media identities."},
"doc +media-insert": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and local-media roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."},
"doc +media-list": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "The command lists media inside one document, so only strong document spellings map to --node. File and URL spellings remain role-ambiguous."},
"doc +media-preview": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."},
"doc +resource-delete": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command removes a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."},
"doc +resource-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command downloads a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."},
"doc +resource-update": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has document-node, local-file, and HTTPS image URL roles. Only strong document spellings map to --node; --file-id and --url must stop as ambiguous."},
"doc +share": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a node identifier into a URL, so identifier spellings are rejected with guidance to provide --url."},
"doc +grant-and-share": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node"}, "scope_strict": true, "note": "This workflow has two different real URL roles: --node selects the document for access control and --url is the shareable link sent to recipients. Explicit document-ID spellings map only to --node; --url remains native."}
},
"validation_fixture": {
@@ -258,8 +318,117 @@
{"command": "chat +unread-chats", "emitted": "size", "expect": "count", "via": "override:scoped"},
{"command": "chat category rename", "emitted": "name", "expect": "title", "via": "override:scoped"},
{"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"},
{"command": "doc +comment-create", "emitted": "node-id", "expect": "node", "via": "concept:doc_node_id"},
{"command": "doc +doc-append", "emitted": "node", "expect": "doc", "via": "concept:doc_node_id"},
{"command": "doc +find-doc", "emitted": "keyword", "expect": "query", "via": "concept:search_query"},
{"command": "doc +search", "emitted": "q", "expect": "query", "via": "concept:search_query"},
{"command": "doc +comment-list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size"},
{"command": "doc +list", "emitted": "page-token", "expect": "cursor", "via": "concept:page_cursor"},
{"command": "doc +copy", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"},
{"command": "doc +copy", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
{"command": "doc +copy", "emitted": "parent-id", "expect": "did-you-mean:blocked", "via": "guard:doc-folder-value-domain"},
{"command": "doc +comment-reply", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
{"command": "doc comment reply", "emitted": "mentioned-open-conversation-ids", "expect": "mentioned-open-conversation-id", "via": "override:scoped-role-list"},
{"command": "doc comment reply", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:numeric-group-id-vs-open-conversation-id"},
{"command": "doc +comment-create", "emitted": "mentioned-open-conversation-id", "expect": "did-you-mean:blocked", "via": "guard:shortcut-missing-capability"},
{"command": "doc block insert", "emitted": "parent-block-id", "expect": "parent-block", "via": "override:scoped-block-role"},
{"command": "doc block insert", "emitted": "block-id", "expect": "did-you-mean:ambiguous", "via": "guard:parent-vs-reference-block-role"},
{"command": "doc media insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-ref-block-plus-where"},
{"command": "doc read", "emitted": "block-id", "expect": "did-you-mean:ambiguous", "via": "guard:requires-scope-and-boundary-role"},
{"command": "doc export get", "emitted": "node", "expect": "did-you-mean:blocked", "via": "guard:document-node-vs-export-job"},
{"command": "doc import get", "emitted": "job-id", "expect": "did-you-mean:blocked", "via": "guard:export-job-vs-import-task"},
{"command": "doc +version-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
{"command": "doc +version-revert", "emitted": "revision", "expect": "did-you-mean:blocked", "via": "concept:doc_version_number+exclude"},
{"command": "doc update", "emitted": "version", "expect": "did-you-mean:blocked", "via": "guard:historical-version-vs-edit-revision"},
{"command": "doc +share-doc", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"},
{"command": "doc +comment-create", "emitted": "body", "expect": "content", "via": "concept:content_text"},
{"command": "doc +doc-append", "emitted": "content", "expect": "text", "via": "concept:content_text"},
{"command": "doc +export-submit", "emitted": "doc-id", "expect": "node", "via": "concept:doc_node_id"},
{"command": "doc +move", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
{"command": "doc +template-list", "emitted": "next-token", "expect": "cursor", "via": "concept:page_cursor"},
{"command": "doc +version-list", "emitted": "page-size", "expect": "limit", "via": "concept:pagination_size"},
{"command": "doc +version-save", "emitted": "file-id", "expect": "node", "via": "concept:doc_node_id"},
{"command": "doc comment create", "emitted": "text", "expect": "content", "via": "concept:content_text"},
{"command": "doc comment create-inline", "emitted": "body", "expect": "content", "via": "concept:content_text"},
{"command": "doc comment delete", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
{"command": "doc comment update", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
{"command": "doc version revert", "emitted": "version-no", "expect": "version", "via": "concept:doc_version_number"},
{"command": "chat +chat-messages", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"},
{"command": "chat +search-msg", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"}
{"command": "chat +search-msg", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"},
{"command": "chat +chat-update", "emitted": "chat-id", "expect": "group", "via": "override:scoped-explicit-cid"},
{"command": "chat +chat-update", "emitted": "conversation-id", "expect": "group", "via": "override:scoped-explicit-cid"},
{"command": "chat +chat-update", "emitted": "open-conversation-id", "expect": "group", "via": "override:scoped-explicit-cid"},
{"command": "chat +chat-update", "emitted": "id", "expect": "did-you-mean:blocked", "via": "guard:generic-id-value-domain"},
{"command": "chat +chat-update", "emitted": "title", "expect": "name", "via": "override:scoped-group-title"},
{"command": "chat +chat-update", "emitted": "new-title", "expect": "name", "via": "override:scoped-group-title"},
{"command": "chat +flag-list", "emitted": "limit", "expect": "size", "via": "override:scoped-page-bound"},
{"command": "chat +flag-list", "emitted": "max", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
{"command": "chat +flag-list", "emitted": "max-results", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
{"command": "chat +flag-list", "emitted": "max-size", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
{"command": "chat +chat-members-list", "emitted": "chat-id", "expect": "conversation-id", "via": "override:scoped-explicit-cid"},
{"command": "chat +chat-members-list", "emitted": "id", "expect": "conversation-id", "via": "override:scoped-explicit-cid"},
{"command": "chat +chat-members-list", "emitted": "query", "expect": "did-you-mean:blocked", "via": "guard:unsupported-member-filter"},
{"command": "chat +conversation-set-top", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "override:scoped-explicit-cid"},
{"command": "chat +conversation-set-top", "emitted": "chat-ids", "expect": "conversation-ids", "via": "override:scoped-explicit-cid-list"},
{"command": "chat +conversation-set-top", "emitted": "groups", "expect": "did-you-mean:blocked", "via": "guard:group-name-or-list-ambiguity"},
{"command": "chat +conversation-set-top", "emitted": "top", "expect": "did-you-mean:blocked", "via": "guard:inverse-boolean-semantics"},
{"command": "chat +chat-members-get", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
{"command": "chat +chat-members-get", "emitted": "open-dingtalk-ids", "expect": "users", "via": "concept:open_dingtalk_ids+bind"},
{"command": "chat +chat-members-get", "emitted": "group", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"},
{"command": "chat +chat-members-get", "emitted": "chat", "expect": "id", "via": "concept:open_conversation_id+bind"},
{"command": "chat +chat-get-by-id", "emitted": "group", "expect": "did-you-mean:blocked", "via": "guard:open-conversation-id-vs-numeric-group-id"},
{"command": "chat +messages-list", "emitted": "start", "expect": "time", "via": "override:scoped-time-boundary"},
{"command": "chat +messages-list", "emitted": "count", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
{"command": "chat +messages-list", "emitted": "max-results", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
{"command": "chat +messages-list", "emitted": "page-all", "expect": "did-you-mean:blocked", "via": "guard:requires-pagination-loop"},
{"command": "chat +messages-reply", "emitted": "msg-id", "expect": "ref-msg-id", "via": "override:scoped-reference-message"},
{"command": "chat +messages-reply", "emitted": "group", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"},
{"command": "chat +messages-reply", "emitted": "chat", "expect": "conversation-id", "via": "concept:open_conversation_id"},
{"command": "chat +flag-cancel", "emitted": "group", "expect": "conversation-id", "via": "concept:open_conversation_id"},
{"command": "chat +flag-cancel", "emitted": "chat", "expect": "conversation-id", "via": "concept:open_conversation_id"},
{"command": "chat +flag-create", "emitted": "group", "expect": "conversation-id", "via": "concept:open_conversation_id"},
{"command": "chat +chat-add-bot", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
{"command": "chat +chat-add-bot", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code"},
{"command": "chat +chat-audit-join", "emitted": "applicant-user-id", "expect": "applicant", "via": "override:scoped-user-role"},
{"command": "chat +chat-audit-join", "emitted": "user-id", "expect": "did-you-mean:ambiguous", "via": "guard:applicant-vs-inviter-role"},
{"command": "chat +chat-create", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
{"command": "chat +chat-mute-member", "emitted": "user-ids", "expect": "users", "via": "override:scoped-mixed-id-list"},
{"command": "chat +chat-mute-member", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
{"command": "chat +chat-remove-bot", "emitted": "open-bot-id", "expect": "bot-id", "via": "concept:open_bot_id"},
{"command": "chat +chat-role-remove", "emitted": "role-ids", "expect": "did-you-mean:blocked", "via": "guard:list-vs-single"},
{"command": "chat +chat-role-remove-user", "emitted": "open-dingtalk-id", "expect": "user", "via": "override:scoped-mixed-id"},
{"command": "chat +chat-transfer-owner", "emitted": "user-id", "expect": "new-owner", "via": "override:scoped-owner-role"},
{"command": "chat +feed-group-query-item", "emitted": "chat-ids", "expect": "conversation-ids", "via": "override:scoped-explicit-cid-list"},
{"command": "chat +feed-group-query-item", "emitted": "conversation-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
{"command": "chat +messages-batch-recall-by-bot", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:process-query-key-vs-open-message-id"},
{"command": "chat +messages-combine-forward", "emitted": "src-open-cid", "expect": "src-conversation-id", "via": "override:scoped-source-role"},
{"command": "chat +messages-combine-forward", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-destination-role"},
{"command": "chat +messages-forward", "emitted": "source-message-id", "expect": "msg-id", "via": "override:scoped-source-role"},
{"command": "chat +messages-forward", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-destination-role"},
{"command": "chat +messages-forward-topic", "emitted": "src-open-message-id", "expect": "src-msg-id", "via": "override:scoped-source-role"},
{"command": "chat +messages-forward-topic", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:message-source-role"},
{"command": "chat +messages-recall-by-bot", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:process-query-key-vs-open-message-id"},
{"command": "chat +messages-resource-download", "emitted": "conversation-id", "expect": "open-conversation-id", "via": "concept:open_conversation_id"},
{"command": "chat +messages-resource-download", "emitted": "download-dir", "expect": "did-you-mean:blocked", "via": "guard:output-file-or-directory-contract"},
{"command": "chat +messages-set-pin", "emitted": "conversation-id", "expect": "open-conversation-id", "via": "concept:open_conversation_id"},
{"command": "doc +create", "emitted": "content-format", "expect": "doc-format", "via": "concept:doc_content_format"},
{"command": "doc +create", "emitted": "content-file", "expect": "did-you-mean:blocked", "via": "guard:requires-file-read-transform"},
{"command": "doc +inspect", "emitted": "include-versions", "expect": "include-history", "via": "override:scoped-section"},
{"command": "doc +inspect", "emitted": "include", "expect": "did-you-mean:blocked", "via": "guard:requires-value-dependent-flag-expansion"},
{"command": "doc +inspect", "emitted": "include-info", "expect": "did-you-mean:blocked", "via": "guard:base-info-always-returned"},
{"command": "doc +update", "emitted": "mode", "expect": "command", "via": "override:scoped-operation"},
{"command": "doc +update", "emitted": "revision", "expect": "expected-revision", "via": "concept:doc_edit_revision"},
{"command": "doc +update", "emitted": "version", "expect": "did-you-mean:blocked", "via": "guard:historical-version-vs-edit-revision"},
{"command": "doc +fetch", "emitted": "start-block", "expect": "start-block-id", "via": "override:scoped-boundary-role"},
{"command": "doc +fetch", "emitted": "block-id", "expect": "did-you-mean:ambiguous", "via": "guard:start-vs-end-boundary-role"},
{"command": "doc +access-grant", "emitted": "doc-id", "expect": "node", "via": "concept:doc_node_id"},
{"command": "doc +history-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
{"command": "doc +create-from-template", "emitted": "keyword", "expect": "query", "via": "concept:search_query"},
{"command": "doc +create-from-template", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"},
{"command": "doc +create-from-template", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
{"command": "doc +media-download", "emitted": "file-id", "expect": "did-you-mean:ambiguous", "via": "guard:document-node-vs-attachment-resource-role"},
{"command": "doc +resource-update", "emitted": "url", "expect": "did-you-mean:ambiguous", "via": "guard:document-url-vs-image-url-role"},
{"command": "doc +share", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"}
]
}
}
+85 -184
View File
@@ -89,9 +89,9 @@ func RegisterRuntimeSchemaConstraints(canonicalPath string, constraints RuntimeS
}
// emptyPinnedMCPMetadata returns the retired pin shape with no tools.
// schema_mcp_metadata.json is deleted; production assembly does not embed or
// load a pinned MCP snapshot. Test fixtures may still inject non-empty maps
// through schemaRegistryForTestWithMetadata.
// schema_mcp_metadata.json is deleted; Schema parameter assembly never loads
// or ranks MCP pin candidates. Optional Interface-registry validators may
// still pass this empty shape when they only need ContractFinal self-checks.
func emptyPinnedMCPMetadata() embeddedMCPMetadata {
return embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}
}
@@ -217,62 +217,6 @@ func collectRuntimeSchemaEntriesFromBound(bound BoundCommandRegistry) ([]runtime
return entries, nil
}
func pinnedMCPMetadataForEntryFrom(entry runtimeSchemaEntry, agentMetadata agentMetadata, mcpMetadata embeddedMCPMetadata) (embeddedMCPToolMetadata, bool) {
// Optional test/diagnostic lookup only. Production mcpMetadata is empty;
// Contract/ParamDecl own interface facts. When a non-empty fixture is
// injected, ContractFinal Interface.Ref remaps CLI canonical names onto
// fixture keys (e.g. reply_personal_message → chat.send_personal_message).
if len(mcpMetadata.Tools) == 0 {
return embeddedMCPToolMetadata{}, false
}
if entry.Command != nil {
if final, ok := RuntimeContractFinal(entry.Command); ok && final.Interface != nil && final.Interface.Ref != nil {
if metadata, found := mcpMetadataForInterfaceRef(mcpMetadata, final.Interface.Ref.ProductID, final.Interface.Ref.RPCName); found {
return metadata, true
}
}
}
paths := []string{
entry.PrimaryCLIPath,
entry.CLIPath,
entry.ProductID + "." + entry.ToolName,
}
paths = append(paths, entry.Aliases...)
if toolMetadata, ok := lookupAgentToolMetadataFrom(agentMetadata, paths...); ok && toolMetadata.InterfaceRef != nil {
if metadata, found := mcpMetadataForInterfaceRef(mcpMetadata, toolMetadata.InterfaceRef.ProductID, toolMetadata.InterfaceRef.RPCName); found {
return metadata, true
}
}
for _, key := range []string{
entry.SourceProductID + "." + entry.ToolName,
entry.ProductID + "." + entry.ToolName,
} {
key = strings.Trim(key, ".")
if key == "" {
continue
}
if meta, ok := mcpMetadata.Tools[key]; ok {
return meta, true
}
}
return embeddedMCPToolMetadata{}, false
}
func mcpMetadataForInterfaceRef(mcpMetadata embeddedMCPMetadata, productID, rpcName string) (embeddedMCPToolMetadata, bool) {
productID = strings.TrimSpace(productID)
rpcName = strings.TrimSpace(rpcName)
key := strings.Trim(productID+"."+rpcName, ".")
if key == "" {
return embeddedMCPToolMetadata{}, false
}
metadata, exists := mcpMetadata.Tools[key]
if !exists {
return embeddedMCPToolMetadata{}, false
}
metadata.InterfaceRef = &embeddedMCPInterfaceRef{ProductID: productID, RPCName: rpcName}
return metadata, true
}
func runtimeSchemaAnnotations(cmd *cobra.Command) (productID, toolName, source string) {
if cmd == nil || cmd.Annotations == nil {
return "", "", ""
@@ -322,7 +266,6 @@ const (
runtimeSchemaRankDefault = 0
runtimeSchemaRankDerived = 50
runtimeSchemaRankInference = 100
runtimeSchemaRankMCP = 400
runtimeSchemaRankCobraHelp = 450
runtimeSchemaRankCobraDefault = 600
runtimeSchemaRankCobraContract = 610
@@ -332,7 +275,7 @@ const (
runtimeSchemaRankVersionedBinding = 650
// ParamDecl.Property (dws.schema.property) outranks residual versioned
// binding candidates (active bindings JSON is empty after Phase 2).
// Mapping exclusions stay highest so an explicit "no MCP property" review
// Mapping exclusions stay highest so an explicit "no RPC property" review
// cannot be overridden by a leaf ParamDecl that still carries a Property.
runtimeSchemaRankParamDeclProperty = 655
runtimeSchemaRankMappingExclusion = 660
@@ -340,7 +283,6 @@ const (
runtimeSchemaPrecedenceDefault = "default"
runtimeSchemaPrecedenceDerived = "derived_resolution"
runtimeSchemaPrecedenceInference = "inference"
runtimeSchemaPrecedenceMCP = "mcp_metadata"
runtimeSchemaPrecedenceCobraHelp = "cobra_help"
runtimeSchemaPrecedenceCobra = "cobra_contract"
runtimeSchemaPrecedenceNativeAnnotation = "native_annotation"
@@ -533,8 +475,6 @@ func runtimeSchemaSourcePriority(source string) (int, string) {
return runtimeSchemaRankCobraDefault, runtimeSchemaPrecedenceCobra
}
return runtimeSchemaRankCobraContract, runtimeSchemaPrecedenceCobra
case "mcp_metadata", "pinned_mcp_metadata":
return runtimeSchemaRankMCP, runtimeSchemaPrecedenceMCP
case "cobra_help":
return runtimeSchemaRankCobraHelp, runtimeSchemaPrecedenceCobraHelp
case "flag_name_inference", "usage_required_inference", "usage_format_inference":
@@ -575,9 +515,9 @@ func runtimeSchemaParameterMappingKey(canonicalPath, flagName string) string {
// runtimeSchemaParameterMappingCandidates resolves the two reviewed,
// versioned property-mapping inputs. An exclusion is an explicit statement
// that the CLI parameter is not a direct MCP property: it therefore supplies
// a present empty candidate (rather than allowing name inference to survive)
// and keeps the review reason in provenance.
// that the CLI parameter is not a direct RPC/interface property: it therefore
// supplies a present empty candidate (rather than allowing name inference to
// survive) and keeps the review reason in provenance.
func runtimeSchemaParameterMappingCandidates(snapshot schemaParameterBindingSnapshot, canonicalPath, flagName string) (runtimeSchemaFieldCandidate, runtimeSchemaFieldCandidate, error) {
binding := strings.TrimSpace(snapshot.Bindings[strings.TrimSpace(canonicalPath)][strings.TrimSpace(flagName)])
bindingCandidate := runtimeSchemaStringCandidate(binding, "versioned_parameter_binding")
@@ -605,34 +545,24 @@ func runtimeSchemaParameterMappingCandidates(snapshot schemaParameterBindingSnap
type runtimeParameterFieldContext struct {
flag *pflag.Flag
metadata RuntimeSchemaParameterMetadata
pinnedParam embeddedMCPParamMeta
hasPinned bool
paramType string
constraints RuntimeSchemaConstraints
property string
}
func (c runtimeParameterFieldContext) interfaceTypeCandidates() []runtimeSchemaFieldCandidate {
candidates := []runtimeSchemaFieldCandidate{
return []runtimeSchemaFieldCandidate{
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagTypeAnnotation), "native_annotation"),
}
if c.hasPinned {
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.Type, "mcp_metadata"))
}
return append(candidates,
runtimeSchemaStringCandidateAtRank(c.paramType, "cobra_flag_type", runtimeSchemaRankInference, "fallback"),
)
}
}
func (c runtimeParameterFieldContext) descriptionCandidates() []runtimeSchemaFieldCandidate {
candidates := []runtimeSchemaFieldCandidate{
return []runtimeSchemaFieldCandidate{
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagDescriptionAnnotation), "native_annotation"),
runtimeSchemaStringCandidate(c.flag.Usage, "cobra_usage"),
runtimeSchemaCandidate("", true, "default"),
}
if c.hasPinned {
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.Description, "mcp_metadata"))
}
return append(candidates, runtimeSchemaCandidate("", true, "default"))
}
func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldCandidate {
@@ -649,7 +579,7 @@ func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldC
break
}
}
candidates := []runtimeSchemaFieldCandidate{
return []runtimeSchemaFieldCandidate{
constraintRequired,
runtimeSchemaCandidate(true, typedRequired, "typed_parameter_metadata"),
runtimeSchemaAnnotatedBoolCandidate(c.flag, runtimeSchemaFlagMetadataRequiredAnnotation, "typed_parameter_metadata"),
@@ -657,50 +587,36 @@ func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldC
runtimeSchemaCandidate(true, runtimeFlagCobraHardRequired(c.flag), "cobra_hard_required"),
runtimeSchemaCandidate(false, cobraDefaultOptional, "cobra_nonzero_default"),
runtimeSchemaCandidate(usageRequired, usageRequired, "usage_required_inference"),
runtimeSchemaCandidate(false, true, "default"),
}
if c.hasPinned && c.pinnedParam.Required != nil {
candidates = append(candidates, runtimeSchemaCandidate(*c.pinnedParam.Required, true, "mcp_metadata"))
}
return append(candidates, runtimeSchemaCandidate(false, true, "default"))
}
func (c runtimeParameterFieldContext) requiredWhenCandidates() []runtimeSchemaFieldCandidate {
candidates := []runtimeSchemaFieldCandidate{
return []runtimeSchemaFieldCandidate{
runtimeSchemaStringCandidate(c.metadata.RequiredWhen[c.flag.Name], "typed_parameter_metadata"),
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagMetadataRequiredWhenAnnotation), "typed_parameter_metadata"),
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagRequiredWhenAnnotation), "native_annotation"),
runtimeSchemaCandidate("", true, "default"),
}
if c.hasPinned {
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.RequiredWhen, "mcp_metadata"))
}
return append(candidates, runtimeSchemaCandidate("", true, "default"))
}
func (c runtimeParameterFieldContext) formatCandidates() []runtimeSchemaFieldCandidate {
candidates := []runtimeSchemaFieldCandidate{
return []runtimeSchemaFieldCandidate{
runtimeSchemaStringCandidate(c.metadata.Formats[c.flag.Name], "typed_parameter_metadata"),
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagMetadataFormatAnnotation), "typed_parameter_metadata"),
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, "x-cli-format"), "native_annotation"),
}
if c.hasPinned {
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.Format, "mcp_metadata"))
}
return append(candidates,
runtimeSchemaStringCandidate(inferredRuntimeFlagFormat(c.flag), "usage_format_inference"),
runtimeSchemaCandidate("", true, "default"),
)
}
}
func (c runtimeParameterFieldContext) enumCandidates() []runtimeSchemaFieldCandidate {
candidates := []runtimeSchemaFieldCandidate{
return []runtimeSchemaFieldCandidate{
runtimeSchemaEnumCandidate(c.metadata.Enums[c.flag.Name], "typed_parameter_metadata"),
runtimeSchemaEnumCandidate(runtimeFlagEnumAnnotation(c.flag, runtimeSchemaFlagMetadataEnumAnnotation), "typed_parameter_metadata"),
runtimeSchemaEnumCandidate(runtimeFlagEnum(c.flag), "native_annotation"),
runtimeSchemaCandidate([]string{}, true, "default"),
}
if c.hasPinned {
candidates = append(candidates, runtimeSchemaEnumCandidate(c.pinnedParam.Enum, "mcp_metadata"))
}
return append(candidates, runtimeSchemaCandidate([]string{}, true, "default"))
}
func (c runtimeParameterFieldContext) exampleCandidates() []runtimeSchemaFieldCandidate {
@@ -717,7 +633,8 @@ func (c runtimeParameterFieldContext) exampleCandidates() []runtimeSchemaFieldCa
// source may intentionally raise or lower type/mapping/description semantics.
// required is different: Cobra MarkFlagRequired is a hard floor that no
// lower-priority source may demote (see resolveRequiredProjection).
func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinnedParams map[string]embeddedMCPParamMeta, constraints RuntimeSchemaConstraints) ([]ParameterSpec, error) {
// MCP pin / mcp_metadata is not a candidate source.
func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, constraints RuntimeSchemaConstraints) ([]ParameterSpec, error) {
if cmd == nil {
return nil, nil
}
@@ -758,18 +675,10 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
return
}
property, _ := propertyWinner.Value.(string)
// pinnedParams remains for test fixtures that inject MCP-shaped maps;
// production assembly always passes an empty map (pin retired).
pinnedParam, hasPinnedParam := embeddedMCPParamMeta{}, false
if len(pinnedParams) > 0 && strings.TrimSpace(property) != "" {
pinnedParam, hasPinnedParam = lookupPinnedMCPParam(pinnedParams, property, flag.Name)
}
paramType := runtimeFlagCLIType(flag)
fieldCtx := runtimeParameterFieldContext{
flag: flag,
metadata: metadata,
pinnedParam: pinnedParam,
hasPinned: hasPinnedParam,
paramType: paramType,
constraints: constraints,
property: property,
@@ -794,10 +703,6 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
return
}
description, _ := descriptionWinner.Value.(string)
interfaceDescription := ""
if hasPinnedParam {
interfaceDescription = strings.TrimSpace(pinnedParam.Description)
}
// Required uses field-level safe merge: higher sources may raise required, but
// Cobra MarkFlagRequired cannot be projected away as optional.
@@ -838,9 +743,6 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
runtimeSchemaCandidate(true, true, "cobra_hard_required"),
)
}
if interfaceDescription != "" && interfaceDescription != description {
parameter.InterfaceDescription = interfaceDescription
}
if interfaceType != "" && interfaceType != paramType {
parameter.InterfaceType = interfaceType
fieldProvenance["interface_type"] = runtimeSchemaFieldProvenance(interfaceTypeWinner)
@@ -855,12 +757,6 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
if def := runtimeFlagDefault(flag); def != "" {
parameter.Default = runtimeSchemaJSONString(def)
}
if hasPinnedParam {
interfaceDefault := strings.TrimSpace(pinnedParam.Default)
if interfaceDefault != "" && interfaceDefault != runtimeFlagDefault(flag) {
parameter.InterfaceDefault = runtimeSchemaJSONString(interfaceDefault)
}
}
formatWinner, ok := resolveField("format", fieldCtx.formatCandidates())
if !ok {
return
@@ -991,19 +887,6 @@ func runtimeSchemaConstraintsEmpty(constraints RuntimeSchemaConstraints) bool {
return runtimeannotate.ConstraintsEmpty(constraints)
}
func lookupPinnedMCPParam(params map[string]embeddedMCPParamMeta, property, flagName string) (embeddedMCPParamMeta, bool) {
if len(params) == 0 {
return embeddedMCPParamMeta{}, false
}
if meta, ok := params[property]; ok {
return meta, true
}
if meta, ok := params[flagName]; ok {
return meta, true
}
return embeddedMCPParamMeta{}, false
}
func isGenericPayloadFlag(flag *pflag.Flag) bool {
if flag == nil {
return false
@@ -1162,69 +1045,87 @@ func strconvQuote(value string) string {
// ─── --compact mode ──────────────────────────────────────────────────────────
// schemaCompactStripKeys are top-level tool/product keys removed in --compact mode.
var schemaCompactStripKeys = map[string]bool{
// provenance / debug
"agent_metadata_source": true,
"agent_source_refs": true,
"agent_summary_source": true,
"effect_source": true,
"metadata_source": true,
"source": true,
"agent_metadata": true,
"field_provenance": true,
"reviewed": true,
// redundant with canonical_path / cli_path
"name": true,
"path": true,
"cli_name": true,
"primary_cli_path": true,
"is_alias": true,
"has_parameters": true,
"parameter_count": true,
"product_id": true,
"display": true,
"title": true,
"group": true,
"source_product_id": true,
"aliases": true,
"catalog_hash": true,
"surface_hash": true,
"workflow_refs": true,
"prerequisites": true,
"tips": true,
"interface_ref": true,
// schemaCompactPayloadKeys is the reviewed Agent-view allowlist. Keep this a
// positive list: a new full/audit field must not silently expand routine Agent
// context just because it was added to ToolSpec.ToPayload.
var schemaCompactPayloadKeys = map[string]bool{
// Navigation envelopes.
"kind": true, "level": true, "count": true, "tool_count": true,
"products": true, "product": true, "tools": true,
"id": true, "schema_path": true, "runtime": true,
// Leaf identity and execution semantics.
"canonical_path": true, "cli_path": true,
"agent_summary": true, "description": true,
"effect": true, "risk": true, "confirmation": true, "idempotency": true,
"interface_mode": true, "availability": true, "interface_reason": true,
"parameters": true, "constraints": true, "positionals": true, "dry_run": true,
"examples": true, "use_when": true, "avoid_when": true,
}
// schemaCompactParamStripKeys are per-parameter keys removed in --compact mode.
var schemaCompactParamStripKeys = map[string]bool{
"interface_description": true,
"interface_type": true,
"property": true,
"field_provenance": true,
// schemaCompactParamKeys is the reviewed parameter allowlist for Agent command
// construction. RPC mapping and provenance fields intentionally remain in the
// full/audit view.
var schemaCompactParamKeys = map[string]bool{
"type": true, "description": true, "required": true,
"cli_required": true, "required_when": true,
"default": true, "interface_default": true, "example": true,
"format": true, "enum": true,
}
// stripSchemaPayloadCompact walks a schema payload map and removes provenance,
// debug and redundant keys so that only agent-essential fields remain.
// It operates recursively on nested maps, slices, and parameter objects.
// stripSchemaPayloadCompact projects a full Schema payload onto the reviewed
// Agent-view allowlist. Structural product/tool children are projected
// recursively; constraint, positional and dry-run values are already typed
// contract data and are retained verbatim.
func stripSchemaPayloadCompact(payload map[string]any) map[string]any {
if payload == nil {
return nil
}
result := make(map[string]any, len(payload))
for k, v := range payload {
if schemaCompactStripKeys[k] {
if !schemaCompactPayloadKeys[k] {
continue
}
if k == "parameters" {
switch k {
case "parameters":
result[k] = stripSchemaParametersCompact(v)
continue
case "product":
if product, ok := v.(map[string]any); ok {
result[k] = stripSchemaPayloadCompact(product)
} else {
result[k] = v
}
case "products", "tools":
result[k] = stripSchemaPayloadCollectionCompact(v)
default:
result[k] = v
}
result[k] = stripSchemaValueCompact(v)
}
return result
}
func stripSchemaPayloadCollectionCompact(value any) any {
switch values := value.(type) {
case []map[string]any:
result := make([]map[string]any, len(values))
for i, item := range values {
result[i] = stripSchemaPayloadCompact(item)
}
return result
case []any:
result := make([]any, len(values))
for i, item := range values {
if payload, ok := item.(map[string]any); ok {
result[i] = stripSchemaPayloadCompact(payload)
} else {
result[i] = item
}
}
return result
default:
return value
}
}
func stripSchemaParametersCompact(value any) any {
parameters, ok := value.(map[string]any)
if !ok {
@@ -1278,7 +1179,7 @@ func stripSchemaValueCompact(v any) any {
func stripSchemaParamCompact(param map[string]any) map[string]any {
result := make(map[string]any, len(param))
for k, v := range param {
if schemaCompactParamStripKeys[k] {
if !schemaCompactParamKeys[k] {
continue
}
result[k] = v
@@ -11,7 +11,6 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
@@ -63,39 +62,6 @@ func TestCrossPlatformCoverageCollectRuntimeSchemaEntriesErrorsAndOrdering(t *te
}
func TestCrossPlatformCoverageRuntimeSchemaMetadataLookupEdges(t *testing.T) {
if _, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{}, agentMetadata{}, embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}); ok {
t.Fatal("empty lookup unexpectedly matched")
}
leaf := &cobra.Command{Use: "reply"}
contractfinal.RegisterRuntimeContractFinal(leaf, contract.ContractFinalPayload{
Identity: &contract.ToolIdentitySpec{
ProductID: "sample", Name: "run", CanonicalPath: "sample.run",
CLIPath: "sample run", PrimaryCLIPath: "sample run",
},
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeMCP,
Availability: contract.InterfaceAvailable,
Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "send_personal_message"},
},
})
t.Cleanup(func() { ClearRuntimeContractFinalForTest(leaf) })
mcp := embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{
"chat.send_personal_message": {
Parameters: map[string]embeddedMCPParamMeta{
"clawType": {Type: "string"},
},
},
}}
got, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{Command: leaf, ProductID: "chat", ToolName: "reply_personal_message"}, agentMetadata{}, mcp)
if !ok || got.Parameters["clawType"].Type != "string" {
t.Fatalf("ContractFinal Interface.Ref MCP remap = %#v ok=%v", got, ok)
}
if got.InterfaceRef == nil || got.InterfaceRef.RPCName != "send_personal_message" {
t.Fatalf("InterfaceRef = %#v", got.InterfaceRef)
}
for _, test := range []struct {
value any
want int
@@ -155,13 +121,13 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
cmd.Flags().String("value", "", "value")
flag := cmd.Flags().Lookup("value")
if specs, err := runtimeCommandParameterSpecs(nil, "sample.run", nil, RuntimeSchemaConstraints{}); err != nil || specs != nil {
if specs, err := runtimeCommandParameterSpecs(nil, "sample.run", RuntimeSchemaConstraints{}); err != nil || specs != nil {
t.Fatalf("nil command specs = %#v, err = %v", specs, err)
}
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
return schemaParameterBindingSnapshot{}, errors.New("load failed")
})
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "load failed") {
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "load failed") {
t.Fatalf("binding load error = %v", err)
}
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
@@ -179,7 +145,7 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
MappingExclusions: map[string]string{"sample.run --value": " "},
}, nil
})
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "mapping exclusion") {
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "mapping exclusion") {
t.Fatalf("mapping exclusion error = %v", err)
}
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
@@ -194,22 +160,22 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
}
return resolveRuntimeSchemaCandidate(field, candidates...)
})
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), target) {
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), target) {
t.Fatalf("%s resolution error = %v", target, err)
}
}
resolveRuntimeSchemaField = realResolver
if specs, err := runtimeCommandParameterSpecs(&cobra.Command{Use: "empty"}, "sample.empty", nil, RuntimeSchemaConstraints{}); err != nil || specs != nil {
if specs, err := runtimeCommandParameterSpecs(&cobra.Command{Use: "empty"}, "sample.empty", RuntimeSchemaConstraints{}); err != nil || specs != nil {
t.Fatalf("empty specs = %#v, err = %v", specs, err)
}
if payload, err := runtimeCommandParameters(nil, "", nil, RuntimeSchemaConstraints{}); err != nil || payload != nil {
if payload, err := runtimeCommandParameters(nil, "", RuntimeSchemaConstraints{}); err != nil || payload != nil {
t.Fatalf("empty payload = %#v, err = %v", payload, err)
}
testseam.Swap(t, &runtimeCommandParameterSpecsForPayload, func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
testseam.Swap(t, &runtimeCommandParameterSpecsForPayload, func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
return []ParameterSpec{{Name: "bad", Example: json.RawMessage("{")}}, nil
})
if _, err := runtimeCommandParameters(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "serialize Schema parameter") {
if _, err := runtimeCommandParameters(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "serialize Schema parameter") {
t.Fatalf("payload serialization error = %v", err)
}
@@ -218,32 +184,21 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
t.Fatalf("required annotation = %v/%v", required, present)
}
// Fixture MCP-shaped maps still participate when explicitly injected.
// Binding snapshot still supplies reviewed property mappings without MCP pin.
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
return schemaParameterBindingSnapshot{
Bindings: map[string]map[string]string{"sample.run": {"value": "clawType"}},
}, nil
})
requiredTrue := true
specs, err := runtimeCommandParameterSpecs(cmd, "sample.run", map[string]embeddedMCPParamMeta{
"clawType": {
Type: "string",
Description: "fixture description",
Required: &requiredTrue,
Default: "fixture-default",
},
}, RuntimeSchemaConstraints{})
specs, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{})
if err != nil {
t.Fatalf("fixture pinned parameter specs error = %v", err)
t.Fatalf("parameter specs error = %v", err)
}
if len(specs) != 1 || specs[0].Property != "clawType" || specs[0].InterfaceDescription != "fixture description" {
t.Fatalf("fixture pinned parameter specs = %#v", specs)
if len(specs) != 1 || specs[0].Property != "clawType" {
t.Fatalf("parameter specs = %#v", specs)
}
if len(specs[0].InterfaceDefault) == 0 {
t.Fatalf("fixture interface_default missing: %#v", specs[0])
}
if prov := specs[0].FieldProvenance["required"]; prov.Source == "" {
t.Fatalf("fixture required provenance missing: %#v", specs[0].FieldProvenance)
if prov := specs[0].FieldProvenance["property"]; prov.Source == "" {
t.Fatalf("property provenance missing: %#v", specs[0].FieldProvenance)
}
}
@@ -273,9 +228,6 @@ func TestCrossPlatformCoverageRuntimeSchemaPureHelperEdges(t *testing.T) {
if !reflect.DeepEqual(groups, [][]string{{"one"}}) {
t.Fatalf("normalized groups = %#v", groups)
}
if meta, ok := lookupPinnedMCPParam(map[string]embeddedMCPParamMeta{"flag": {Type: "string"}}, "property", "flag"); !ok || meta.Type != "string" {
t.Fatalf("flag fallback metadata = %#v/%v", meta, ok)
}
if isGenericPayloadFlag(nil) {
t.Fatal("nil flag cannot be a generic payload")
}
@@ -322,4 +274,75 @@ func TestCrossPlatformCoverageSchemaCompactProjectionEdges(t *testing.T) {
if _, exists := value["property"]; exists {
t.Fatalf("compact parameter value = %#v", value)
}
// Non-parameter nested maps fall through to payload compacting.
nested := stripSchemaValueCompact(map[string]any{"description": "keep", "provenance": "drop"}).(map[string]any)
if nested["description"] != "keep" {
t.Fatalf("nested non-param map = %#v", nested)
}
if _, exists := nested["provenance"]; exists {
t.Fatalf("nested non-param provenance should drop: %#v", nested)
}
// Type-only maps still count as parameter objects.
typedOnly := stripSchemaValueCompact(map[string]any{"type": "string", "property": "remote"}).(map[string]any)
if _, exists := typedOnly["property"]; exists {
t.Fatalf("type-only param value = %#v", typedOnly)
}
mapSlice := stripSchemaValueCompact([]map[string]any{{"description": "leaf", "provenance": "drop"}}).([]map[string]any)
if len(mapSlice) != 1 || mapSlice[0]["description"] != "leaf" {
t.Fatalf("value compact []map = %#v", mapSlice)
}
if _, exists := mapSlice[0]["provenance"]; exists {
t.Fatalf("value compact []map provenance should drop: %#v", mapSlice)
}
anySlice := stripSchemaValueCompact([]any{map[string]any{"description": "leaf", "provenance": "drop"}, "raw"}).([]any)
if len(anySlice) != 2 || anySlice[1] != "raw" {
t.Fatalf("value compact []any = %#v", anySlice)
}
payload := map[string]any{
"description": "calendar",
"provenance": map[string]any{"source": "drop"},
"parameters": parameters,
"product": map[string]any{"description": "calendar", "provenance": "drop"},
"products": []map[string]any{
{"description": "calendar", "provenance": "drop"},
},
"tools": []any{
map[string]any{"description": "leaf", "provenance": "drop"},
"skip-me",
},
"constraints": map[string]any{"require_one_of": []any{}},
}
stripped := stripSchemaPayloadCompact(payload)
if stripped["description"] != "calendar" {
t.Fatalf("compact description = %#v", stripped["description"])
}
if _, exists := stripped["provenance"]; exists {
t.Fatalf("compact should drop provenance: %#v", stripped)
}
if product, ok := stripped["product"].(map[string]any); !ok || product["description"] != "calendar" {
t.Fatalf("compact product = %#v", stripped["product"])
}
if _, exists := stripped["product"].(map[string]any)["provenance"]; exists {
t.Fatalf("nested product provenance should drop: %#v", stripped["product"])
}
if products, ok := stripped["products"].([]map[string]any); !ok || len(products) != 1 || products[0]["description"] != "calendar" {
t.Fatalf("compact products = %#v", stripped["products"])
}
if tools, ok := stripped["tools"].([]any); !ok || len(tools) != 2 {
t.Fatalf("compact tools = %#v", stripped["tools"])
}
if tool, ok := stripped["tools"].([]any)[0].(map[string]any); !ok || tool["description"] != "leaf" {
t.Fatalf("compact tools[0] = %#v", stripped["tools"].([]any)[0])
}
if stripped["tools"].([]any)[1] != "skip-me" {
t.Fatalf("compact tools[1] = %#v", stripped["tools"].([]any)[1])
}
// Non-map product values are retained verbatim.
if got := stripSchemaPayloadCompact(map[string]any{"product": "raw"}); got["product"] != "raw" {
t.Fatalf("non-map product = %#v", got["product"])
}
if got := stripSchemaPayloadCollectionCompact("raw"); got != "raw" {
t.Fatalf("non-collection compact = %#v", got)
}
}
-19
View File
@@ -189,25 +189,6 @@ func cloneFieldCandidates(source []contract.FieldCandidateProvenance) []contract
return out
}
func lookupAgentToolMetadataFrom(source agentMetadata, paths ...string) (agentToolMetadata, bool) {
seen := map[string]bool{}
for _, path := range paths {
for _, candidate := range []string{
strings.TrimSpace(path),
strings.Join(splitSchemaPathTokens(path), " "),
} {
if candidate == "" || seen[candidate] {
continue
}
seen[candidate] = true
if metadata, ok := source.Tools[candidate]; ok {
return metadata, true
}
}
}
return agentToolMetadata{}, false
}
// agentMetadataSummaryFromProducts publishes Catalog-level Agent coverage from
// the assembled Schema surface (ContractFinal / ProductDecl). This keeps
// runtime delivery and CI dumps hash-aligned without requiring build-time
+9 -122
View File
@@ -20,7 +20,6 @@ import (
"testing/fstest"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
"github.com/spf13/cobra"
)
@@ -94,9 +93,8 @@ func TestRuntimeSchemaIncludesAgentMetadata(t *testing.T) {
// longer participates in assembly.
root := buildRuntimeSchemaTestRoot()
declareRuntimeSchemaTestRootDoc(t, root, nil)
mcpFixture := embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}
leaf, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"doc.create_document"}, emptyAgentMetadata(), mcpFixture)
leaf, err := runtimeSchemaPayloadForTest(root, []string{"doc.create_document"})
if err != nil {
t.Fatalf("runtimeSchemaPayloadForTest(leaf): %v", err)
}
@@ -110,7 +108,7 @@ func TestRuntimeSchemaIncludesAgentMetadata(t *testing.T) {
t.Fatalf("leaf examples = %#v", leaf["examples"])
}
catalog, err := runtimeSchemaPayloadForTestWithMetadata(root, nil, emptyAgentMetadata(), mcpFixture)
catalog, err := runtimeSchemaPayloadForTest(root, nil)
if err != nil {
t.Fatalf("runtimeSchemaPayloadForTest(catalog): %v", err)
}
@@ -136,7 +134,7 @@ func TestRuntimeSchemaIncludesAgentMetadata(t *testing.T) {
t.Fatalf("product summary must not include examples: %#v", tools[0])
}
registry, err := schemaRegistryForTestWithMetadata(root, emptyAgentMetadata(), mcpFixture)
registry, err := schemaRegistryForTest(root)
if err != nil {
t.Fatalf("schemaRegistryForTest(): %v", err)
}
@@ -162,7 +160,7 @@ func TestRuntimeSchemaAllPayloadContainsFullLeafParameters(t *testing.T) {
// exercises the production assembly path.
root := buildRuntimeSchemaTestRoot()
declareRuntimeSchemaTestRootDoc(t, root, nil)
registry, err := schemaRegistryForTestWithMetadata(root, emptyAgentMetadata(), embeddedMCPMetadata{})
registry, err := schemaRegistryForTest(root)
if err != nil {
t.Fatal(err)
}
@@ -203,9 +201,8 @@ func schemaTestInt(value any) int {
}
func TestRuntimeSchemaUsesVersionedInterfaceRef(t *testing.T) {
// interface_ref declares on the leaf ContractFinal; the injected MCP
// fixture participates through the gated fixture lookup (remapped via the
// declared Interface.Ref).
// interface_ref declares on the leaf ContractFinal; MCP pin is not a
// parameter candidate source.
root := buildRuntimeSchemaTestRoot()
declareRuntimeSchemaTestRootDoc(t, root, func(payload *contract.ContractFinalPayload) {
payload.Interface = &contract.InterfaceSpec{
@@ -215,17 +212,8 @@ func TestRuntimeSchemaUsesVersionedInterfaceRef(t *testing.T) {
Ref: &contract.InterfaceRefSpec{ProductID: "documents", RPCName: "create_doc_v2"},
}
})
mcpFixture := embeddedMCPMetadata{
Tools: map[string]embeddedMCPToolMetadata{
"documents.create_doc_v2": {
Parameters: map[string]embeddedMCPParamMeta{
"title": {Description: "MCP document title"},
},
},
},
}
payload, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"doc.create_document"}, emptyAgentMetadata(), mcpFixture)
payload, err := runtimeSchemaPayloadForTest(root, []string{"doc.create_document"})
if err != nil {
t.Fatal(err)
}
@@ -233,112 +221,11 @@ func TestRuntimeSchemaUsesVersionedInterfaceRef(t *testing.T) {
if ref["product_id"] != "documents" || ref["rpc_name"] != "create_doc_v2" {
t.Fatalf("interface_ref = %#v", payload["interface_ref"])
}
parameters, _ := payload["parameters"].(map[string]any)
title, _ := parameters["title"].(map[string]any)
if title["interface_description"] != "MCP document title" {
t.Fatalf("title metadata = %#v", title)
if payload["interface_mode"] != contract.InterfaceModeMCP {
t.Fatalf("interface_mode = %#v", payload["interface_mode"])
}
}
func TestMCPRequiredParticipatesInSourcePrecedence(t *testing.T) {
required := true
mcpFixture := embeddedMCPMetadata{
Tools: map[string]embeddedMCPToolMetadata{
"sample.list_items": {
Parameters: map[string]embeddedMCPParamMeta{
"limit": {Required: &required},
},
},
},
}
root := &cobra.Command{Use: "dws"}
list := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
list.Flags().Int("limit", 0, "optional page size")
AttachRuntimeSchema(list, "sample", "list_items", "test")
sample := &cobra.Command{Use: "sample"}
sample.AddCommand(list)
root.AddCommand(sample)
declareSampleListItemsLeaf(t, list)
payload, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"sample.list_items"}, emptyAgentMetadata(), mcpFixture)
if err != nil {
t.Fatal(err)
}
parameters, _ := payload["parameters"].(map[string]any)
limit, _ := parameters["limit"].(map[string]any)
if limit["required"] != true {
t.Fatalf("MCP required candidate did not win over the default: %#v", limit)
}
}
func TestMCPDefaultDoesNotOverrideCLIDefault(t *testing.T) {
mcpFixture := embeddedMCPMetadata{
Tools: map[string]embeddedMCPToolMetadata{
"sample.list_items": {
Parameters: map[string]embeddedMCPParamMeta{
"limit": {Default: "50"},
},
},
},
}
root := &cobra.Command{Use: "dws"}
list := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
list.Flags().Int("limit", 10, "optional page size")
AttachRuntimeSchema(list, "sample", "list_items", "test")
sample := &cobra.Command{Use: "sample"}
sample.AddCommand(list)
root.AddCommand(sample)
declareSampleListItemsLeaf(t, list)
payload, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"sample.list_items"}, emptyAgentMetadata(), mcpFixture)
if err != nil {
t.Fatal(err)
}
parameters, _ := payload["parameters"].(map[string]any)
limit, _ := parameters["limit"].(map[string]any)
if limit["default"] != "10" || limit["interface_default"] != "50" {
t.Fatalf("CLI and interface defaults were not separated: %#v", limit)
}
}
// declareSampleListItemsLeaf registers the ContractFinal / ProductDecl
// declarations for the synthetic sample.list_items leaf so MCP fixture tests
// assemble through the production path.
func declareSampleListItemsLeaf(t *testing.T, list *cobra.Command) {
t.Helper()
contractfinal.RegisterRuntimeContractFinal(list, contract.ContractFinalPayload{
Identity: &contract.ToolIdentitySpec{
ProductID: "sample", Name: "list_items", CanonicalPath: "sample.list_items",
CLIPath: "sample list", PrimaryCLIPath: "sample list",
},
Title: "List items",
Description: "List sample items",
Safety: &contract.SafetySpec{
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
},
Interface: &contract.InterfaceSpec{
Mode: "local", Availability: "available", Reason: "test local leaf",
},
Selection: &contract.SelectionSpec{
AgentSummary: "List sample items",
UseWhen: []string{"list sample items"},
AvoidWhen: []string{"not listing"},
},
})
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(list) })
contract.RegisterProductDecl(contract.ProductDecl{
ID: "sample",
Selection: contract.ProductSelectionDecl{
AgentSummary: "Sample product",
UseWhen: []string{"sample routing"},
AvoidWhen: []string{"not sample"},
},
})
t.Cleanup(func() { contract.ClearProductDeclForTest("sample") })
}
func findSchemaProduct(products []map[string]any, id string) map[string]any {
for _, product := range products {
if product["id"] == id {
+7 -3
View File
@@ -508,6 +508,10 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
if err != nil {
t.Fatal(err)
}
leaf["future_audit_field"] = "must not leak into Agent view"
for _, raw := range schemaMap(leaf["parameters"]) {
raw["future_mapping_field"] = "must not leak into Agent view"
}
stripped := stripSchemaPayloadCompact(leaf)
// Must keep agent-essential fields.
@@ -518,7 +522,7 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
}
// Must strip provenance / redundant fields.
for _, key := range []string{"agent_metadata_source", "agent_source_refs", "agent_summary_source", "effect_source", "metadata_source", "primary_cli_path", "parameter_count", "has_parameters", "interface_ref", "source", "title", "display"} {
for _, key := range []string{"agent_metadata_source", "agent_source_refs", "agent_summary_source", "effect_source", "metadata_source", "primary_cli_path", "parameter_count", "has_parameters", "interface_ref", "source", "title", "display", "future_audit_field"} {
if _, ok := stripped[key]; ok {
t.Fatalf("compact leaf still contains stripped key %q", key)
}
@@ -528,7 +532,7 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
if params, ok := stripped["parameters"].(map[string]any); ok {
for name, p := range params {
if pm, ok := p.(map[string]any); ok {
for _, stripped := range []string{"interface_description", "interface_type", "property"} {
for _, stripped := range []string{"interface_description", "interface_type", "property", "future_mapping_field"} {
if _, present := pm[stripped]; present {
t.Fatalf("compact param %q still contains %q", name, stripped)
}
@@ -1304,7 +1308,7 @@ func TestDeliveryCatalogContactParamDeclsMatchMergeBaseContract(t *testing.T) {
}
if want.interfaceType != "" {
prov := schemaMap(param["field_provenance"])["interface_type"]
if src, _ := prov["source"].(string); src != "native_annotation" && src != "mcp_metadata" {
if src, _ := prov["source"].(string); src != "native_annotation" {
t.Fatalf("%s --%s interface_type source = %#v", tc.path, flagName, prov)
}
}
+1 -4
View File
@@ -20,7 +20,7 @@ package cli
var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
{
ID: "cli-management",
Reason: "Local CLI lifecycle, authentication, configuration, recovery, and plugin-management commands are user-operated controls rather than stable Agent tools.",
Reason: "Local CLI lifecycle, authentication, configuration, and plugin-management commands are user-operated controls rather than stable Agent tools.",
Reviewed: true,
Commands: []string{
"api",
@@ -53,9 +53,6 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
"profile list",
"profile switch",
"profile use",
"recovery execute",
"recovery finalize",
"recovery plan",
"schema",
"skill get",
"skill install",
+2 -2
View File
@@ -137,8 +137,8 @@ func schemaToolSpecFromPayload(payload map[string]any) (ToolSpec, error) {
// runtimeCommandParameters is the compatibility wire adapter used only by
// tests; resolution happens in runtimeCommandParameterSpecs.
func runtimeCommandParameters(cmd *cobra.Command, canonicalPath string, pinnedParams map[string]embeddedMCPParamMeta, constraints RuntimeSchemaConstraints) (map[string]any, error) {
specs, err := runtimeCommandParameterSpecsForPayload(cmd, canonicalPath, pinnedParams, constraints)
func runtimeCommandParameters(cmd *cobra.Command, canonicalPath string, constraints RuntimeSchemaConstraints) (map[string]any, error) {
specs, err := runtimeCommandParameterSpecsForPayload(cmd, canonicalPath, constraints)
if err != nil {
return nil, err
}
@@ -969,16 +969,16 @@ func TestOverallCoverageGapDeliveryCompletenessAndDryRun(t *testing.T) {
func TestOverallCoverageGapRuntimeParamsAndAgentMetadata(t *testing.T) {
prevSpecs := runtimeCommandParameterSpecsForPayload
t.Cleanup(func() { runtimeCommandParameterSpecsForPayload = prevSpecs })
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
return nil, fmt.Errorf("specs boom")
}
if _, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil {
if _, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", RuntimeSchemaConstraints{}); err == nil {
t.Fatal("parameter specs error must surface")
}
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
return []ParameterSpec{{Name: "ok", Type: "string"}}, nil
}
payload, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", nil, RuntimeSchemaConstraints{})
payload, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", RuntimeSchemaConstraints{})
if err != nil || payload["ok"] == nil {
t.Fatalf("parameter payload = %#v err=%v", payload, err)
}
@@ -1070,24 +1070,6 @@ func TestOverallCoverageGapRuntimeParamsAndAgentMetadata(t *testing.T) {
}
func TestCrossPlatformCoverageOverallRegressionRecovery(t *testing.T) {
if _, ok := lookupPinnedMCPParam(nil, "property", "flag"); ok {
t.Fatal("nil pinned params must miss")
}
if _, ok := lookupPinnedMCPParam(map[string]embeddedMCPParamMeta{}, "property", "flag"); ok {
t.Fatal("empty pinned params must miss")
}
if _, ok := lookupPinnedMCPParam(map[string]embeddedMCPParamMeta{"other": {Type: "string"}}, "property", "flag"); ok {
t.Fatal("unmatched pinned params must miss")
}
if _, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{}, agentMetadata{}, embeddedMCPMetadata{}); ok {
t.Fatal("empty MCP metadata must not match")
}
if _, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{}, agentMetadata{}, embeddedMCPMetadata{
Tools: map[string]embeddedMCPToolMetadata{"other.key": {}},
}); ok {
t.Fatal("missing MCP metadata keys must not match")
}
left := runtimeSchemaStringCandidateAtPriority("same", true, "z-source", 5, "p")
right := runtimeSchemaStringCandidateAtPriority("same", true, "a-source", 5, "p")
winner, err := resolveRuntimeSchemaCandidate("source-order", left, right)
@@ -410,7 +410,7 @@ func TestRuntimeCommandParameterSpecsPreserveReviewedEmptyPropertyProvenance(t *
cmd := &cobra.Command{Use: "query"}
cmd.Flags().Bool("all", false, "fetch every page")
parameters, err := runtimeCommandParameterSpecs(cmd, "aitable.query_records", nil, RuntimeSchemaConstraints{})
parameters, err := runtimeCommandParameterSpecs(cmd, "aitable.query_records", RuntimeSchemaConstraints{})
if err != nil {
t.Fatal(err)
}
@@ -448,6 +448,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"drive.download_file --output": "local output path",
"drive.download_file --parallel": "local multipart download control; never sent to download_file",
"drive.download_file --part-size": "local multipart download control; never sent to download_file",
"drive.download_file --version": "Polymorphic dispatch: --version switches the MCP tool call from download_file to download_file_version; not a download_file interface property",
"drive.download_file_version --no-resume": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI-local multipart download control and does not publish a direct interface property.",
"drive.download_file_version --node": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.download_file_version --output": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --output is a CLI wrapper input and does not publish a direct interface property.",
@@ -655,7 +656,6 @@ var reviewedSchemaParameterBindingRemovals = map[string]schemaParameterBindingRe
"contact.get_dept_info_by_dept_id --id": {Reason: "The public flag was renamed from --id to the unambiguous --dept spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
"contact.get_dept_members_by_deptId --ids": {Reason: "The public flag was renamed from --ids to the unambiguous --depts spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
"contact.get_sub_depts_by_dept_id --id": {Reason: "The public flag was renamed from --id to the unambiguous --dept spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
"drive.download_file --version": {Reason: "Polymorphic dispatch: --version switches the MCP tool call from download_file to download_file_version; the version property belongs to download_file_version metadata, not download_file.", Reviewed: true},
"minutes.query_user_tag_list --limit": {Reason: "The current helper and pinned interface have no pagination input.", Reviewed: true},
"oa.list_pending_approvals --size": {Reason: "The public pagination flag was normalized from --size to --limit; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
"oa.list_user_visible_process --size": {Reason: "The public pagination flag was normalized from --size to --limit; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
@@ -70,18 +70,6 @@ func schemaRegistryForTest(root *cobra.Command) (SchemaRegistry, error) {
return AssembleSchemaRegistryFromBound(bound)
}
func schemaRegistryForTestWithMetadata(root *cobra.Command, agent agentMetadata, mcp embeddedMCPMetadata) (SchemaRegistry, error) {
bound, err := boundTestCommandRegistry(root)
if err != nil {
return SchemaRegistry{}, err
}
// Production-shaped assembly: leaves must carry ContractFinal and products
// a ProductDecl (see declareRuntimeSchemaTestRootDoc). Injected MCP/agent
// fixtures participate only through the gated fixture lookup in
// runtimeToolSpecFromContractFinal; production passes an empty pin.
return assembleSchemaRegistryFromBound(bound, runtimeSchemaMetadataSources{Agent: agent, MCP: mcp})
}
// declareRuntimeSchemaTestRootDoc registers the ContractFinal / ProductDecl
// declarations for the synthetic doc.create_document tree built by
// buildRuntimeSchemaTestRoot, so production-shaped assembly can resolve it.
@@ -156,18 +144,6 @@ func runtimeSchemaPayloadForTest(root *cobra.Command, args []string) (map[string
return schemaPayloadFromLoadedCatalog(loaded, args)
}
func runtimeSchemaPayloadForTestWithMetadata(root *cobra.Command, args []string, agent agentMetadata, mcp embeddedMCPMetadata) (map[string]any, error) {
registry, err := schemaRegistryForTestWithMetadata(root, agent, mcp)
if err != nil {
return nil, err
}
loaded, err := loadedSchemaCatalogForTestRegistry(registry)
if err != nil {
return nil, err
}
return schemaPayloadFromLoadedCatalog(loaded, args)
}
func runtimeSchemaAllPayloadForTest(root *cobra.Command) (map[string]any, error) {
registry, err := schemaRegistryForTest(root)
if err != nil {
+11 -16
View File
@@ -15,8 +15,10 @@ import (
)
type runtimeSchemaMetadataSources struct {
// Agent remains only for historical test seams that still construct this
// struct; production assembly does not overlay Agent or MCP pin onto
// parameters or tool text.
Agent agentMetadata
MCP embeddedMCPMetadata
}
var (
@@ -62,10 +64,9 @@ func (resolved ResolvedSchemaBuild) CommandCount() int {
}
func pinnedRuntimeSchemaMetadataSources() runtimeSchemaMetadataSources {
return runtimeSchemaMetadataSources{
Agent: runtimeAgentMetadata(),
MCP: emptyPinnedMCPMetadata(),
}
// Production pin and Agent inject are both retired; assembly is Contract /
// ParamDecl / Cobra only.
return runtimeSchemaMetadataSources{}
}
// ResolveSchemaBuild is the only assembly path from executable Cobra commands
@@ -123,7 +124,7 @@ func AssembleSchemaRegistryFromBound(bound BoundCommandRegistry) (SchemaRegistry
// assembleSchemaRegistryFromBound resolves every entry through the
// ContractFinal / ProductDecl production path. Missing declarations fail
// closed; retired skill/MCP/agent-inject overlays are never reopened.
// closed; retired skill/MCP-pin/agent-inject overlays are never reopened.
func assembleSchemaRegistryFromBound(bound BoundCommandRegistry, metadata runtimeSchemaMetadataSources) (SchemaRegistry, error) {
entries, err := assembleCollectEntries(bound)
if err != nil {
@@ -197,18 +198,12 @@ func assembleProductSelection(entry runtimeSchemaEntry) (contract.SelectionSpec,
// runtimeToolSpecFromContractFinal pass-throughs Contract-authored Schema fields.
// Declared values are the final data source; hints/registry text does not merge.
// Production MCP pin is empty, so assembly skips MCP-metadata lookups entirely;
// interface_type / interface_* facts come from ParamDecl / native annotations.
// Tests may still inject a non-empty MCP fixture map, which participates through
// pinnedMCPMetadataForEntryFrom.
// MCP pin is retired: interface_type / interface_* facts come from ParamDecl /
// native annotations only.
func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.ContractFinalPayload, metadata runtimeSchemaMetadataSources) (ToolSpec, error) {
_ = metadata // reserved for historical assemble seams; no overlay sources remain
canonicalPath := entry.ProductID + "." + entry.ToolName
constraints := runtimeCommandConstraints(entry.Command)
var pinnedParams map[string]embeddedMCPParamMeta
if len(metadata.MCP.Tools) > 0 {
pinnedMeta, _ := pinnedMCPMetadataForEntryFrom(entry, metadata.Agent, metadata.MCP)
pinnedParams = pinnedMeta.Parameters
}
// Apply parameter declarations from the contract.ContractFinalPayload before the
// resolver reads them. The decls were put there by AttachContract at
// DeclareLeafMetadata time; now that all flags exist on the fully-built
@@ -216,7 +211,7 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
if err := ApplyParamDecls(entry.Command, final.Parameters); err != nil {
return ToolSpec{}, fmt.Errorf("apply Contract Schema ParamDecls for %s: %w", canonicalPath, err)
}
parameters, err := resolveRuntimeParameters(entry.Command, canonicalPath, pinnedParams, constraints)
parameters, err := resolveRuntimeParameters(entry.Command, canonicalPath, constraints)
if err != nil {
return ToolSpec{}, fmt.Errorf("resolve Contract Schema parameters for %s: %w", canonicalPath, err)
}
@@ -278,23 +278,10 @@ func TestCrossPlatformCoverageRenderSafetyAnnotationSuccess(t *testing.T) {
}
func TestCrossPlatformCoverageMCPMetadataInterfaceRefEdges(t *testing.T) {
if _, ok := mcpMetadataForInterfaceRef(embeddedMCPMetadata{}, " ", " "); ok {
t.Fatal("blank interface ref must miss")
}
if _, ok := mcpMetadataForInterfaceRef(embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}, "chat", "missing"); ok {
t.Fatal("missing MCP tool must miss")
}
agent := agentMetadata{Tools: map[string]agentToolMetadata{
"chat reply": {InterfaceRef: &embeddedMCPInterfaceRef{ProductID: "chat", RPCName: "send_personal_message"}},
}}
mcp := embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{
"chat.send_personal_message": {Parameters: map[string]embeddedMCPParamMeta{"clawType": {Type: "string"}}},
}}
got, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{
PrimaryCLIPath: "chat reply", ProductID: "chat", ToolName: "reply_personal_message",
}, agent, mcp)
if !ok || got.Parameters["clawType"].Type != "string" {
t.Fatalf("agent InterfaceRef remap = %#v ok=%v", got, ok)
// MCP pin lookup helpers are retired; keep this named coverage slot as a
// no-op marker so CrossPlatformCoverage* selection stays stable.
if got := emptyPinnedMCPMetadata(); got.Tools == nil || len(got.Tools) != 0 {
t.Fatalf("empty pinned metadata = %#v", got)
}
}
@@ -0,0 +1,122 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Command cmd_command_path_fallbacks validates the reviewed recovery-only
// command path table against the live distribution Cobra tree and writes the
// deterministic runtime lookup source. It does not add paths to Cobra, Help,
// Skill, Schema, or the stable CommandRegistry identity surface.
package main
import (
"bytes"
"flag"
"fmt"
"go/format"
"os"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/generator/outputguard"
)
const defaultOutput = "internal/cli/command_path_fallbacks_generated.go"
var (
newCommandFallbackRoot = app.NewSchemaSourceRootCommand
reduceCommandFallbackEntries = cli.ReduceCommandPathFallbacks
formatCommandFallbackSource = format.Source
writeCommandFallbackFile = os.WriteFile
exitCommandFallbackProcess = os.Exit
)
func main() {
var rootPath string
var outputPath string
flag.StringVar(&rootPath, "root", ".", "Repository root used to protect generator inputs")
flag.StringVar(&outputPath, "output", defaultOutput, "Output generated command-path fallback table")
flag.Parse()
if err := generateCommandPathFallbacks(rootPath, outputPath); err != nil {
fail(err)
}
}
func generateCommandPathFallbacks(rootPath, outputPath string) error {
if err := validateOutputIsolation(rootPath, outputPath); err != nil {
return err
}
entries, err := reduceCommandFallbackEntries(newCommandFallbackRoot())
if err != nil {
return err
}
source, err := renderCommandPathFallbacks(entries)
if err != nil {
return err
}
if err := writeCommandFallbackFile(outputPath, source, 0o644); err != nil {
return fmt.Errorf("write generated command path fallbacks: %w", err)
}
_, _ = fmt.Fprintf(os.Stderr, "generated command path fallbacks: output=%s entries=%d\n", outputPath, len(entries))
return nil
}
func validateOutputIsolation(rootPath, outputPath string) error {
inputs := []outputguard.Input{
{Name: "reviewed command path fallbacks", Path: "internal/cli/command_path_fallbacks.json"},
{Name: "reviewed command path fallback schema", Path: "internal/cli/command_path_fallbacks.schema.json"},
}
target := outputguard.Target{Name: "--output", Path: outputPath}
if err := outputguard.Validate(rootPath, inputs, []outputguard.Target{target}); err != nil {
return err
}
return outputguard.ValidateRepoTargetAllowlist(rootPath, target, defaultOutput)
}
func renderCommandPathFallbacks(entries []cli.CommandPathFallback) ([]byte, error) {
var b bytes.Buffer
b.WriteString("// Copyright 2026 Alibaba Group\n")
b.WriteString("// Licensed under the Apache License, Version 2.0\n\n")
b.WriteString("// Code generated by cmd_command_path_fallbacks; DO NOT EDIT.\n")
b.WriteString("// Source: internal/cli/command_path_fallbacks.json validated against the live Cobra tree.\n")
b.WriteString("// Regenerate with `make generate-schema` (or `go generate ./internal/cli`).\n\n")
b.WriteString("package cli\n\n")
b.WriteString("var generatedCommandPathFallbacks = []CommandPathFallback{\n")
for _, entry := range entries {
b.WriteString("\t{\n")
fmt.Fprintf(&b, "\t\tFrom: %q,\n", entry.From)
fmt.Fprintf(&b, "\t\tMode: %q,\n", entry.Mode)
if entry.To != "" {
fmt.Fprintf(&b, "\t\tTo: %q,\n", entry.To)
}
if len(entry.Candidates) > 0 {
fmt.Fprintf(&b, "\t\tCandidates: %s,\n", renderStringSlice(entry.Candidates))
}
b.WriteString("\t\tReviewed: true,\n")
fmt.Fprintf(&b, "\t\tReviewReason: %q,\n", entry.ReviewReason)
b.WriteString("\t},\n")
}
b.WriteString("}\n\n")
b.WriteString("func loadGeneratedCommandPathFallbacks() []CommandPathFallback {\n")
b.WriteString("\treturn generatedCommandPathFallbacks\n")
b.WriteString("}\n")
formatted, err := formatCommandFallbackSource(b.Bytes())
if err != nil {
return nil, fmt.Errorf("format generated command path fallbacks: %w", err)
}
return formatted, nil
}
func renderStringSlice(values []string) string {
quoted := make([]string, len(values))
for index, value := range values {
quoted[index] = fmt.Sprintf("%q", value)
}
return "[]string{" + strings.Join(quoted, ", ") + "}"
}
func fail(err error) {
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
exitCommandFallbackProcess(1)
}
@@ -0,0 +1,184 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package main
import (
"context"
"errors"
"flag"
"go/format"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageCommandFallbackGeneratorUsesDistributionOwnedCommandTree(t *testing.T) {
got := reflect.ValueOf(newCommandFallbackRoot).Pointer()
want := reflect.ValueOf(app.NewSchemaSourceRootCommand).Pointer()
if got != want {
t.Fatal("command fallback generator must use the distribution-owned Schema source command tree")
}
}
func preserveCommandFallbackGeneratorGlobals(t *testing.T) {
t.Helper()
oldArgs, oldFlags := os.Args, flag.CommandLine
oldRoot := newCommandFallbackRoot
oldReduce := reduceCommandFallbackEntries
oldFormat := formatCommandFallbackSource
oldWrite := writeCommandFallbackFile
oldExit := exitCommandFallbackProcess
t.Cleanup(func() {
os.Args, flag.CommandLine = oldArgs, oldFlags
newCommandFallbackRoot = oldRoot
reduceCommandFallbackEntries = oldReduce
formatCommandFallbackSource = oldFormat
writeCommandFallbackFile = oldWrite
exitCommandFallbackProcess = oldExit
})
}
func commandFallbackRepositoryRoot(t *testing.T) string {
t.Helper()
root, err := filepath.Abs(filepath.Join("..", "..", ".."))
if err != nil {
t.Fatal(err)
}
return root
}
func TestCrossPlatformCoverageCommandFallbackMainWritesGeneratedFile(t *testing.T) {
preserveCommandFallbackGeneratorGlobals(t)
output := filepath.Join(t.TempDir(), "command_path_fallbacks_generated.go")
flag.CommandLine = flag.NewFlagSet("command-fallback-success", flag.ContinueOnError)
os.Args = []string{"cmd_command_path_fallbacks", "-root", commandFallbackRepositoryRoot(t), "-output", output}
newCommandFallbackRoot = func(...context.Context) *cobra.Command { return &cobra.Command{Use: "dws"} }
reduceCommandFallbackEntries = func(*cobra.Command) ([]cli.CommandPathFallback, error) {
return []cli.CommandPathFallback{{
From: "demo +bad",
Mode: cli.CommandPathFallbackRewrite,
To: "demo +good",
Reviewed: true,
ReviewReason: "fixture",
}}, nil
}
main()
data, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
text := string(data)
for _, want := range []string{
"func loadGeneratedCommandPathFallbacks() []CommandPathFallback",
`From: "demo +bad"`,
`To: "demo +good"`,
} {
if !strings.Contains(text, want) {
t.Fatalf("generated source missing %q:\n%s", want, text)
}
}
}
func TestCrossPlatformCoverageGenerateCommandPathFallbackFailurePaths(t *testing.T) {
preserveCommandFallbackGeneratorGlobals(t)
root := commandFallbackRepositoryRoot(t)
output := filepath.Join(t.TempDir(), "command_path_fallbacks_generated.go")
newCommandFallbackRoot = func(...context.Context) *cobra.Command { return &cobra.Command{Use: "dws"} }
if err := generateCommandPathFallbacks(root, filepath.Join(root, "internal", "cli", "command_path_fallbacks.json")); err == nil {
t.Fatal("generator accepted output overlapping reviewed input")
}
reduceCommandFallbackEntries = func(*cobra.Command) ([]cli.CommandPathFallback, error) {
return nil, errors.New("reduce")
}
if err := generateCommandPathFallbacks(root, output); err == nil || !strings.Contains(err.Error(), "reduce") {
t.Fatalf("reduction error = %v", err)
}
reduceCommandFallbackEntries = func(*cobra.Command) ([]cli.CommandPathFallback, error) { return nil, nil }
formatCommandFallbackSource = func([]byte) ([]byte, error) { return nil, errors.New("format") }
if err := generateCommandPathFallbacks(root, output); err == nil || !strings.Contains(err.Error(), "format generated") {
t.Fatalf("format error = %v", err)
}
formatCommandFallbackSource = format.Source
writeCommandFallbackFile = func(string, []byte, os.FileMode) error { return errors.New("write") }
if err := generateCommandPathFallbacks(root, output); err == nil || !strings.Contains(err.Error(), "write generated") {
t.Fatalf("write error = %v", err)
}
}
func TestCrossPlatformCoverageValidateCommandFallbackOutputIsolation(t *testing.T) {
root := commandFallbackRepositoryRoot(t)
if err := validateOutputIsolation(root, filepath.Join(t.TempDir(), "fallbacks.go")); err != nil {
t.Fatalf("temporary output rejected: %v", err)
}
if err := validateOutputIsolation(root, filepath.Join(root, "internal", "cli", "not_a_delivery_target.go")); err == nil ||
!strings.Contains(err.Error(), "not a canonical generated delivery target") {
t.Fatalf("non-canonical repository output error = %v", err)
}
}
func TestCrossPlatformCoverageRenderCommandPathFallbacksShape(t *testing.T) {
preserveCommandFallbackGeneratorGlobals(t)
entries := []cli.CommandPathFallback{
{
From: "demo +bad",
Mode: cli.CommandPathFallbackRewrite,
To: "demo +good",
Reviewed: true,
ReviewReason: "rewrite fixture",
},
{
From: "demo +choose",
Mode: cli.CommandPathFallbackAmbiguous,
Candidates: []string{"demo +one", "demo +two"},
Reviewed: true,
ReviewReason: "ambiguous fixture",
},
}
data, err := renderCommandPathFallbacks(entries)
if err != nil {
t.Fatal(err)
}
text := string(data)
for _, want := range []string{
`Mode: "rewrite"`,
`Candidates: []string{"demo +one", "demo +two"}`,
`ReviewReason: "ambiguous fixture"`,
} {
if !strings.Contains(text, want) {
t.Fatalf("render missing %q:\n%s", want, text)
}
}
if got := renderStringSlice(nil); got != "[]string{}" {
t.Fatalf("renderStringSlice(nil) = %q", got)
}
formatCommandFallbackSource = func([]byte) ([]byte, error) { return nil, errors.New("broken formatter") }
if _, err := renderCommandPathFallbacks(entries); err == nil || !strings.Contains(err.Error(), "broken formatter") {
t.Fatalf("formatter error = %v", err)
}
}
func TestCrossPlatformCoverageCommandFallbackMainReportsFailure(t *testing.T) {
preserveCommandFallbackGeneratorGlobals(t)
flag.CommandLine = flag.NewFlagSet("command-fallback-failure", flag.ContinueOnError)
os.Args = []string{
"cmd_command_path_fallbacks",
"-root", commandFallbackRepositoryRoot(t),
"-output", filepath.Join(commandFallbackRepositoryRoot(t), "internal", "cli", "command_path_fallbacks.json"),
}
exitCommandFallbackProcess = func(code int) { panic(code) }
defer func() {
if recovered := recover(); recovered != 1 {
t.Fatalf("main exit = %#v, want 1", recovered)
}
}()
main()
}
+179 -94
View File
@@ -5,12 +5,15 @@ import (
"encoding/json"
"fmt"
"io"
"math"
"os"
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/paging"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
@@ -123,113 +126,195 @@ func resolveWorkflowDSL(cmd *cobra.Command) (map[string]any, error) {
}
// recordQueryFetchAll implements --all auto-pagination for record query.
// - pageLimit controls max pages (default 50, 0 = unlimited)
// - Mid-loop errors break gracefully, outputting already-fetched data
// - Merged result preserves last page's cursor so caller can resume
// It prints only a complete result. A page limit, empty/invalid response,
// transport failure, or cursor cycle returns a non-zero structured error whose
// details retain the incomplete records and retry cursor.
func recordQueryFetchAll(toolArgs map[string]any, pageLimit int) error {
const pageDelayMs = 200
const serverID = "aitable"
ctx := context.Background()
var allRecords []any
page := 0
lastCursor := ""
for {
page++
if page > 1 {
helperSleep(time.Duration(pageDelayMs) * time.Millisecond)
}
fmt.Fprintf(os.Stderr, "[page %d] fetching...\n", page)
result, err := deps.Caller.CallTool(ctx, serverID, "query_records", toolArgs)
if err != nil {
if page == 1 {
return WrapError(err)
}
fmt.Fprintf(os.Stderr, "[page %d] error, stopping pagination: %v\n", page, err)
break
}
// Extract text content from tool result
var text string
for _, c := range result.Content {
if c.Type == "text" && c.Text != "" {
text = c.Text
break
}
}
if text == "" {
break
}
var resp map[string]any
if err := json.Unmarshal([]byte(text), &resp); err != nil {
if page == 1 {
deps.Out.PrintRaw(text)
return nil
}
break
}
// Extract records from response
var pageRecords []any
if data, ok := resp["data"].(map[string]any); ok {
if recs, ok := data["records"].([]any); ok {
pageRecords = recs
}
} else if recs, ok := resp["records"].([]any); ok {
pageRecords = recs
}
allRecords = append(allRecords, pageRecords...)
// Extract cursor for next page (MCP returns "nextCursor" or "cursor")
cursor := ""
if data, ok := resp["data"].(map[string]any); ok {
if c, ok := data["nextCursor"].(string); ok && c != "" {
cursor = c
} else if c, ok := data["cursor"].(string); ok && c != "" {
cursor = c
}
} else if c, ok := resp["nextCursor"].(string); ok && c != "" {
cursor = c
} else if c, ok := resp["cursor"].(string); ok && c != "" {
cursor = c
}
requestArgs := make(map[string]any, len(toolArgs))
for key, value := range toolArgs {
requestArgs[key] = value
}
initialCursor, _ := requestArgs["cursor"].(string)
effectivePageLimit := pageLimit
if pageLimit == 0 {
effectivePageLimit = paging.UnlimitedPageLimit
}
result := paging.FetchAll(ctx, func(ctx context.Context, cursor string) (paging.Page, error) {
if cursor == "" {
lastCursor = ""
break
delete(requestArgs, "cursor")
} else {
requestArgs["cursor"] = cursor
}
// Check page limit (0 = unlimited)
if pageLimit > 0 && page >= pageLimit {
lastCursor = cursor
fmt.Fprintf(os.Stderr, "[pagination] reached page limit (%d), stopping. Use --page-limit 0 to fetch all.\n", pageLimit)
break
toolResult, err := deps.Caller.CallTool(ctx, serverID, "query_records", requestArgs)
if err != nil {
return paging.Page{}, err
}
if toolResult == nil {
return paging.Page{}, fmt.Errorf("query_records returned a nil result")
}
for _, content := range toolResult.Content {
if content.Type == "text" && strings.TrimSpace(content.Text) != "" {
return parseRecordQueryPage(content.Text)
}
}
return paging.Page{}, fmt.Errorf("query_records returned no non-empty text content")
}, paging.Options{
PageLimit: effectivePageLimit,
InterPageDelay: paging.DefaultInterPageDelay,
InitialCursor: initialCursor,
})
lastCursor = ""
toolArgs["cursor"] = cursor
if !result.Complete {
return recordQueryIncompleteError(result, pageLimit)
}
fmt.Fprintf(os.Stderr, "[pagination] done: %d pages, %d total records\n", page, len(allRecords))
// Build merged output — preserve cursor & has_more for resume capability
fmt.Fprintf(os.Stderr, "[pagination] complete: %d pages, %d fetched records\n", result.Pages, len(result.Records))
mergedData := map[string]any{
"records": allRecords,
"totalCount": len(allRecords),
"records": result.Records,
"fetchedCount": len(result.Records),
"hasMore": false,
"complete": true,
"pages": result.Pages,
}
if lastCursor != "" {
mergedData["cursor"] = lastCursor
mergedData["hasMore"] = true
} else {
mergedData["hasMore"] = false
if result.TotalCount != nil {
mergedData["totalCount"] = *result.TotalCount
}
return deps.Out.PrintJSON(map[string]any{"data": mergedData})
}
func parseRecordQueryPage(text string) (paging.Page, error) {
decoder := json.NewDecoder(strings.NewReader(text))
decoder.UseNumber()
var response map[string]any
if err := decoder.Decode(&response); err != nil {
return paging.Page{}, fmt.Errorf("query_records returned invalid JSON: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
return paging.Page{}, fmt.Errorf("query_records returned more than one JSON value")
}
return paging.Page{}, fmt.Errorf("query_records returned trailing invalid JSON: %w", err)
}
if response == nil {
return paging.Page{}, fmt.Errorf("query_records returned null instead of an object")
}
return deps.Out.PrintJSON(map[string]any{"data": mergedData})
payload := response
if rawData, exists := response["data"]; exists {
data, ok := rawData.(map[string]any)
if !ok {
return paging.Page{}, fmt.Errorf("query_records data must be an object, got %T", rawData)
}
payload = data
}
rawRecords, exists := payload["records"]
if !exists {
return paging.Page{}, fmt.Errorf("query_records response is missing records")
}
records, ok := rawRecords.([]any)
if !ok {
return paging.Page{}, fmt.Errorf("query_records records must be an array, got %T", rawRecords)
}
for index, record := range records {
if _, ok := record.(map[string]any); !ok {
return paging.Page{}, fmt.Errorf("query_records records[%d] must be an object, got %T", index, record)
}
}
nextCursor := firstNonEmptyString(payload, "nextCursor", "cursor")
if hasMore, ok := payload["hasMore"].(bool); ok && hasMore && nextCursor == "" {
return paging.Page{}, fmt.Errorf("query_records reported hasMore=true without a next cursor")
}
totalCount, err := parseOptionalNonNegativeInt(payload["totalCount"])
if err != nil {
return paging.Page{}, fmt.Errorf("query_records totalCount: %w", err)
}
return paging.Page{Records: records, NextCursor: nextCursor, TotalCount: totalCount}, nil
}
func firstNonEmptyString(values map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := values[key].(string); ok && strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func parseOptionalNonNegativeInt(value any) (*int, error) {
if value == nil {
return nil, nil
}
var parsed int64
switch typed := value.(type) {
case json.Number:
integer, err := typed.Int64()
if err != nil {
return nil, fmt.Errorf("must be an integer, got %q", typed)
}
parsed = integer
case float64:
if math.Trunc(typed) != typed {
return nil, fmt.Errorf("must be an integer, got %v", typed)
}
parsed = int64(typed)
case int:
parsed = int64(typed)
case int64:
parsed = typed
default:
return nil, fmt.Errorf("must be a non-negative integer, got %T", value)
}
if parsed < 0 || int64(int(parsed)) != parsed {
return nil, fmt.Errorf("must fit a non-negative int, got %d", parsed)
}
result := int(parsed)
return &result, nil
}
func recordQueryIncompleteError(result paging.Result, pageLimit int) error {
incomplete := map[string]any{
"records": result.Records,
"fetchedCount": len(result.Records),
"pages": result.Pages,
"attempts": result.Attempts,
"complete": false,
"hasMore": result.HasMore,
"stopReason": result.StopReason,
}
if result.LastCursor != "" {
incomplete["cursor"] = result.LastCursor
}
if result.TotalCount != nil {
incomplete["totalCount"] = *result.TotalCount
}
hint := "retry the command; the structured error details preserve fetched records and the retry cursor"
if result.StopReason == paging.StopPageLimit {
hint = "rerun with --page-limit 0 to require a complete result, or resume from details.cursor"
}
message := fmt.Sprintf("record pagination incomplete after %d successful page(s) and %d fetched record(s)", result.Pages, len(result.Records))
return apperrors.NewAPI(message,
apperrors.WithOperation("aitable.query_records.all"),
apperrors.WithServerKey("aitable"),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("pagination"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(true),
apperrors.WithReason("pagination_"+string(result.StopReason)),
apperrors.WithHint(hint),
apperrors.WithDetails(map[string]any{
"page_limit": pageLimit,
"incomplete_result": incomplete,
}),
apperrors.WithCause(result.Err),
)
}
// ─── filters 格式校验 ──────────────────────────────────────────────────────
@@ -6966,8 +7051,8 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
recordQueryCmd.Flags().Int("page-size", 0, "--limit 的别名(兼容 LLM 常见误用)")
_ = recordQueryCmd.Flags().MarkHidden("page-size")
recordQueryCmd.Flags().String("cursor", "", "分页游标,首次查询不传;cursor 为空表示已取完全部记录")
recordQueryCmd.Flags().Bool("all", false, "自动翻页获取全部记录。传入时自动循环直到无更多数据或达到 --page-limit 上限")
recordQueryCmd.Flags().Int("page-limit", 50, "自动翻页最大页数(仅 --all 时生效)。默认 50 页(5000 条),设为 0 表示无限制")
recordQueryCmd.Flags().Bool("all", false, "自动翻页获取完整记录集;达到 --page-limit 且仍有更多页时返回非零结构化错误,不把不完整结果作为成功输出")
recordQueryCmd.Flags().Int("page-limit", 50, "自动翻页最大页数(仅 --all 时生效)。默认 50 页(约 5000 条);设为 0 表示显式不限页数;超限时错误详情保留已取记录和续传 cursor")
recordQueryCmd.Flags().String("view-id", "", "视图 ID(record query 不支持按视图过滤,此参数会被忽略并给出提示)")
_ = recordQueryCmd.Flags().MarkHidden("view-id")
recordCreateCmd.Flags().String("base-id", "", "Base ID,可通过 base list 或 base search 获取 (必填)")
+4 -4
View File
@@ -292,15 +292,15 @@ func TestCrossPlatformCoverageAitableToolResponseAndPaginationHelpers(t *testing
t.Fatalf("update view top-level: %v", err)
}
caller = &aitableTestCaller{responses: []string{`{"data":{"records":[{"id":1}],"nextCursor":"next"}}`}}
caller = &aitableTestCaller{responses: []string{`{"data":{"records":[{"id":1}],"totalCount":17}}`}}
out = installAitableDeps(t, caller)
if err := recordQueryFetchAll(map[string]any{}, 1); err != nil || !strings.Contains(out.String(), "totalCount") {
if err := recordQueryFetchAll(map[string]any{}, 1); err != nil || !strings.Contains(out.String(), `"totalCount": 17`) || !strings.Contains(out.String(), `"fetchedCount": 1`) {
t.Fatalf("paginated records = %q, %v", out.String(), err)
}
caller = &aitableTestCaller{responses: []string{"not-json"}}
out = installAitableDeps(t, caller)
if err := recordQueryFetchAll(map[string]any{}, 1); err != nil || !strings.Contains(out.String(), "not-json") {
t.Fatalf("raw first page = %q, %v", out.String(), err)
if err := recordQueryFetchAll(map[string]any{}, 1); err == nil || out.Len() != 0 {
t.Fatalf("invalid first page must fail without success output = %q, %v", out.String(), err)
}
caller = &aitableTestCaller{responses: []string{`{"records":[{"id":1}]}`}}
installAitableDeps(t, caller)
@@ -0,0 +1,93 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package helpers
import (
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/paging"
)
func TestCrossPlatformCoverageAITablePageParsingRemainingEdges(t *testing.T) {
for name, raw := range map[string]string{
"trailing invalid JSON": `{"records":[]} {`,
"data not object": `{"data":[]}`,
} {
t.Run(name, func(t *testing.T) {
if _, err := parseRecordQueryPage(raw); err == nil {
t.Fatalf("parseRecordQueryPage(%q) succeeded", raw)
}
})
}
}
func TestCrossPlatformCoverageAITableOptionalCountNumericTypes(t *testing.T) {
cases := []struct {
name string
value any
want int
wantErr bool
}{
{name: "bad number", value: json.Number("1.5"), wantErr: true},
{name: "fractional float", value: float64(1.5), wantErr: true},
{name: "whole float", value: float64(2), want: 2},
{name: "int", value: int(3), want: 3},
{name: "int64", value: int64(4), want: 4},
{name: "wrong type", value: "5", wantErr: true},
{name: "negative", value: int64(-1), wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parseOptionalNonNegativeInt(tc.value)
if tc.wantErr {
if err == nil {
t.Fatalf("value %#v succeeded: %#v", tc.value, got)
}
return
}
if err != nil || got == nil || *got != tc.want {
t.Fatalf("value %#v = %#v, %v", tc.value, got, err)
}
})
}
}
func TestCrossPlatformCoverageAITableIncompleteResultMetadata(t *testing.T) {
total := 9
err := recordQueryIncompleteError(paging.Result{
Records: []any{map[string]any{"id": "r"}}, Pages: 1, Attempts: 1,
HasMore: true, LastCursor: "next", StopReason: paging.StopPageLimit,
TotalCount: &total,
}, 1)
if err == nil || !strings.Contains(err.Error(), "pagination incomplete") {
t.Fatalf("incomplete result error = %v", err)
}
}
func TestCrossPlatformCoverageAITableExplicitUnlimitedPaginationE2E(t *testing.T) {
responses := make([]string, paging.DefaultPageLimit+1)
for index := range responses {
next := ""
if index < len(responses)-1 {
next = fmt.Sprintf(`,"nextCursor":"cursor-%d"`, index+1)
}
responses[index] = fmt.Sprintf(`{"records":[{"recordId":"record-%d"}]%s}`, index, next)
}
caller := &aitableTestCaller{responses: responses}
out := installAitableDeps(t, caller)
if err := recordQueryFetchAll(map[string]any{}, 0); err != nil {
t.Fatalf("explicit unlimited pagination failed: %v", err)
}
if len(caller.calls) != len(responses) {
t.Fatalf("explicit unlimited pagination calls = %d, want %d", len(caller.calls), len(responses))
}
for _, want := range []string{`"pages": 51`, `"fetchedCount": 51`, `"complete": true`} {
if !strings.Contains(out.String(), want) {
t.Fatalf("explicit unlimited output missing %s: %s", want, out.String())
}
}
}
@@ -0,0 +1,192 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package helpers
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"reflect"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type recordQueryE2EStep struct {
result *edition.ToolResult
err error
}
type recordQueryE2ECaller struct {
steps []recordQueryE2EStep
calls []aitableTestCall
}
func (c *recordQueryE2ECaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
cloned := make(map[string]any, len(args))
for key, value := range args {
cloned[key] = value
}
c.calls = append(c.calls, aitableTestCall{server: server, tool: tool, args: cloned})
index := len(c.calls) - 1
if index >= len(c.steps) {
return nil, fmt.Errorf("unexpected tool call %d", index+1)
}
return c.steps[index].result, c.steps[index].err
}
func (*recordQueryE2ECaller) Format() string { return "json" }
func (*recordQueryE2ECaller) DryRun() bool { return false }
func (*recordQueryE2ECaller) Fields() string { return "" }
func (*recordQueryE2ECaller) JQ() string { return "" }
func runRecordQueryCLI(t *testing.T, caller *recordQueryE2ECaller, extraArgs ...string) (string, error) {
t.Helper()
testseam.Protect(t, &deps)
oldArgs := os.Args
t.Cleanup(func() { os.Args = oldArgs })
InitDeps(caller)
out := &bytes.Buffer{}
deps.Out.w = out
deps.Out.errW = out
args := []string{"record", "query", "--base-id", "base-e2e", "--table-id", "table-e2e", "--all"}
args = append(args, extraArgs...)
os.Args = append([]string{"dws", "aitable"}, args...)
command := newAitableCommand()
command.SilenceErrors = true
command.SilenceUsage = true
command.SetArgs(args)
err := command.Execute()
return out.String(), err
}
func recordQueryTextStep(text string) recordQueryE2EStep {
return recordQueryE2EStep{result: textToolResult(text)}
}
func TestCrossPlatformCoverageRecordQueryCLICompleteE2E(t *testing.T) {
caller := &recordQueryE2ECaller{steps: []recordQueryE2EStep{
recordQueryTextStep(`{"data":{"records":[{"id":"r1"},{"id":"r2"}],"nextCursor":"cursor-2","totalCount":3}}`),
recordQueryTextStep(`{"data":{"records":[{"id":"r3"}],"nextCursor":"","totalCount":3}}`),
}}
out, err := runRecordQueryCLI(t, caller, "--page-limit", "0")
if err != nil {
t.Fatalf("record query CLI returned error: %v", err)
}
for _, want := range []string{`"complete": true`, `"fetchedCount": 3`, `"totalCount": 3`, `"id": "r3"`} {
if !bytes.Contains([]byte(out), []byte(want)) {
t.Fatalf("record query CLI output missing %s:\n%s", want, out)
}
}
if len(caller.calls) != 2 {
t.Fatalf("tool calls = %d, want 2", len(caller.calls))
}
if got := caller.calls[0].args["cursor"]; got != nil {
t.Fatalf("first-page cursor = %#v, want absent", got)
}
if got := caller.calls[1].args["cursor"]; got != "cursor-2" {
t.Fatalf("second-page cursor = %#v, want cursor-2", got)
}
}
func TestCrossPlatformCoverageRecordQueryCLIFailsClosedE2E(t *testing.T) {
tests := []struct {
name string
steps []recordQueryE2EStep
args []string
}{
{name: "nil result", steps: []recordQueryE2EStep{{result: nil}}},
{name: "empty content", steps: []recordQueryE2EStep{{result: &edition.ToolResult{}}}},
{name: "empty text", steps: []recordQueryE2EStep{recordQueryTextStep("")}},
{name: "invalid json", steps: []recordQueryE2EStep{recordQueryTextStep("{")}},
{name: "null payload", steps: []recordQueryE2EStep{recordQueryTextStep("null")}},
{name: "missing records", steps: []recordQueryE2EStep{recordQueryTextStep(`{"data":{"nextCursor":"c"}}`)}},
{name: "records wrong type", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":{}}`)}},
{name: "records null", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":null}`)}},
{name: "record item wrong type", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":["bad"]}`)}},
{name: "trailing json", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":[]} {}`)}},
{name: "has more without cursor", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":[],"hasMore":true}`)}},
{name: "invalid total", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":[],"totalCount":"many"}`)}},
{name: "first transport error", steps: []recordQueryE2EStep{{err: errors.New("offline")}}},
{
name: "second page error",
steps: []recordQueryE2EStep{
recordQueryTextStep(`{"records":[{"id":"kept"}],"nextCursor":"retry-me"}`),
{err: errors.New("upstream reset")},
},
args: []string{"--page-limit", "0"},
},
{
name: "page limit",
steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":[{"id":"kept"}],"nextCursor":"resume-me"}`)},
args: []string{"--page-limit", "1"},
},
{
name: "cursor cycle",
steps: []recordQueryE2EStep{
recordQueryTextStep(`{"records":[{"id":"r1"}],"nextCursor":"same"}`),
recordQueryTextStep(`{"records":[{"id":"r2"}],"nextCursor":"same"}`),
},
args: []string{"--page-limit", "0"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &recordQueryE2ECaller{steps: test.steps}
out, err := runRecordQueryCLI(t, caller, test.args...)
if err == nil {
t.Fatalf("CLI treated %s as success; output=%s", test.name, out)
}
if out != "" {
t.Fatalf("CLI emitted success output for %s: %q", test.name, out)
}
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error type = %T, want *errors.Error", err)
}
if typed.Reason == "" || typed.Details["incomplete_result"] == nil {
t.Fatalf("error lacks recovery metadata: %#v", typed)
}
})
}
}
func TestCrossPlatformCoverageRecordQueryCLIInitialCursorE2E(t *testing.T) {
caller := &recordQueryE2ECaller{steps: []recordQueryE2EStep{
recordQueryTextStep(`{"records":[]}`),
}}
if out, err := runRecordQueryCLI(t, caller, "--cursor", "resume-from-here", "--page-limit", "0"); err != nil || out == "" {
t.Fatalf("resume query = %q, %v", out, err)
}
want := map[string]any{"baseId": "base-e2e", "tableId": "table-e2e", "cursor": "resume-from-here"}
if !reflect.DeepEqual(caller.calls[0].args, want) {
t.Fatalf("resume args = %#v, want %#v", caller.calls[0].args, want)
}
}
func TestCrossPlatformCoverageRecordQueryCLIPageLimitHelpE2E(t *testing.T) {
command := newAitableCommand()
command.SilenceErrors = true
command.SilenceUsage = true
out := &bytes.Buffer{}
command.SetOut(out)
command.SetErr(out)
command.SetArgs([]string{"record", "query", "--help"})
if err := command.Execute(); err != nil {
t.Fatalf("record query help failed: %v", err)
}
for _, want := range []string{"返回非零结构化错误", "不完整结果", "错误详情保留已取记录和续传 cursor"} {
if !strings.Contains(out.String(), want) {
t.Fatalf("record query help missing %q:\n%s", want, out.String())
}
}
}
+179 -2
View File
@@ -230,7 +230,7 @@ func newCalendarCommand() *cobra.Command {
Long: `管理钉钉日历:日程、参会人、会议室、闲忙、附件、日历本、访问权限。调用前必须先使用 --help 查看参数结构。
命令结构:
dws calendar event [list|get|create|update|delete|suggest|respond] 日程管理
dws calendar event [list|get|create|update|delete|suggest|respond|instances] 日程管理
dws calendar attendee [list|add|delete] 参会人管理
dws calendar room [search|add|delete|list-groups] 会议室管理
dws calendar busy search 闲忙查询 (可查人、查会议室)
@@ -2082,7 +2082,184 @@ func newCalendarCommand() *cobra.Command {
eventSuggestCmd.Flags().String("members", "", "")
_ = eventSuggestCmd.Flags().MarkHidden("members")
eventSuggestCmd.Flags().String("duration", "", "日程持续时间 (分钟,默认30)")
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd)
eventInstancesCmd := &cobra.Command{
Use: "instances",
Short: "查询循环日程的实例列表",
Long: `查询指定重复性日程(SeriesMaster)在指定时间范围内的所有实例。
**注意**:此接口只能查询重复性日程的实例;若传入的是普通非循环日程,将查不到任何实例信息。
必须传入 --id 指定重复性日程的 eventId(即 SeriesMaster 的 eventId,可通过 event list 获取)。
不传 --start/--end 时,默认查询今天(00:00:00 ~ 23:59:59)的实例。`,
Example: ` dws calendar event instances --id EVENT_ID
dws calendar event instances --id EVENT_ID --start "2026-03-10T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00"
dws calendar event instances --id EVENT_ID --limit 50
dws calendar event instances --id EVENT_ID --cursor "<nextCursor>"`,
RunE: func(cmd *cobra.Command, args []string) error {
eventID, err := mustFlagOrFallback(cmd, "id", "event", "event-id", "eventId")
if err != nil {
return err
}
toolArgs := map[string]any{"eventId": eventID}
var startTime, endTime int64
var now time.Time
if v := flagOrFallback(cmd, "start", "time-min", "min-time", "start-time", "startTime", "start_time", "start-date", "startDate"); v != "" {
startTime, err = parseISOTimeToMillis("start", v)
if err != nil {
return err
}
toolArgs["startTime"] = startTime
} else {
now = time.Now()
startTime = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).UnixMilli()
toolArgs["startTime"] = startTime
}
if v := flagOrFallback(cmd, "end", "time-max", "max-time", "end-time", "endTime", "end_time", "end-date", "endDate"); v != "" {
endTime, err = parseISOTimeToMillis("end", v)
if err != nil {
return err
}
toolArgs["endTime"] = endTime
} else {
if now.IsZero() {
now = time.Now()
}
endTime = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).UnixMilli()
toolArgs["endTime"] = endTime
}
if err := validateTimeRange(startTime, endTime); err != nil {
return err
}
if v := flagOrFallback(cmd, "calendar-id", "calendarId", "calendar"); v != "" {
toolArgs["calendarId"] = v
}
if v := flagOrFallback(cmd, "cursor", "next-cursor", "nextCursor", "page-token", "pageToken", "next-token"); v != "" {
toolArgs["cursor"] = v
}
if lim, _ := cmd.Flags().GetInt("limit"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("max-results"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("maxResults"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("page-size"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("size"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("count"); lim > 0 {
toolArgs["limit"] = lim
}
return callSortedCalendarEvents(cmd, "list_event_instances", toolArgs)
},
}
DeclareLeafMetadata(eventInstancesCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "calendar",
Name: "list_event_instances",
CanonicalPath: "calendar.list_event_instances",
CLIPath: "calendar event instances",
PrimaryCLIPath: "calendar event instances",
},
Description: "查询循环日程的实例列表",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "calendar", RPCName: "list_event_instances"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询循环日程在时间范围内展开的实例",
UseWhen: []string{"已知循环日程 eventId(SeriesMaster),需要列出某时间窗内的实例时"},
AvoidWhen: []string{
"普通非循环日程请用 dws calendar event get / list",
"未知 eventId 时先 dws calendar event list",
},
Examples: []string{
"dws calendar event instances --id <EVENT_ID>",
"dws calendar event instances --id <EVENT_ID> --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-31T23:59:59+08:00\"",
},
},
Parameters: []contract.ParamDecl{
{Name: "id", Property: "eventId", Required: boolPtr(true)},
{Name: "start", Property: "startTime"},
{Name: "end", Property: "endTime"},
{Name: "calendar-id", Property: "calendarId"},
{Name: "cursor", Property: "cursor"},
{Name: "limit", Property: "limit", InterfaceType: "integer"},
},
},
})
// InstancesEvent flags (aligned with event list aliases)
eventInstancesCmd.Flags().String("id", "", "日程 ID (必填)")
eventInstancesCmd.Flags().String("event", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("event")
eventInstancesCmd.Flags().String("event-id", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("event-id")
eventInstancesCmd.Flags().String("eventId", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("eventId")
eventInstancesCmd.Flags().String("start", "", "开始时间 ISO-8601 (例如 2026-03-10T00:00:00+08:00)")
eventInstancesCmd.Flags().String("time-min", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("time-min")
eventInstancesCmd.Flags().String("min-time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("min-time")
eventInstancesCmd.Flags().String("start-time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("start-time")
eventInstancesCmd.Flags().String("startTime", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("startTime")
eventInstancesCmd.Flags().String("start_time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("start_time")
eventInstancesCmd.Flags().String("start-date", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("start-date")
eventInstancesCmd.Flags().String("startDate", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("startDate")
eventInstancesCmd.Flags().String("end", "", "结束时间 ISO-8601 (例如 2026-03-31T23:59:59+08:00)")
eventInstancesCmd.Flags().String("time-max", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("time-max")
eventInstancesCmd.Flags().String("max-time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("max-time")
eventInstancesCmd.Flags().String("end-time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("end-time")
eventInstancesCmd.Flags().String("endTime", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("endTime")
eventInstancesCmd.Flags().String("end_time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("end_time")
eventInstancesCmd.Flags().String("end-date", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("end-date")
eventInstancesCmd.Flags().String("endDate", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("endDate")
eventInstancesCmd.Flags().String("calendar-id", "", "日历 ID (可选,默认 primary 主日历;指定其他日历本时填写,可通过 book list 获取)")
eventInstancesCmd.Flags().String("calendarId", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("calendarId")
eventInstancesCmd.Flags().String("calendar", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("calendar")
eventInstancesCmd.Flags().String("cursor", "", "分页游标 (首次查询无需传入,仅翻页时传入上一次返回的 nextCursor)")
eventInstancesCmd.Flags().String("next-cursor", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("next-cursor")
eventInstancesCmd.Flags().String("nextCursor", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("nextCursor")
eventInstancesCmd.Flags().String("page-token", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("page-token")
eventInstancesCmd.Flags().String("pageToken", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("pageToken")
eventInstancesCmd.Flags().String("next-token", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("next-token")
eventInstancesCmd.Flags().Int("limit", 0, "每页返回条数 (默认 100,最大 100)")
eventInstancesCmd.Flags().Int("max-results", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("max-results")
eventInstancesCmd.Flags().Int("maxResults", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("maxResults")
eventInstancesCmd.Flags().Int("page-size", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("page-size")
eventInstancesCmd.Flags().Int("size", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("size")
eventInstancesCmd.Flags().Int("count", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("count")
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd, eventInstancesCmd)
// participant
participantCmd.PersistentFlags().String("event", "", "日程 ID (必填)")
+1152 -41
View File
File diff suppressed because it is too large Load Diff
+144
View File
@@ -0,0 +1,144 @@
// 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 chat
import (
"fmt"
"strconv"
"strings"
"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"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
type LeafContract = corecmd.ContractDecl
type LeafSpec struct {
Safety contract.SafetySpec
Contract LeafContract
}
type Deps struct {
GroupRunE func(*cobra.Command, []string) error
CallMCPToolOnServer func(string, string, map[string]any) error
DeclareLeafMetadata func(*cobra.Command, LeafSpec) *cobra.Command
ValidateRequiredFlag func(*cobra.Command, ...string) error
}
var deps = Deps{
GroupRunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
CallMCPToolOnServer: func(serverID, toolName string, args map[string]any) error {
return fmt.Errorf("im helpers dependency CallMCPToolOnServer is not initialized")
},
DeclareLeafMetadata: func(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
return cmd
},
ValidateRequiredFlag: validateRequiredFlagsLocal,
}
func SetDeps(next Deps) {
if next.GroupRunE != nil {
deps.GroupRunE = next.GroupRunE
}
if next.CallMCPToolOnServer != nil {
deps.CallMCPToolOnServer = next.CallMCPToolOnServer
}
if next.DeclareLeafMetadata != nil {
deps.DeclareLeafMetadata = next.DeclareLeafMetadata
}
if next.ValidateRequiredFlag != nil {
deps.ValidateRequiredFlag = next.ValidateRequiredFlag
}
}
func NewChatToolbarCommand() *cobra.Command {
return newChatToolbarCommand()
}
func groupRunE(cmd *cobra.Command, args []string) error {
return deps.GroupRunE(cmd, args)
}
func callMCPToolOnServer(serverID, toolName string, args map[string]any) error {
return deps.CallMCPToolOnServer(serverID, toolName, args)
}
func DeclareLeafMetadata(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
return deps.DeclareLeafMetadata(cmd, spec)
}
func validateRequiredFlags(cmd *cobra.Command, names ...string) error {
return deps.ValidateRequiredFlag(cmd, names...)
}
func mustGetFlag(cmd *cobra.Command, name string) string {
val, _ := cmd.Flags().GetString(name)
if val == "" {
val, _ = cmd.InheritedFlags().GetString(name)
}
return strings.TrimSpace(val)
}
func parseCSVInt64(raw string) ([]int64, error) {
parts := strings.Split(raw, ",")
result := make([]int64, 0, len(parts))
for _, p := range parts {
v := strings.TrimSpace(p)
if v == "" {
continue
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid integer %q", v)
}
result = append(result, n)
}
if len(result) == 0 {
return nil, fmt.Errorf("at least one ID is required")
}
return result, nil
}
func validateRequiredFlagsLocal(cmd *cobra.Command, names ...string) error {
for _, name := range names {
if strings.TrimSpace(mustGetFlag(cmd, name)) == "" {
return apperrors.NewValidation(
fmt.Sprintf("flag --%s is required", name),
apperrors.WithReason("missing_required_flag"),
apperrors.WithHint(fmt.Sprintf("使用 --%s 指定必填参数", name)),
)
}
}
return nil
}
func commandBoolFlag(cmd *cobra.Command, name string) bool {
if cmd == nil {
return false
}
value, _ := cmd.Flags().GetBool(name)
if !value {
value, _ = cmd.InheritedFlags().GetBool(name)
}
return value
}
func boolPtr(v bool) *bool {
return &v
}
+38
View File
@@ -0,0 +1,38 @@
// 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 chat
import "github.com/spf13/cobra"
func newChatToolbarCommand() *cobra.Command {
toolbarCmd := &cobra.Command{
Use: "toolbar",
Short: "快捷栏管理",
Long: "管理会话快捷栏入口:查询、添加、隐藏、排序及自定义入口 CRUD。",
RunE: groupRunE,
}
toolbarCmd.DisableAutoGenTag = true
toolbarCmd.AddCommand(
newToolbarListCommand(),
newToolbarAddCommand(),
newToolbarHideCommand(),
newToolbarSortCommand(),
newToolbarCreateCustomCommand(),
newToolbarRemoveCustomCommand(),
newToolbarUpdateCustomCommand(),
)
return toolbarCmd
}
+92
View File
@@ -0,0 +1,92 @@
// 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 chat
import (
"fmt"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func newToolbarAddCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "add",
Short: "将入口添加到快捷栏可见区",
Long: "将一个或多个快捷入口从隐藏区移至可见区。入口 ID 使用逗号分隔。",
Example: ` dws chat toolbar add --conversation-id <cid> --shortcut-ids 101,102
# 查询入口 ID: dws chat toolbar list --conversation-id <cid>`,
RunE: func(cmd *cobra.Command, args []string) error {
cid, err := toolbarConversationID(cmd)
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "shortcut-ids"); err != nil {
return err
}
ids, err := parseCSVInt64(mustGetFlag(cmd, "shortcut-ids"))
if err != nil {
return fmt.Errorf("--shortcut-ids: %w", err)
}
err = callMCPToolOnServer("im", "add_shortcut_to_bar", map[string]any{
"openConversationId": cid,
"shortcutIds": ids,
})
if isSystemBusy(err) {
return toolbarNewSystemBusyError()
}
return err
},
}
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
cmd.Flags().String("shortcut-ids", "", "入口 ID 列表(逗号分隔)")
_ = cmd.MarkFlagRequired("conversation-id")
_ = cmd.MarkFlagRequired("shortcut-ids")
cmd.DisableAutoGenTag = true
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat",
Name: "add_shortcut_to_bar",
CanonicalPath: "chat.add_shortcut_to_bar",
CLIPath: "chat toolbar add",
PrimaryCLIPath: "chat toolbar add",
},
Description: "将快捷入口添加到会话快捷栏可见区",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "add_shortcut_to_bar"},
},
Selection: contract.SelectionSpec{
AgentSummary: "将快捷入口添加到会话快捷栏可见区",
UseWhen: []string{"需要将隐藏的快捷入口重新显示在快捷栏中"},
AvoidWhen: []string{"创建新的自定义入口应使用 chat toolbar create-custom"},
Examples: []string{"dws chat toolbar add --conversation-id <cid> --shortcut-ids 101,102"},
},
Parameters: []contract.ParamDecl{
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
{Name: "shortcut-ids", Property: "shortcutIds", Required: boolPtr(true)},
},
},
})
return cmd
}
@@ -0,0 +1,143 @@
// 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 chat
import (
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func newToolbarCreateCustomCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "create-custom",
Short: "创建自定义快捷栏入口",
Long: `创建一个新的自定义快捷栏入口。
必填参数:
--conversation-id 会话 openConversationId
--title 入口标题
--url 入口跳转链接
--icon-url 入口图标 URL
--pc-url PC 端跳转链接(可与 --url 相同)
可选参数:
--extension 扩展信息,格式 key=value,可重复使用
--desc 入口描述(为空时使用 --title)
--tag 入口标签
--sort-index 排序权重`,
Example: ` dws chat toolbar create-custom --conversation-id <cid> --title "周报" --url "https://example.com" --icon-url "https://example.com/icon.png" --pc-url "https://example.com"
dws chat toolbar create-custom --conversation-id <cid> --title "工具" --url "https://example.com" --icon-url "https://example.com/icon.png" --pc-url "https://example.com" --extension color=blue --desc "常用工具"`,
RunE: func(cmd *cobra.Command, args []string) error {
cid, err := toolbarConversationID(cmd)
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "title", "url", "icon-url", "pc-url"); err != nil {
return err
}
toolArgs := map[string]any{
"openConversationId": cid,
"name": toolbarI18nJSONString(mustGetFlag(cmd, "title")),
"url": mustGetFlag(cmd, "url"),
"icons": toolbarIconsJSONString(mustGetFlag(cmd, "icon-url")),
"pcUrl": mustGetFlag(cmd, "pc-url"),
}
descValue := mustGetFlag(cmd, "desc")
if descValue == "" {
descValue = mustGetFlag(cmd, "title")
}
toolArgs["desc"] = toolbarI18nJSONString(descValue)
if tag, _ := cmd.Flags().GetString("tag"); tag != "" {
toolArgs["tag"] = tag
}
if cmd.Flags().Changed("sort-index") {
sortIndex, _ := cmd.Flags().GetInt("sort-index")
toolArgs["sortIndex"] = sortIndex
}
ext, err := parseExtension(cmd)
if err != nil {
return err
}
if ext != nil {
toolArgs["extension"] = ext
}
err = callMCPToolOnServer("im", "create_custom_shortcut", toolArgs)
if isSystemBusy(err) {
return toolbarNewSystemBusyError()
}
return err
},
}
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
cmd.Flags().String("title", "", "入口标题")
cmd.Flags().String("url", "", "入口跳转链接")
cmd.Flags().String("icon-url", "", "入口图标 URL")
cmd.Flags().String("pc-url", "", "PC 端跳转链接")
cmd.Flags().StringArray("extension", nil, "扩展信息,格式 key=value,可重复使用")
cmd.Flags().String("desc", "", "入口描述(为空时使用 --title)")
cmd.Flags().String("tag", "", "入口标签")
cmd.Flags().Int("sort-index", 0, "排序权重")
_ = cmd.MarkFlagRequired("conversation-id")
_ = cmd.MarkFlagRequired("title")
_ = cmd.MarkFlagRequired("url")
_ = cmd.MarkFlagRequired("icon-url")
_ = cmd.MarkFlagRequired("pc-url")
cmd.DisableAutoGenTag = true
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat",
Name: "create_custom_shortcut",
CanonicalPath: "chat.create_custom_shortcut",
CLIPath: "chat toolbar create-custom",
PrimaryCLIPath: "chat toolbar create-custom",
},
Description: "创建自定义快捷栏入口",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "create_custom_shortcut"},
},
Selection: contract.SelectionSpec{
AgentSummary: "创建自定义快捷栏入口",
UseWhen: []string{"需要在快捷栏中新增一个自定义入口(含标题、链接、图标等)"},
AvoidWhen: []string{"移动已有入口使用 chat toolbar add / hide;更新已有自定义入口使用 chat toolbar update-custom"},
Examples: []string{"dws chat toolbar create-custom --conversation-id <cid> --title \"周报\" --url \"https://example.com\" --icon-url \"https://example.com/icon.png\" --pc-url \"https://example.com\""},
},
Parameters: []contract.ParamDecl{
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
{Name: "title", Property: "name", Required: boolPtr(true)},
{Name: "url", Property: "url", Required: boolPtr(true)},
{Name: "icon-url", Property: "icons", Required: boolPtr(true)},
{Name: "pc-url", Property: "pcUrl", Required: boolPtr(true)},
{Name: "extension", Property: "extension", Required: boolPtr(false)},
{Name: "desc", Property: "desc", Required: boolPtr(false)},
{Name: "tag", Property: "tag", Required: boolPtr(false)},
{Name: "sort-index", Property: "sortIndex", Required: boolPtr(false)},
},
},
})
return cmd
}
@@ -0,0 +1,26 @@
// 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 chat
import "encoding/json"
func toolbarI18nJSONString(value string) string {
payload, _ := json.Marshal(map[string]string{"zh_CN": value})
return string(payload)
}
func toolbarIconsJSONString(iconMobile string) string {
payload, _ := json.Marshal(map[string]string{"iconMobile": iconMobile})
return string(payload)
}
+93
View File
@@ -0,0 +1,93 @@
// 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 chat
import (
"fmt"
"strings"
"github.com/spf13/cobra"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
func hasIntersection(a, b []int64) bool {
if len(a) == 0 || len(b) == 0 {
return false
}
seen := make(map[int64]struct{}, len(a))
for _, v := range a {
seen[v] = struct{}{}
}
for _, v := range b {
if _, ok := seen[v]; ok {
return true
}
}
return false
}
func isSystemBusy(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "SYSTEM_BUSY")
}
func parseExtension(cmd *cobra.Command) (map[string]string, error) {
raw, _ := cmd.Flags().GetStringArray("extension")
if len(raw) == 0 {
return nil, nil
}
result := make(map[string]string, len(raw))
for _, pair := range raw {
idx := strings.Index(pair, "=")
if idx <= 0 {
return nil, fmt.Errorf("--extension 格式错误,期望 key=value,实际 %q", pair)
}
key := strings.TrimSpace(pair[:idx])
value := strings.TrimSpace(pair[idx+1:])
if key == "" {
return nil, fmt.Errorf("--extension key 不能为空: %q", pair)
}
if _, exists := result[key]; exists {
return nil, fmt.Errorf("--extension 重复 key %q,每个 key 只能指定一次", key)
}
result[key] = value
}
return result, nil
}
func toolbarConversationID(cmd *cobra.Command) (string, error) {
cid, _ := cmd.Flags().GetString("conversation-id")
cid = strings.TrimSpace(cid)
if cid == "" {
return "", apperrors.NewValidation(
"flag --conversation-id is required",
apperrors.WithReason("missing_required_flag"),
apperrors.WithHint("使用 --conversation-id 指定会话 openConversationId"),
apperrors.WithActions("通过 dws chat search 或 dws chat group list 获取会话 ID"),
)
}
return cid, nil
}
func toolbarNewSystemBusyError() error {
return apperrors.NewValidation(
"服务端繁忙(SYSTEM_BUSY),请稍后重试",
apperrors.WithReason("system_busy"),
apperrors.WithHint("服务端当前负载较高,建议等待几秒后重试"),
apperrors.WithActions("等待后使用相同参数重试"),
)
}
@@ -0,0 +1,733 @@
// 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 chat
import (
"context"
"errors"
"io"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type toolbarCall struct {
product string
tool string
args map[string]any
}
type toolbarTestCaller struct {
product string
tool string
args map[string]any
err error
calls []toolbarCall
}
func (c *toolbarTestCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, toolbarCall{product: productID, tool: toolName, args: args})
c.product = productID
c.tool = toolName
c.args = args
if c.err != nil {
return nil, c.err
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: "{}"}}}, nil
}
func (c *toolbarTestCaller) Format() string { return "json" }
func (c *toolbarTestCaller) DryRun() bool { return false }
func (c *toolbarTestCaller) Fields() string { return "" }
func (c *toolbarTestCaller) JQ() string { return "" }
func executeToolbarCommand(t *testing.T, cmd *cobra.Command, caller *toolbarTestCaller, args ...string) error {
t.Helper()
initToolbarDepsForTest(t, caller)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
return cmd.Execute()
}
func initToolbarDepsForTest(t *testing.T, caller *toolbarTestCaller) {
t.Helper()
previous := deps
SetDeps(Deps{
GroupRunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
CallMCPToolOnServer: func(productID, toolName string, args map[string]any) error {
_, err := caller.CallTool(context.Background(), productID, toolName, args)
return err
},
DeclareLeafMetadata: func(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
return cmd
},
ValidateRequiredFlag: validateRequiredFlagsLocal,
})
t.Cleanup(func() {
deps = previous
})
}
func requireTypedConfirmationError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected confirmation error, got nil")
}
if !strings.Contains(err.Error(), "获得用户确认后加 --yes 执行") {
t.Fatalf("expected confirmation error, got %v", err)
}
}
func requireToolbarCall(
t *testing.T,
caller *toolbarTestCaller,
wantProduct, wantTool string,
wantArgs map[string]any,
) {
t.Helper()
if caller.product != wantProduct || caller.tool != wantTool {
t.Fatalf("tool call = %s/%s, want %s/%s", caller.product, caller.tool, wantProduct, wantTool)
}
if !reflect.DeepEqual(caller.args, wantArgs) {
t.Fatalf("tool args = %#v, want %#v", caller.args, wantArgs)
}
}
func toolbarCustomArgs(extra ...string) []string {
args := []string{"--conversation-id", "cid", "--title", "Title", "--url", "https://example.com/mobile", "--icon-url", "https://example.com/icon.png", "--pc-url", "https://example.com/pc"}
return append(args, extra...)
}
func TestHasIntersection(t *testing.T) {
tests := []struct {
name string
a, b []int64
want bool
}{
{"both empty", nil, nil, false},
{"a empty", nil, []int64{1}, false},
{"b empty", []int64{1}, nil, false},
{"no overlap", []int64{1, 2, 3}, []int64{4, 5, 6}, false},
{"has overlap", []int64{1, 2, 3}, []int64{3, 4, 5}, true},
{"single match", []int64{1}, []int64{1}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := hasIntersection(tt.a, tt.b)
if got != tt.want {
t.Errorf("hasIntersection(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want)
}
})
}
}
func TestIsSystemBusy(t *testing.T) {
if isSystemBusy(nil) {
t.Error("isSystemBusy(nil) should be false")
}
if !isSystemBusy(errors.New("error code: SYSTEM_BUSY")) {
t.Error("isSystemBusy should detect SYSTEM_BUSY in error message")
}
if isSystemBusy(errors.New("some other error")) {
t.Error("isSystemBusy should return false for non-SYSTEM_BUSY errors")
}
}
func TestParseExtension(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().StringArray("extension", nil, "")
ext, err := parseExtension(cmd)
if err != nil {
t.Fatalf("empty extension should not error: %v", err)
}
if ext != nil {
t.Fatalf("empty extension should return nil, got %v", ext)
}
_ = cmd.Flags().Set("extension", "key1=val1")
_ = cmd.Flags().Set("extension", "key2=val2")
ext, err = parseExtension(cmd)
if err != nil {
t.Fatalf("valid extension should not error: %v", err)
}
if ext["key1"] != "val1" || ext["key2"] != "val2" {
t.Fatalf("unexpected extension map: %v", ext)
}
cmd2 := &cobra.Command{Use: "test2"}
cmd2.Flags().StringArray("extension", nil, "")
_ = cmd2.Flags().Set("extension", "badformat")
_, err = parseExtension(cmd2)
if err == nil {
t.Fatal("invalid extension format should error")
}
cmd3 := &cobra.Command{Use: "test3"}
cmd3.Flags().StringArray("extension", nil, "")
_ = cmd3.Flags().Set("extension", "color=blue")
_ = cmd3.Flags().Set("extension", "color=red")
_, err = parseExtension(cmd3)
if err == nil {
t.Fatal("duplicate extension keys should error")
}
cmd4 := &cobra.Command{Use: "test4"}
cmd4.Flags().StringArray("extension", nil, "")
_ = cmd4.Flags().Set("extension", " =value")
_, err = parseExtension(cmd4)
if err == nil {
t.Fatal("blank extension key should error")
}
}
func TestToolbarConversationID(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("conversation-id", "", "")
_, err := toolbarConversationID(cmd)
if err == nil {
t.Fatal("missing conversation-id should error")
}
_ = cmd.Flags().Set("conversation-id", "cid123")
cid, err := toolbarConversationID(cmd)
if err != nil {
t.Fatalf("valid conversation-id should not error: %v", err)
}
if cid != "cid123" {
t.Fatalf("expected cid123, got %s", cid)
}
}
func TestToolbarDepsDefaultWrappersAndValidationEdges(t *testing.T) {
cmd := &cobra.Command{Use: "toolbar"}
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
if err := groupRunE(cmd, nil); err != nil {
t.Fatalf("default groupRunE returned error: %v", err)
}
if err := callMCPToolOnServer("im", "tool", nil); err == nil ||
!strings.Contains(err.Error(), "CallMCPToolOnServer is not initialized") {
t.Fatalf("default callMCPToolOnServer error = %v", err)
}
if got := DeclareLeafMetadata(cmd, LeafSpec{}); got != cmd {
t.Fatal("default DeclareLeafMetadata should return the original command")
}
toolbar := NewChatToolbarCommand()
if toolbar.Name() != "toolbar" || len(toolbar.Commands()) != 7 {
t.Fatalf("toolbar command = %q with %d children, want toolbar with 7 children", toolbar.Name(), len(toolbar.Commands()))
}
parent := &cobra.Command{Use: "parent"}
parent.PersistentFlags().String("conversation-id", "", "")
child := &cobra.Command{Use: "child"}
parent.AddCommand(child)
_ = parent.PersistentFlags().Set("conversation-id", " inherited ")
if got := mustGetFlag(child, "conversation-id"); got != "inherited" {
t.Fatalf("mustGetFlag inherited value = %q, want inherited", got)
}
if _, err := parseCSVInt64(" , "); err == nil || !strings.Contains(err.Error(), "at least one ID") {
t.Fatalf("empty csv error = %v", err)
}
if _, err := parseCSVInt64("1,bad"); err == nil || !strings.Contains(err.Error(), `invalid integer "bad"`) {
t.Fatalf("invalid csv error = %v", err)
}
required := &cobra.Command{Use: "required"}
required.Flags().String("title", "", "")
if err := validateRequiredFlagsLocal(required, "title"); err == nil ||
!strings.Contains(err.Error(), "flag --title is required") {
t.Fatalf("validateRequiredFlagsLocal error = %v", err)
}
if commandBoolFlag(nil, "yes") {
t.Fatal("commandBoolFlag(nil) should be false")
}
boolParent := &cobra.Command{Use: "parent"}
boolParent.PersistentFlags().Bool("yes", false, "")
boolChild := &cobra.Command{Use: "child"}
boolParent.AddCommand(boolChild)
_ = boolParent.PersistentFlags().Set("yes", "true")
if !commandBoolFlag(boolChild, "yes") {
t.Fatal("commandBoolFlag should read inherited true value")
}
}
func TestToolbarCommandsValidationEdges(t *testing.T) {
tests := []struct {
name string
cmd func() *cobra.Command
args []string
wantError string
}{
{
name: "add blank conversation id",
cmd: newToolbarAddCommand,
args: []string{"--conversation-id", " ", "--shortcut-ids", "1"},
wantError: "flag --conversation-id is required",
},
{
name: "add blank shortcut ids",
cmd: newToolbarAddCommand,
args: []string{"--conversation-id", "cid", "--shortcut-ids", " "},
wantError: "flag --shortcut-ids is required",
},
{
name: "add invalid shortcut ids",
cmd: newToolbarAddCommand,
args: []string{"--conversation-id", "cid", "--shortcut-ids", "1,bad"},
wantError: "--shortcut-ids",
},
{
name: "hide blank conversation id",
cmd: newToolbarHideCommand,
args: []string{"--conversation-id", " ", "--shortcut-ids", "1"},
wantError: "flag --conversation-id is required",
},
{
name: "hide blank shortcut ids",
cmd: newToolbarHideCommand,
args: []string{"--conversation-id", "cid", "--shortcut-ids", " "},
wantError: "flag --shortcut-ids is required",
},
{
name: "hide invalid shortcut ids",
cmd: newToolbarHideCommand,
args: []string{"--conversation-id", "cid", "--shortcut-ids", "1,bad"},
wantError: "--shortcut-ids",
},
{
name: "list blank conversation id",
cmd: newToolbarListCommand,
args: []string{"--conversation-id", " "},
wantError: "flag --conversation-id is required",
},
{
name: "sort blank conversation id",
cmd: newToolbarSortCommand,
args: []string{"--conversation-id", " ", "--sorted-ids", "1"},
wantError: "flag --conversation-id is required",
},
{
name: "sort blank sorted ids",
cmd: newToolbarSortCommand,
args: []string{"--conversation-id", "cid", "--sorted-ids", " "},
wantError: "flag --sorted-ids is required",
},
{
name: "sort invalid sorted ids",
cmd: newToolbarSortCommand,
args: []string{"--conversation-id", "cid", "--sorted-ids", "1,bad"},
wantError: "--sorted-ids",
},
{
name: "sort invalid unsorted ids",
cmd: newToolbarSortCommand,
args: []string{"--conversation-id", "cid", "--sorted-ids", "1", "--unsorted-ids", "bad"},
wantError: "--unsorted-ids",
},
{
name: "create blank conversation id",
cmd: newToolbarCreateCustomCommand,
args: toolbarCustomArgs("--conversation-id", " "),
wantError: "flag --conversation-id is required",
},
{
name: "create blank title",
cmd: newToolbarCreateCustomCommand,
args: toolbarCustomArgs("--title", " "),
wantError: "flag --title is required",
},
{
name: "create invalid extension",
cmd: newToolbarCreateCustomCommand,
args: toolbarCustomArgs("--extension", "bad"),
wantError: "--extension",
},
{
name: "remove blank conversation id",
cmd: newToolbarRemoveCustomCommand,
args: []string{"--conversation-id", " ", "--shortcut-id", "42", "--yes"},
wantError: "flag --conversation-id is required",
},
{
name: "update blank conversation id",
cmd: newToolbarUpdateCustomCommand,
args: toolbarCustomArgs("--shortcut-id", "42", "--conversation-id", " "),
wantError: "flag --conversation-id is required",
},
{
name: "update blank title",
cmd: newToolbarUpdateCustomCommand,
args: toolbarCustomArgs("--shortcut-id", "42", "--title", " "),
wantError: "flag --title is required",
},
{
name: "update invalid extension",
cmd: newToolbarUpdateCustomCommand,
args: toolbarCustomArgs("--shortcut-id", "42", "--extension", "bad"),
wantError: "--extension",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := executeToolbarCommand(t, tt.cmd(), &toolbarTestCaller{}, tt.args...)
if err == nil || !strings.Contains(err.Error(), tt.wantError) {
t.Fatalf("error = %v, want containing %q", err, tt.wantError)
}
})
}
}
func TestToolbarCommandsConvertSystemBusy(t *testing.T) {
tests := []struct {
name string
cmd func() *cobra.Command
args []string
}{
{
name: "add",
cmd: newToolbarAddCommand,
args: []string{"--conversation-id", "cid", "--shortcut-ids", "1,2"},
},
{
name: "hide",
cmd: newToolbarHideCommand,
args: []string{"--conversation-id", "cid", "--shortcut-ids", "1,2"},
},
{
name: "sort",
cmd: newToolbarSortCommand,
args: []string{"--conversation-id", "cid", "--sorted-ids", "1,2"},
},
{
name: "remove-custom",
cmd: newToolbarRemoveCustomCommand,
args: []string{"--conversation-id", "cid", "--shortcut-id", "1", "--yes"},
},
{
name: "create-custom",
cmd: newToolbarCreateCustomCommand,
args: toolbarCustomArgs(),
},
{
name: "update-custom",
cmd: newToolbarUpdateCustomCommand,
args: toolbarCustomArgs("--shortcut-id", "99"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := executeToolbarCommand(t, tt.cmd(), &toolbarTestCaller{
err: errors.New("remote SYSTEM_BUSY"),
}, tt.args...)
if err == nil || !strings.Contains(err.Error(), "SYSTEM_BUSY") {
t.Fatalf("expected system_busy validation error, got %v", err)
}
})
}
}
func TestToolbarCreateAndUpdateCustomIncludeOptionalFields(t *testing.T) {
createCaller := &toolbarTestCaller{}
err := executeToolbarCommand(t, newToolbarCreateCustomCommand(), createCaller, toolbarCustomArgs(
"--desc", "Description",
"--tag", "Tag",
"--sort-index", "7",
"--extension", "color=blue",
)...)
if err != nil {
t.Fatalf("create-custom returned error: %v", err)
}
requireToolbarCall(t, createCaller, "im", "create_custom_shortcut", map[string]any{
"openConversationId": "cid",
"name": `{"zh_CN":"Title"}`,
"url": "https://example.com/mobile",
"icons": `{"iconMobile":"https://example.com/icon.png"}`,
"pcUrl": "https://example.com/pc",
"desc": `{"zh_CN":"Description"}`,
"tag": "Tag",
"sortIndex": 7,
"extension": map[string]string{"color": "blue"},
})
updateCaller := &toolbarTestCaller{}
err = executeToolbarCommand(t, newToolbarUpdateCustomCommand(), updateCaller, toolbarCustomArgs(
"--shortcut-id", "99",
"--desc", "Description",
"--tag", "Tag",
"--sort-index", "7",
"--extension", "color=blue",
)...)
if err != nil {
t.Fatalf("update-custom returned error: %v", err)
}
requireToolbarCall(t, updateCaller, "im", "update_custom_shortcut", map[string]any{
"openConversationId": "cid",
"shortcutId": int64(99),
"name": `{"zh_CN":"Title"}`,
"url": "https://example.com/mobile",
"icons": `{"iconMobile":"https://example.com/icon.png"}`,
"pcUrl": "https://example.com/pc",
"desc": `{"zh_CN":"Description"}`,
"tag": "Tag",
"sortIndex": 7,
"extension": map[string]string{"color": "blue"},
})
}
func TestToolbarCreateCustomDefaultsDescToTitle(t *testing.T) {
tests := []struct {
name string
args []string
}{
{name: "missing desc", args: toolbarCustomArgs()},
{name: "blank desc", args: toolbarCustomArgs("--desc", " ")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &toolbarTestCaller{}
if err := executeToolbarCommand(t, newToolbarCreateCustomCommand(), caller, tt.args...); err != nil {
t.Fatalf("create-custom returned error: %v", err)
}
requireToolbarCall(t, caller, "im", "create_custom_shortcut", map[string]any{
"openConversationId": "cid",
"name": `{"zh_CN":"Title"}`,
"url": "https://example.com/mobile",
"icons": `{"iconMobile":"https://example.com/icon.png"}`,
"pcUrl": "https://example.com/pc",
"desc": `{"zh_CN":"Title"}`,
})
})
}
}
func TestToolbarUpdateCustomDefaultsDescToTitle(t *testing.T) {
tests := []struct {
name string
args []string
}{
{name: "missing desc", args: toolbarCustomArgs("--shortcut-id", "99")},
{name: "blank desc", args: toolbarCustomArgs("--shortcut-id", "99", "--desc", " ")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &toolbarTestCaller{}
if err := executeToolbarCommand(t, newToolbarUpdateCustomCommand(), caller, tt.args...); err != nil {
t.Fatalf("update-custom returned error: %v", err)
}
requireToolbarCall(t, caller, "im", "update_custom_shortcut", map[string]any{
"openConversationId": "cid",
"shortcutId": int64(99),
"name": `{"zh_CN":"Title"}`,
"url": "https://example.com/mobile",
"icons": `{"iconMobile":"https://example.com/icon.png"}`,
"pcUrl": "https://example.com/pc",
"desc": `{"zh_CN":"Title"}`,
})
})
}
}
func TestToolbarSortValidatesOptionalUnsortedIDs(t *testing.T) {
intersectionErr := executeToolbarCommand(t, newToolbarSortCommand(), &toolbarTestCaller{},
"--conversation-id", "cid",
"--sorted-ids", "1,2",
"--unsorted-ids", "2,3",
)
if intersectionErr == nil || !strings.Contains(intersectionErr.Error(), "不能有交集") {
t.Fatalf("expected id_intersection error, got %v", intersectionErr)
}
caller := &toolbarTestCaller{}
err := executeToolbarCommand(t, newToolbarSortCommand(), caller,
"--conversation-id", "cid",
"--sorted-ids", "1,2",
"--unsorted-ids", "3,4",
)
if err != nil {
t.Fatalf("sort returned error: %v", err)
}
requireToolbarCall(t, caller, "im", "sort_shortcut_bar", map[string]any{
"openConversationId": "cid",
"sortedShortcutIds": []int64{1, 2},
"unsortedShortcutIds": []int64{3, 4},
})
}
func TestCrossPlatformCoverageToolbarAddHideAndListCallMCPWithExactArgs(t *testing.T) {
tests := []struct {
name string
cmd func() *cobra.Command
args []string
wantTool string
wantArgs map[string]any
}{
{
name: "add",
cmd: newToolbarAddCommand,
args: []string{"--conversation-id", "cidX", "--shortcut-ids", "101,102"},
wantTool: "add_shortcut_to_bar",
wantArgs: map[string]any{
"openConversationId": "cidX",
"shortcutIds": []int64{101, 102},
},
},
{
name: "hide",
cmd: newToolbarHideCommand,
args: []string{"--conversation-id", "cidX", "--shortcut-ids", "201,202"},
wantTool: "hide_shortcut_from_bar",
wantArgs: map[string]any{
"openConversationId": "cidX",
"shortcutIds": []int64{201, 202},
},
},
{
name: "list",
cmd: newToolbarListCommand,
args: []string{"--conversation-id", "cidX"},
wantTool: "list_chat_toolbar_shortcuts",
wantArgs: map[string]any{
"openConversationId": "cidX",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &toolbarTestCaller{}
if err := executeToolbarCommand(t, tt.cmd(), caller, tt.args...); err != nil {
t.Fatalf("%s returned error: %v", tt.name, err)
}
requireToolbarCall(t, caller, "im", tt.wantTool, tt.wantArgs)
})
}
}
func TestCrossPlatformCoverageToolbarRemoveCustomRejectsWithoutYes(t *testing.T) {
caller := &toolbarTestCaller{}
err := executeToolbarCommand(t, newToolbarRemoveCustomCommand(), caller,
"--conversation-id", "cidX", "--shortcut-id", "42")
requireTypedConfirmationError(t, err)
if len(caller.calls) != 0 {
t.Fatalf("expected 0 MCP calls before --yes, got %d: %+v", len(caller.calls), caller.calls)
}
if caller.product != "" || caller.tool != "" || caller.args != nil {
t.Fatalf("expected no MCP call recorded, got product=%q tool=%q args=%#v", caller.product, caller.tool, caller.args)
}
}
func TestCrossPlatformCoverageToolbarRemoveCustomCallsMCPWithExactArgsWhenYes(t *testing.T) {
caller := &toolbarTestCaller{}
err := executeToolbarCommand(t, newToolbarRemoveCustomCommand(), caller,
"--conversation-id", "cidX", "--shortcut-id", "42", "--yes")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected exactly 1 MCP call, got %d: %+v", len(caller.calls), caller.calls)
}
call := caller.calls[0]
if call.product != "im" {
t.Fatalf("product = %q, want im", call.product)
}
if call.tool != "remove_custom_shortcut" {
t.Fatalf("tool = %q, want remove_custom_shortcut", call.tool)
}
wantArgs := map[string]any{"openConversationId": "cidX", "shortcutId": int64(42)}
if !reflect.DeepEqual(call.args, wantArgs) {
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
}
}
type removeCustomSeamCall struct {
openConversationId string
shortcutId int64
}
type removeCustomSeamStub struct {
calls []removeCustomSeamCall
err error
}
// invoke records the seam call and forwards to deps.Caller.CallTool so the
// command path still records the same remote invocation shape as production.
func (s *removeCustomSeamStub) invoke(openConversationId string, shortcutId int64) error {
s.calls = append(s.calls, removeCustomSeamCall{openConversationId: openConversationId, shortcutId: shortcutId})
if err := callMCPToolOnServer("im", "remove_custom_shortcut", map[string]any{
"openConversationId": openConversationId,
"shortcutId": shortcutId,
}); err != nil {
return err
}
return s.err
}
func TestCrossPlatformCoverageToolbarRemoveCustomSeamRejectsWithoutYes(t *testing.T) {
stub := &removeCustomSeamStub{}
testseam.Swap(t, &removeChatToolbarCustomShortcutFn, stub.invoke)
err := executeToolbarCommand(t, newToolbarRemoveCustomCommand(), &toolbarTestCaller{},
"--conversation-id", "cidX", "--shortcut-id", "42")
requireTypedConfirmationError(t, err)
if len(stub.calls) != 0 {
t.Fatalf("expected 0 seam calls before --yes, got %d: %+v", len(stub.calls), stub.calls)
}
}
func TestCrossPlatformCoverageToolbarRemoveCustomSeamCallsWithExactArgsWhenYes(t *testing.T) {
stub := &removeCustomSeamStub{}
testseam.Swap(t, &removeChatToolbarCustomShortcutFn, stub.invoke)
err := executeToolbarCommand(t, newToolbarRemoveCustomCommand(), &toolbarTestCaller{},
"--conversation-id", "cidX", "--shortcut-id", "42", "--yes")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(stub.calls) != 1 {
t.Fatalf("expected exactly 1 seam call, got %d: %+v", len(stub.calls), stub.calls)
}
call := stub.calls[0]
if call.openConversationId != "cidX" {
t.Fatalf("seam openConversationId = %q, want cidX", call.openConversationId)
}
if call.shortcutId != int64(42) {
t.Fatalf("seam shortcutId = %d, want 42", call.shortcutId)
}
}
func TestToolbarRemoveCustomDeclaresDestructiveHighRiskConfirmation(t *testing.T) {
var got LeafSpec
next := deps
next.DeclareLeafMetadata = func(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
got = spec
return cmd
}
testseam.Swap(t, &deps, next)
_ = newToolbarRemoveCustomCommand()
if got.Safety.Effect != "destructive" || got.Safety.Risk != "high" ||
got.Safety.Confirmation != "user_required" {
t.Fatalf("remove-custom safety = %#v, want destructive/high/user_required", got.Safety)
}
}
+92
View File
@@ -0,0 +1,92 @@
// 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 chat
import (
"fmt"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func newToolbarHideCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "hide",
Short: "将入口从快捷栏可见区隐藏",
Long: "将一个或多个快捷入口从可见区移至隐藏区。入口 ID 使用逗号分隔。",
Example: ` dws chat toolbar hide --conversation-id <cid> --shortcut-ids 101,102
# 查询入口 ID: dws chat toolbar list --conversation-id <cid>`,
RunE: func(cmd *cobra.Command, args []string) error {
cid, err := toolbarConversationID(cmd)
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "shortcut-ids"); err != nil {
return err
}
ids, err := parseCSVInt64(mustGetFlag(cmd, "shortcut-ids"))
if err != nil {
return fmt.Errorf("--shortcut-ids: %w", err)
}
err = callMCPToolOnServer("im", "hide_shortcut_from_bar", map[string]any{
"openConversationId": cid,
"shortcutIds": ids,
})
if isSystemBusy(err) {
return toolbarNewSystemBusyError()
}
return err
},
}
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
cmd.Flags().String("shortcut-ids", "", "入口 ID 列表(逗号分隔)")
_ = cmd.MarkFlagRequired("conversation-id")
_ = cmd.MarkFlagRequired("shortcut-ids")
cmd.DisableAutoGenTag = true
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat",
Name: "hide_shortcut_from_bar",
CanonicalPath: "chat.hide_shortcut_from_bar",
CLIPath: "chat toolbar hide",
PrimaryCLIPath: "chat toolbar hide",
},
Description: "将快捷入口从会话快捷栏可见区移至隐藏区",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "hide_shortcut_from_bar"},
},
Selection: contract.SelectionSpec{
AgentSummary: "将快捷入口从会话快捷栏可见区隐藏",
UseWhen: []string{"需要将快捷栏中的入口隐藏到隐藏区"},
AvoidWhen: []string{"永久删除自定义入口应使用 chat toolbar remove-custom"},
Examples: []string{"dws chat toolbar hide --conversation-id <cid> --shortcut-ids 101,102"},
},
Parameters: []contract.ParamDecl{
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
{Name: "shortcut-ids", Property: "shortcutIds", Required: boolPtr(true)},
},
},
})
return cmd
}
+75
View File
@@ -0,0 +1,75 @@
// 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 chat
import (
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func newToolbarListCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "查询会话快捷栏入口列表",
Long: "查询指定会话的快捷栏入口列表,包括可见区和隐藏区的所有入口。",
Example: ` dws chat toolbar list --conversation-id <cid>
# 查询群 ID: dws chat search --query "群名"`,
RunE: func(cmd *cobra.Command, args []string) error {
cid, err := toolbarConversationID(cmd)
if err != nil {
return err
}
return callMCPToolOnServer("im", "list_chat_toolbar_shortcuts", map[string]any{
"openConversationId": cid,
})
},
}
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
_ = cmd.MarkFlagRequired("conversation-id")
cmd.DisableAutoGenTag = true
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat",
Name: "list_chat_toolbar_shortcuts",
CanonicalPath: "chat.list_chat_toolbar_shortcuts",
CLIPath: "chat toolbar list",
PrimaryCLIPath: "chat toolbar list",
},
Description: "查询会话快捷栏入口列表",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "list_chat_toolbar_shortcuts"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询会话快捷栏入口列表",
UseWhen: []string{"需要查看会话快捷栏中有哪些入口、获取入口 ID 时"},
AvoidWhen: []string{"修改快捷栏排序使用 chat toolbar sort;创建自定义入口使用 chat toolbar create-custom"},
Examples: []string{"dws chat toolbar list --conversation-id <cid>"},
},
Parameters: []contract.ParamDecl{
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
},
},
})
return cmd
}
@@ -0,0 +1,103 @@
// 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 chat
import (
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
// removeChatToolbarCustomShortcutFn is the package-level injection seam for
// the remove-custom command's remote call. The default implementation routes
// through callMCPToolOnServer against the im server, so production behavior is
// unchanged. Tests swap it with testseam.Swap(t, &removeChatToolbarCustomShortcutFn, stub)
// to capture the call without hitting a real remote. Like every package-var
// seam in this repo it is not safe for t.Parallel tests.
var removeChatToolbarCustomShortcutFn = func(openConversationId string, shortcutId int64) error {
return callMCPToolOnServer("im", "remove_custom_shortcut", map[string]any{
"openConversationId": openConversationId,
"shortcutId": shortcutId,
})
}
func newToolbarRemoveCustomCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "remove-custom",
Short: "删除自定义快捷栏入口",
Long: "删除一个自定义快捷栏入口。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。",
Example: ` dws chat toolbar remove-custom --conversation-id <cid> --shortcut-id 123`,
RunE: func(cmd *cobra.Command, args []string) error {
cid, err := toolbarConversationID(cmd)
if err != nil {
return err
}
shortcutId, _ := cmd.Flags().GetInt64("shortcut-id")
if !commandBoolFlag(cmd, "yes") {
return apperrors.NewValidation(
"删除自定义快捷入口不可逆;获得用户确认后加 --yes 执行",
apperrors.WithReason("confirmation_required"),
apperrors.WithHint("先确认目标入口及影响范围;用户明确同意后以相同参数追加 --yes"),
apperrors.WithActions("确认目标自定义入口", "获得用户确认后使用 --yes 执行"),
)
}
err = removeChatToolbarCustomShortcutFn(cid, shortcutId)
if isSystemBusy(err) {
return toolbarNewSystemBusyError()
}
return err
},
}
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
cmd.Flags().Int64("shortcut-id", 0, "自定义入口 ID")
cmd.Flags().Bool("yes", false, "确认执行删除操作")
_ = cmd.MarkFlagRequired("conversation-id")
_ = cmd.MarkFlagRequired("shortcut-id")
cmd.DisableAutoGenTag = true
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "destructive", Risk: "high",
Confirmation: "user_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat",
Name: "remove_custom_shortcut",
CanonicalPath: "chat.remove_custom_shortcut",
CLIPath: "chat toolbar remove-custom",
PrimaryCLIPath: "chat toolbar remove-custom",
},
Description: "删除自定义快捷栏入口",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "remove_custom_shortcut"},
},
Selection: contract.SelectionSpec{
AgentSummary: "删除自定义快捷栏入口",
UseWhen: []string{"需要永久删除某个自定义快捷栏入口"},
AvoidWhen: []string{"仅隐藏入口使用 chat toolbar hide;更新入口使用 chat toolbar update-custom"},
Examples: []string{"dws chat toolbar remove-custom --conversation-id <cid> --shortcut-id 123"},
},
Parameters: []contract.ParamDecl{
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
{Name: "shortcut-id", Property: "shortcutId", Required: boolPtr(true)},
},
},
})
return cmd
}
+115
View File
@@ -0,0 +1,115 @@
// 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 chat
import (
"fmt"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
func newToolbarSortCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "sort",
Short: "排序快捷栏入口",
Long: "对会话快捷栏入口进行排序。sorted-ids 指定排序后的可见区入口 ID 列表,unsorted-ids 指定不参与排序放在末尾的入口 ID 列表。两个列表不能有交集。",
Example: ` dws chat toolbar sort --conversation-id <cid> --sorted-ids 101,102,103
dws chat toolbar sort --conversation-id <cid> --sorted-ids 101,102 --unsorted-ids 103,104
# 查询入口 ID: dws chat toolbar list --conversation-id <cid>`,
RunE: func(cmd *cobra.Command, args []string) error {
cid, err := toolbarConversationID(cmd)
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "sorted-ids"); err != nil {
return err
}
sortedIds, err := parseCSVInt64(mustGetFlag(cmd, "sorted-ids"))
if err != nil {
return fmt.Errorf("--sorted-ids: %w", err)
}
toolArgs := map[string]any{
"openConversationId": cid,
"sortedShortcutIds": sortedIds,
}
if unsortedRaw, _ := cmd.Flags().GetString("unsorted-ids"); unsortedRaw != "" {
unsortedIds, err := parseCSVInt64(unsortedRaw)
if err != nil {
return fmt.Errorf("--unsorted-ids: %w", err)
}
if hasIntersection(sortedIds, unsortedIds) {
return apperrors.NewValidation(
"--sorted-ids 与 --unsorted-ids 不能有交集",
apperrors.WithReason("id_intersection"),
apperrors.WithHint("检查两个 ID 列表,确保同一个 ID 不同时出现在两个列表中"),
apperrors.WithActions("修正 ID 列表后重试"),
)
}
toolArgs["unsortedShortcutIds"] = unsortedIds
}
err = callMCPToolOnServer("im", "sort_shortcut_bar", toolArgs)
if isSystemBusy(err) {
return toolbarNewSystemBusyError()
}
return err
},
}
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
cmd.Flags().String("sorted-ids", "", "排序后的入口 ID 列表(逗号分隔)")
cmd.Flags().String("unsorted-ids", "", "不参与排序放在末尾的入口 ID 列表(逗号分隔)")
_ = cmd.MarkFlagRequired("conversation-id")
_ = cmd.MarkFlagRequired("sorted-ids")
cmd.DisableAutoGenTag = true
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat",
Name: "sort_shortcut_bar",
CanonicalPath: "chat.sort_shortcut_bar",
CLIPath: "chat toolbar sort",
PrimaryCLIPath: "chat toolbar sort",
},
Description: "对会话快捷栏入口进行排序",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "sort_shortcut_bar"},
},
Selection: contract.SelectionSpec{
AgentSummary: "对会话快捷栏入口进行排序",
UseWhen: []string{"需要调整快捷栏入口的显示顺序"},
AvoidWhen: []string{"仅添加或隐藏入口使用 chat toolbar add / hide"},
Examples: []string{"dws chat toolbar sort --conversation-id <cid> --sorted-ids 101,102,103"},
},
Parameters: []contract.ParamDecl{
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
{Name: "sorted-ids", Property: "sortedShortcutIds", Required: boolPtr(true)},
{Name: "unsorted-ids", Property: "unsortedShortcutIds", Required: boolPtr(false)},
},
},
})
return cmd
}
@@ -0,0 +1,149 @@
// 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 chat
import (
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func newToolbarUpdateCustomCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update-custom",
Short: "更新自定义快捷栏入口",
Long: `更新一个已有的自定义快捷栏入口。
必填参数:
--conversation-id 会话 openConversationId
--shortcut-id 自定义入口 ID
--title 入口标题
--url 入口跳转链接
--icon-url 入口图标 URL
--pc-url PC 端跳转链接
可选参数:
--extension 扩展信息,格式 key=value,可重复使用
--desc 入口描述
--tag 入口标签
--sort-index 排序权重`,
Example: ` dws chat toolbar update-custom --conversation-id <cid> --shortcut-id 123 --title "周报" --url "https://example.com" --icon-url "https://example.com/icon.png" --pc-url "https://example.com"
# 查询入口 ID: dws chat toolbar list --conversation-id <cid>`,
RunE: func(cmd *cobra.Command, args []string) error {
cid, err := toolbarConversationID(cmd)
if err != nil {
return err
}
shortcutId, _ := cmd.Flags().GetInt64("shortcut-id")
if err := validateRequiredFlags(cmd, "title", "url", "icon-url", "pc-url"); err != nil {
return err
}
toolArgs := map[string]any{
"openConversationId": cid,
"shortcutId": shortcutId,
"name": toolbarI18nJSONString(mustGetFlag(cmd, "title")),
"url": mustGetFlag(cmd, "url"),
"icons": toolbarIconsJSONString(mustGetFlag(cmd, "icon-url")),
"pcUrl": mustGetFlag(cmd, "pc-url"),
}
descValue := mustGetFlag(cmd, "desc")
if descValue == "" {
descValue = mustGetFlag(cmd, "title")
}
toolArgs["desc"] = toolbarI18nJSONString(descValue)
if tag, _ := cmd.Flags().GetString("tag"); tag != "" {
toolArgs["tag"] = tag
}
if cmd.Flags().Changed("sort-index") {
sortIndex, _ := cmd.Flags().GetInt("sort-index")
toolArgs["sortIndex"] = sortIndex
}
ext, err := parseExtension(cmd)
if err != nil {
return err
}
if ext != nil {
toolArgs["extension"] = ext
}
err = callMCPToolOnServer("im", "update_custom_shortcut", toolArgs)
if isSystemBusy(err) {
return toolbarNewSystemBusyError()
}
return err
},
}
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
cmd.Flags().Int64("shortcut-id", 0, "自定义入口 ID")
cmd.Flags().String("title", "", "入口标题")
cmd.Flags().String("url", "", "入口跳转链接")
cmd.Flags().String("icon-url", "", "入口图标 URL")
cmd.Flags().String("pc-url", "", "PC 端跳转链接")
cmd.Flags().StringArray("extension", nil, "扩展信息,格式 key=value,可重复使用")
cmd.Flags().String("desc", "", "入口描述")
cmd.Flags().String("tag", "", "入口标签")
cmd.Flags().Int("sort-index", 0, "排序权重")
_ = cmd.MarkFlagRequired("conversation-id")
_ = cmd.MarkFlagRequired("shortcut-id")
_ = cmd.MarkFlagRequired("title")
_ = cmd.MarkFlagRequired("url")
_ = cmd.MarkFlagRequired("icon-url")
_ = cmd.MarkFlagRequired("pc-url")
cmd.DisableAutoGenTag = true
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat",
Name: "update_custom_shortcut",
CanonicalPath: "chat.update_custom_shortcut",
CLIPath: "chat toolbar update-custom",
PrimaryCLIPath: "chat toolbar update-custom",
},
Description: "更新自定义快捷栏入口",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "update_custom_shortcut"},
},
Selection: contract.SelectionSpec{
AgentSummary: "更新自定义快捷栏入口",
UseWhen: []string{"需要修改已有自定义快捷栏入口的标题、链接、图标等属性"},
AvoidWhen: []string{"创建新入口使用 chat toolbar create-custom;删除入口使用 chat toolbar remove-custom"},
Examples: []string{"dws chat toolbar update-custom --conversation-id <cid> --shortcut-id 123 --title \"周报\" --url \"https://example.com\" --icon-url \"https://example.com/icon.png\" --pc-url \"https://example.com\""},
},
Parameters: []contract.ParamDecl{
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
{Name: "shortcut-id", Property: "shortcutId", Required: boolPtr(true)},
{Name: "title", Property: "name", Required: boolPtr(true)},
{Name: "url", Property: "url", Required: boolPtr(true)},
{Name: "icon-url", Property: "icons", Required: boolPtr(true)},
{Name: "pc-url", Property: "pcUrl", Required: boolPtr(true)},
{Name: "extension", Property: "extension", Required: boolPtr(false)},
{Name: "desc", Property: "desc", Required: boolPtr(false)},
{Name: "tag", Property: "tag", Required: boolPtr(false)},
{Name: "sort-index", Property: "sortIndex", Required: boolPtr(false)},
},
},
})
return cmd
}
+125
View File
@@ -370,6 +370,131 @@ func TestCrossPlatformCoverageUploadConversationLocalFileCoverage(t *testing.T)
}
}
func TestCrossPlatformCoverageChatBotRichMediaCoverage(t *testing.T) {
testseam.Protect(t, &deps)
testseam.Swap(t, &httpPutFile, func(context.Context, string, map[string]string, string, int64) error {
return nil
})
filePath := filepath.Join(t.TempDir(), "report.pdf")
if err := os.WriteFile(filePath, []byte("report"), 0o600); err != nil {
t.Fatal(err)
}
for _, response := range []string{`{`, `{"result":{}}`} {
if _, err := parseConversationFileDownloadURL(response); err == nil {
t.Fatalf("parseConversationFileDownloadURL(%q) returned nil", response)
}
}
for _, tc := range []struct {
name string
args []string
}{
{name: "markdown requires title", args: []string{"--text", "message"}},
{name: "markdown requires text", args: []string{"--title", "title"}},
{name: "image requires URL", args: []string{"--msg-type", "image"}},
{name: "file requires path", args: []string{"--msg-type", "file"}},
} {
t.Run(tc.name, func(t *testing.T) {
args := []string{"message", "send-by-bot", "--robot-code", "robot", "--group", "group"}
if err := runChatCoverageCommand(t, &scriptedToolCaller{}, append(args, tc.args...)...); err == nil {
t.Fatal("missing rich-media input returned nil")
}
})
}
t.Run("group and direct targets are mutually exclusive", func(t *testing.T) {
err := runChatCoverageCommand(t, &scriptedToolCaller{},
"message", "send-by-bot", "--robot-code", "robot",
"--group", "group", "--users", "user", "--title", "title", "--text", "message",
)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("mutually exclusive target error = %v", err)
}
})
t.Run("direct markdown defaults to DX message type", func(t *testing.T) {
caller := &scriptedToolCaller{}
if err := runChatCoverageCommand(t, caller,
"message", "send-by-bot", "--robot-code", "robot", "--users", "user",
"--title", "title", "--text", "message",
); err != nil {
t.Fatal(err)
}
if caller.tool != "batch_send_robot_msg_to_users" || caller.args["msgType"] != "sampleMarkdownDX" {
t.Fatalf("direct markdown call = %s %#v", caller.tool, caller.args)
}
})
t.Run("direct image", func(t *testing.T) {
caller := &scriptedToolCaller{}
if err := runChatCoverageCommand(t, caller,
"message", "send-by-bot", "--robot-code", "robot", "--users", "user",
"--msg-type", "image", "--image-url", "https://example.test/image.png", "--at-all",
); err != nil {
t.Fatal(err)
}
if caller.tool != "batch_send_robot_msg_to_users" || caller.args["msgType"] != "sampleImageMsg" || caller.args["isAtAll"] != "true" {
t.Fatalf("direct image call = %s %#v", caller.tool, caller.args)
}
})
for _, tc := range []struct {
name string
target []string
}{
{name: "user file", target: []string{"--users", "user"}},
{name: "open DingTalk ID file", target: []string{"--open-dingtalk-ids", "D-user"}},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://upload.example/file","uploadKey":"key"}`},
{text: `{"result":{"downloadUrl":"https://download.example/report.pdf"}}`},
{text: `{"success":true}`},
}}
args := []string{"message", "send-by-bot", "--robot-code", "robot", "--msg-type", "file", "--file-path", filePath}
if err := runChatCoverageCommand(t, caller, append(args, tc.target...)...); err != nil {
t.Fatal(err)
}
if caller.calls != 3 || caller.tool != "batch_send_robot_msg_to_users" || caller.args["msgType"] != "sampleDingtalkDriveFile" || caller.args["fileUrl"] != "https://download.example/report.pdf" {
t.Fatalf("direct file call = %d %s %#v", caller.calls, caller.tool, caller.args)
}
})
}
t.Run("file dry run", func(t *testing.T) {
caller := &scriptedToolCaller{dry: true}
if err := runChatCoverageCommand(t, caller,
"message", "send-by-bot", "--robot-code", "robot", "--group", "group",
"--msg-type", "file", "--file-path", filePath,
); err != nil || caller.calls != 0 {
t.Fatalf("dry run error = %v, calls = %d", err, caller.calls)
}
})
t.Run("file rejects multiple recipients", func(t *testing.T) {
err := runChatCoverageCommand(t, &scriptedToolCaller{},
"message", "send-by-bot", "--robot-code", "robot", "--users", "one,two",
"--msg-type", "file", "--file-path", filePath,
)
if err == nil || !strings.Contains(err.Error(), "requires exactly one") {
t.Fatalf("multiple-recipient error = %v", err)
}
})
t.Run("file upload failure", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("upload failed")}}}
err := runChatCoverageCommand(t, caller,
"message", "send-by-bot", "--robot-code", "robot", "--group", "group",
"--msg-type", "file", "--file-path", filePath,
)
if err == nil || !strings.Contains(err.Error(), "upload failed") {
t.Fatalf("upload error = %v", err)
}
})
}
func TestCrossPlatformCoverageGuardGroupOwnerRemovalCoverage(t *testing.T) {
oldArgs := os.Args
os.Args = []string{"dws", "chat"}
@@ -0,0 +1,62 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageChatCatalogSafetyMetadataIsExplicit(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.AddCommand(newChatCommand())
checked := 0
walkChatCatalogLeaves(root, func(cmd *cobra.Command) {
final, ok := contractfinal.RuntimeContractFinal(cmd)
if !ok || final.Identity == nil || final.Safety == nil {
return
}
if strings.TrimSpace(final.Identity.ProductID) != "chat" {
return
}
checked++
canonical := strings.TrimSpace(final.Identity.CanonicalPath)
safety := final.Safety
if safety.Effect == "" || safety.Risk == "" ||
safety.Confirmation == "" || safety.Idempotency == "" {
t.Fatalf("chat Catalog leaf %s has incomplete Safety: %+v", canonical, *safety)
}
if safety.Effect == "read" {
if safety.Risk != "low" ||
safety.Confirmation != "not_required" ||
safety.Idempotency != "idempotent" {
t.Fatalf("chat read Catalog leaf %s Safety = %+v, want read/low/not_required/idempotent",
canonical, *safety)
}
}
})
if checked == 0 {
t.Fatal("no chat Catalog leaves checked")
}
}
func walkChatCatalogLeaves(cmd *cobra.Command, fn func(*cobra.Command)) {
if cmd == nil {
return
}
if cmd.Runnable() && !cmd.HasSubCommands() {
fn(cmd)
return
}
for _, child := range cmd.Commands() {
if child.Name() == "help" {
continue
}
walkChatCatalogLeaves(child, fn)
}
}
+34
View File
@@ -0,0 +1,34 @@
// 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 (
"github.com/spf13/cobra"
chathelpers "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers/chat"
)
func newChatToolbarCommand() *cobra.Command {
chathelpers.SetDeps(chathelpers.Deps{
GroupRunE: GroupRunE,
CallMCPToolOnServer: CallMCPToolOnServer,
DeclareLeafMetadata: func(cmd *cobra.Command, spec chathelpers.LeafSpec) *cobra.Command {
return DeclareLeafMetadata(cmd, LeafSpec{
Safety: spec.Safety,
Contract: spec.Contract,
})
},
})
return chathelpers.NewChatToolbarCommand()
}
+52
View File
@@ -0,0 +1,52 @@
package helpers
import (
"context"
"io"
"reflect"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type chatToolbarParentCaller struct {
product string
tool string
args map[string]any
}
func (c *chatToolbarParentCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.product = productID
c.tool = toolName
c.args = args
return textToolResult(`{"ok":true}`), nil
}
func (*chatToolbarParentCaller) Format() string { return "json" }
func (*chatToolbarParentCaller) DryRun() bool { return false }
func (*chatToolbarParentCaller) Fields() string { return "" }
func (*chatToolbarParentCaller) JQ() string { return "" }
func TestCrossPlatformCoverageChatToolbarMountedThroughParentDeps(t *testing.T) {
caller := &chatToolbarParentCaller{}
installHelpersCoreDeps(t, caller)
root := newChatCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
if command, _, err := root.Find([]string{"toolbar", "list"}); err != nil || command == nil || command.Name() != "list" {
t.Fatalf("find chat toolbar list = command %v, err %v", command, err)
}
root.SetArgs([]string{"toolbar", "list", "--conversation-id", "cidX"})
if err := root.Execute(); err != nil {
t.Fatalf("chat toolbar list returned error: %v", err)
}
if caller.product != "im" || caller.tool != "list_chat_toolbar_shortcuts" {
t.Fatalf("tool call = %s/%s, want im/list_chat_toolbar_shortcuts", caller.product, caller.tool)
}
wantArgs := map[string]any{"openConversationId": "cidX"}
if !reflect.DeepEqual(caller.args, wantArgs) {
t.Fatalf("tool args = %#v, want %#v", caller.args, wantArgs)
}
}
-1
View File
@@ -144,7 +144,6 @@ var (
"clear": {},
"refresh": {},
"recover": {},
"recovery": {},
"login": {},
"logout": {},
"register": {},
@@ -15,6 +15,7 @@ import (
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -80,6 +81,148 @@ func requireTypedConfirmationError(t *testing.T, err error) {
}
}
func TestChatChmodRequiresConfirmationBeforeRemoteCall(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"chmod", "chat.message:send",
"--agentCode", "wukong",
"--grant-type", "timed",
"--ttl", "24h",
"--conversation-id", "cidX",
)
requireTypedConfirmationError(t, err)
if len(caller.calls) != 0 {
t.Fatalf("expected 0 MCP calls before --yes, got %d: %+v", len(caller.calls), caller.calls)
}
}
func TestChatChmodCallsRemoteWithExactArgsWhenConfirmed(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"chmod", "chat.message:send",
"--agentCode", "wukong",
"--grant-type", "timed",
"--ttl", "24h",
"--conversation-id", "cidX",
"--yes",
)
if err != nil {
t.Fatalf("chat chmod with --yes returned error: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected exactly 1 MCP call, got %d: %+v", len(caller.calls), caller.calls)
}
call := caller.calls[0]
if call.productID != "im" || call.toolName != "chat_permission_grant" {
t.Fatalf("tool call = %s/%s, want im/chat_permission_grant", call.productID, call.toolName)
}
wantArgs := map[string]any{
"agentCode": "wukong",
"scope": "chat.message:send",
"grantType": "timed",
"ttl": "24h",
"grantParams": `{"conversationId":"cidX","openCid":"cidX","openConversationId":"cidX"}`,
}
if !reflect.DeepEqual(call.args, wantArgs) {
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
}
}
type guardedStepCaller struct {
calls []guardedMutationCall
steps []string
index int
dryRun bool
}
func (c *guardedStepCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, guardedMutationCall{productID: productID, toolName: toolName, args: args})
text := `{}`
if c.index < len(c.steps) {
text = c.steps[c.index]
}
c.index++
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
func (*guardedStepCaller) Format() string { return "json" }
func (c *guardedStepCaller) DryRun() bool { return c.dryRun }
func (*guardedStepCaller) Fields() string { return "" }
func (*guardedStepCaller) JQ() string { return "" }
func executeGuardedMailMutationCommand(t *testing.T, caller edition.ToolCaller, args ...string) error {
t.Helper()
testseam.Protect(t, &os.Args)
os.Args = append([]string{"dws", "mail"}, args...)
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
deps.Out.w = io.Discard
root := newMailCommand()
root.SilenceErrors = true
root.SilenceUsage = true
if root.InOrStdin() == os.Stdin {
root.SetIn(strings.NewReader(""))
}
root.SetArgs(args)
return root.Execute()
}
func TestMailMessageShareToChatRequiresConfirmationBeforeToolCall(t *testing.T) {
baseArgs := []string{
"message", "share-to-chat",
"--email", "user@company.com",
"--id", "msg-1",
"--users", "uid1",
}
caller := &guardedMutationCaller{}
err := executeGuardedMailMutationCommand(t, caller, baseArgs...)
requireTypedConfirmationError(t, err)
if len(caller.calls) != 0 {
t.Fatalf("tool calls = %#v, want none before confirmation", caller.calls)
}
stepCaller := &guardedStepCaller{steps: []string{
`{"result":{"sign":"sig","riskMessage":"careful"}}`,
`{"ok":true}`,
}}
err = executeGuardedMailMutationCommand(t, stepCaller, append(append([]string(nil), baseArgs...), "--yes")...)
if err != nil {
t.Fatalf("confirmed share with sign returned error: %v", err)
}
if len(stepCaller.calls) != 2 {
t.Fatalf("tool calls = %d, want 2 for sign retry", len(stepCaller.calls))
}
if stepCaller.calls[0].toolName != "share_message_to_chat" || stepCaller.calls[1].toolName != "share_message_to_chat" {
t.Fatalf("tool calls = %#v, want share_message_to_chat twice", stepCaller.calls)
}
if sign, _ := stepCaller.calls[1].args["sign"].(string); sign != "sig" {
t.Fatalf("second call sign = %q, want sig", sign)
}
directCaller := &guardedStepCaller{steps: []string{`{"ok":true}`}}
err = executeGuardedMailMutationCommand(t, directCaller, append(append([]string(nil), baseArgs...), "--yes")...)
if err != nil {
t.Fatalf("confirmed direct success returned error: %v", err)
}
if len(directCaller.calls) != 1 {
t.Fatalf("tool calls = %d, want 1 for direct success", len(directCaller.calls))
}
if directCaller.calls[0].toolName != "share_message_to_chat" {
t.Fatalf("tool call = %#v, want share_message_to_chat", directCaller.calls[0])
}
dryRunCaller := &guardedMutationCaller{dryRun: true}
err = executeGuardedMailMutationCommand(t, dryRunCaller, baseArgs...)
if err != nil {
t.Fatalf("dry-run without --yes returned error: %v", err)
}
if len(dryRunCaller.calls) != 0 {
t.Fatalf("dry-run tool calls = %#v, want none", dryRunCaller.calls)
}
}
func TestChatDismissGroupRequiresConfirmationBeforeToolCall(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,

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